Remove the per-swimlane-lists-era board migrations that made spurious "Restored Items" columns and empty-looking cards.

#6521: after upgrading toward 10.33 a board grew a new "Restored Items" column
full of cards that looked empty but were not. That column, and the card moves
behind it, came from obsolete board migrations that fight the CURRENT data model:

  * restoreLostCards - created a "Lost Cards" swimlane and a "Restored Items" list
    (i18n lost-cards / lost-cards-list) and moved cards into them. It called a card
    "orphaned" when its list was merely ARCHIVED (archived lists are excluded from
    the set it checks) and "lost" when its swimlaneId was '' - which is NORMAL for a
    board-wide shared card - so it dragged real, fine cards into "Restored Items".
  * comprehensiveBoardMigration / fixMissingListsMigration - converted today's
    board-wide SHARED lists (swimlaneId '') back into per-swimlane DUPLICATE
    columns, i.e. the exact damage the startup schema step merge-per-swimlane-lists
    now UNDOES.
  * restoreAllArchived - un-archived every swimlane/list/card at once.

All four had NO callers left - their admin migration dashboard was already
removed - so they were dead code exposed only as admin-callable Meteor methods, a
footgun that could re-corrupt a board if invoked. They are removed, along with
their imports and the stale commented-out imports in 00checkStartup.js.

The legitimate paths remain and are correct: the board-open self-heal
(repairBoardData) assigns a genuinely missing swimlaneId / relinks orphaned cards
to the board's real first list/swimlane, and the startup schema step
merge-per-swimlane-lists merges any per-swimlane duplicate columns back into one
shared list while cards keep their swimlaneId. It still keys off the era's board
markers (fixMissingListsCompleted / comprehensiveMigrationCompleted) in existing
data, so removing the code that once wrote them changes nothing for already-migrated
boards.

Adds tests/obsoleteBoardMigrationsRemoved.test.cjs so these cannot come back.

Thanks to hmeunier95 and xet7 !

Fixes #6521,
This commit is contained in:
Lauri Ojansivu committed 2026-07-24 16:18:24 +03:00
1 parent e28181734e
commit 52d68be755
8 files changed
+118 -1630

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@
},
"scripts": {
"test": "meteor test --once --driver-package meteortesting:mocha",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/ruleActionResolve.test.cjs && node tests/boardBackground.test.cjs && node tests/weekStart.test.cjs && node tests/ldapUsernameSlug.test.cjs && node tests/ldapGroupAllowlist.test.cjs && node tests/ldapConnectionRelease.test.cjs && node tests/htmljsArrayContent.test.cjs && node tests/cardDescriptionDraft.test.cjs && node tests/commentDraft.test.cjs && node tests/attachmentDeleteGuard.test.cjs && node tests/ruleMoveAction.test.cjs && node tests/snapMigrationRecovery.test.cjs && node tests/migrationAttachmentExtraction.test.cjs && node tests/attachmentRepair.test.cjs && node tests/attachmentStoragePath.test.cjs && node tests/schemaUpgradeSteps.test.cjs && node tests/cpuExec.test.cjs && node tests/cpuExecWiring.test.cjs && node tests/ferretdbHasData.test.cjs && node tests/ferretdbPolling.test.cjs && node tests/ferretdbWaitTimeout.test.cjs && node tests/ferretdbOplog.test.cjs && node tests/ferretdbOplogReset.test.cjs && node tests/ferretdbTextDataBackup.test.cjs && node tests/recoveryPlan.test.cjs && node tests/recoveryReportQuery.test.cjs && node tests/recoveryEventsJsonl.test.cjs && node tests/recoveryReportWiring.test.cjs && node tests/recoveryMaintenance.test.cjs && node tests/importMemberMapping.test.cjs && node tests/boardMemberMapPlan.test.cjs && node tests/withDeadline.test.cjs && node tests/importVirtualMembers.test.cjs && node tests/sandstormMigrationBridge.test.cjs && node tests/adminReportsPagination.test.cjs && node tests/fileNameDisplay.test.cjs && node tests/uploadFileName.test.cjs && node tests/diskSpace.test.cjs && node tests/fileTypeCorrection.test.cjs && node tests/fileHardeningGuards.test.cjs && node tests/cpuHighTracker.test.cjs && node tests/cpuMonitorWiring.test.cjs && node tests/buttonThemeColors.test.cjs && node tests/uiDensity.test.cjs && node tests/editorSubmitKey.test.cjs && node tests/isoWeek.test.cjs && node tests/boardCopyDefaults.test.cjs && node tests/linkedCardTitle.test.cjs && node tests/commentActivity.test.cjs && node tests/swimlaneSort.test.cjs && node tests/subtaskCopy.test.cjs && node tests/subtasksDefaultBoard.test.cjs && node tests/scrollbarCss.test.cjs && node tests/minicardBidiIsolation.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/popupOffset.test.cjs && node tests/popupAfterConfirm.test.cjs && node tests/defaultBoard.test.cjs && node tests/upcomingUiGuards.test.cjs && node tests/linkedCardCover.test.cjs && node tests/inviteEmailLanguage.test.cjs && node tests/domainTablePage.test.cjs && node tests/mongoSelectorSafety.test.cjs && node tests/filterDateNull.test.cjs && node tests/listMoveSwimlane.test.cjs && node tests/cardDragSwimlaneScrollRefresh.test.cjs && node tests/undoRedoSelection.test.cjs && node tests/softDelete.test.cjs && node tests/globalThemeColor.test.cjs && node tests/themeCategories.test.cjs && node tests/themeColorPicker.test.cjs && node tests/uiFonts.test.cjs && node tests/sandstormAutoOpen.test.cjs && node tests/createListParams.test.cjs && node tests/addBoardTileHeight.test.cjs && node tests/multiSelectionButton.test.cjs && node tests/headerHamburger.test.cjs && node tests/changeLanguageColumns.test.cjs && node tests/cardsLoading.test.cjs && node tests/base64Chunk.test.cjs && node tests/backupPaths.test.cjs && node tests/backupCron.test.cjs && node tests/avatarUrlSafety.test.cjs && node tests/importedUserReconciliationPlan.test.cjs && node tests/ldapPhoto.test.cjs && node tests/importedBoardMemberPlan.test.cjs && node tests/migrateNonFinite.test.cjs && node tests/addListComposerOptions.test.cjs && node tests/sidebarWidth.test.cjs && node tests/sidebarResize.test.cjs && node tests/securityMeifukun.test.cjs && node tests/securityLog.test.cjs && node tests/exporterBackpressure.test.cjs && node tests/ldapEmailFieldmap.test.cjs && node tests/listWidthDefaults.test.cjs && node tests/changelogCommitLinks.test.cjs && node tests/testsAreRegistered.test.cjs && node tests/dragDropTouch.test.cjs && node tests/statusActive.test.cjs && node tests/eventLogProblems.test.cjs && node tests/dragHandles.test.cjs && node tests/rebuildWekanDevServer.test.cjs && node tests/unit/resetPasswordEmail.test.js",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/ruleActionResolve.test.cjs && node tests/boardBackground.test.cjs && node tests/weekStart.test.cjs && node tests/ldapUsernameSlug.test.cjs && node tests/ldapGroupAllowlist.test.cjs && node tests/ldapConnectionRelease.test.cjs && node tests/htmljsArrayContent.test.cjs && node tests/cardDescriptionDraft.test.cjs && node tests/commentDraft.test.cjs && node tests/attachmentDeleteGuard.test.cjs && node tests/ruleMoveAction.test.cjs && node tests/snapMigrationRecovery.test.cjs && node tests/migrationAttachmentExtraction.test.cjs && node tests/attachmentRepair.test.cjs && node tests/attachmentStoragePath.test.cjs && node tests/schemaUpgradeSteps.test.cjs && node tests/cpuExec.test.cjs && node tests/cpuExecWiring.test.cjs && node tests/ferretdbHasData.test.cjs && node tests/ferretdbPolling.test.cjs && node tests/ferretdbWaitTimeout.test.cjs && node tests/ferretdbOplog.test.cjs && node tests/ferretdbOplogReset.test.cjs && node tests/ferretdbTextDataBackup.test.cjs && node tests/recoveryPlan.test.cjs && node tests/recoveryReportQuery.test.cjs && node tests/recoveryEventsJsonl.test.cjs && node tests/recoveryReportWiring.test.cjs && node tests/recoveryMaintenance.test.cjs && node tests/importMemberMapping.test.cjs && node tests/boardMemberMapPlan.test.cjs && node tests/withDeadline.test.cjs && node tests/importVirtualMembers.test.cjs && node tests/sandstormMigrationBridge.test.cjs && node tests/adminReportsPagination.test.cjs && node tests/fileNameDisplay.test.cjs && node tests/uploadFileName.test.cjs && node tests/diskSpace.test.cjs && node tests/fileTypeCorrection.test.cjs && node tests/fileHardeningGuards.test.cjs && node tests/cpuHighTracker.test.cjs && node tests/cpuMonitorWiring.test.cjs && node tests/buttonThemeColors.test.cjs && node tests/uiDensity.test.cjs && node tests/editorSubmitKey.test.cjs && node tests/isoWeek.test.cjs && node tests/boardCopyDefaults.test.cjs && node tests/linkedCardTitle.test.cjs && node tests/commentActivity.test.cjs && node tests/swimlaneSort.test.cjs && node tests/subtaskCopy.test.cjs && node tests/subtasksDefaultBoard.test.cjs && node tests/scrollbarCss.test.cjs && node tests/minicardBidiIsolation.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/popupOffset.test.cjs && node tests/popupAfterConfirm.test.cjs && node tests/defaultBoard.test.cjs && node tests/upcomingUiGuards.test.cjs && node tests/linkedCardCover.test.cjs && node tests/inviteEmailLanguage.test.cjs && node tests/domainTablePage.test.cjs && node tests/mongoSelectorSafety.test.cjs && node tests/filterDateNull.test.cjs && node tests/listMoveSwimlane.test.cjs && node tests/cardDragSwimlaneScrollRefresh.test.cjs && node tests/undoRedoSelection.test.cjs && node tests/softDelete.test.cjs && node tests/globalThemeColor.test.cjs && node tests/themeCategories.test.cjs && node tests/themeColorPicker.test.cjs && node tests/uiFonts.test.cjs && node tests/sandstormAutoOpen.test.cjs && node tests/createListParams.test.cjs && node tests/addBoardTileHeight.test.cjs && node tests/multiSelectionButton.test.cjs && node tests/headerHamburger.test.cjs && node tests/changeLanguageColumns.test.cjs && node tests/cardsLoading.test.cjs && node tests/base64Chunk.test.cjs && node tests/backupPaths.test.cjs && node tests/backupCron.test.cjs && node tests/avatarUrlSafety.test.cjs && node tests/importedUserReconciliationPlan.test.cjs && node tests/ldapPhoto.test.cjs && node tests/importedBoardMemberPlan.test.cjs && node tests/migrateNonFinite.test.cjs && node tests/addListComposerOptions.test.cjs && node tests/sidebarWidth.test.cjs && node tests/sidebarResize.test.cjs && node tests/securityMeifukun.test.cjs && node tests/securityLog.test.cjs && node tests/exporterBackpressure.test.cjs && node tests/ldapEmailFieldmap.test.cjs && node tests/listWidthDefaults.test.cjs && node tests/changelogCommitLinks.test.cjs && node tests/testsAreRegistered.test.cjs && node tests/dragDropTouch.test.cjs && node tests/statusActive.test.cjs && node tests/eventLogProblems.test.cjs && node tests/obsoleteBoardMigrationsRemoved.test.cjs && node tests/dragHandles.test.cjs && node tests/rebuildWekanDevServer.test.cjs && node tests/unit/resetPasswordEmail.testLine truncated
"test:unit:all": "npm run test:unit:node && node tests/cards.stickers.test.js && node tests/stickers.test.js && node tests/stickers.packs.test.js && node tests/linkCardPopup.test.js && node tests/trelloCreator.import.test.js && node tests/trelloImport.maplink.test.js && node tests/trelloImport.zip.test.js && node tests/wekanCreator.import.test.js && node tests/wekanCreator.inconsistent.test.js && node tests/unit/oauth2-admin-groups.test.js",
"test:watch": "meteor test --driver-package meteortesting:mocha",
"test:e2e": "node tests/e2e/list-regressions.js",
+1 -2
View File
@@ -28,10 +28,9 @@ if (errors.length > 0) {
// Import migrations - COMMENTED OUT
// import './migrations/fixMissingListsMigration';
// import './migrations/fixAvatarUrls';
// import './migrations/fixAllFileUrls';
// import './migrations/comprehensiveBoardMigration';
// (fixMissingListsMigration / comprehensiveBoardMigration removed in #6521)
// Import file serving routes
import './routes/universalFileServer';
+10 -4
View File
@@ -198,16 +198,22 @@ import '/server/startup/repairBoardsOnStartup';
// ----------------------------------------------------------------------------
// 11. Server — migrations
// ----------------------------------------------------------------------------
import '/server/migrations/comprehensiveBoardMigration';
// #6521: the per-swimlane-lists-era board migrations (comprehensiveBoardMigration,
// fixMissingListsMigration, restoreLostCards, restoreAllArchived) are removed. They
// converted today's board-wide SHARED lists (swimlaneId '') back into per-swimlane
// DUPLICATE columns and created "Lost Cards" / "Restored Items" columns, moving
// real cards into them (they even treated cards on archived lists as "orphaned" and
// shared-swimlane cards as "lost"). That is exactly the damage the startup schema
// step `merge-per-swimlane-lists` now UNDOES, and the board-open self-heal
// (repairBoardData) fixes genuinely missing swimlaneId / orphaned cards correctly.
// The migrations had no callers left (their admin migration dashboard was removed),
// so they were dead, admin-callable footguns.
import '/server/migrations/correctFileExtensions';
import '/server/migrations/deleteDuplicateEmptyLists';
import '/server/migrations/ensureValidSwimlaneIds';
import '/server/migrations/fixAllFileUrls';
import '/server/migrations/fixAvatarUrls';
import '/server/migrations/fixMissingListsMigration';
import '/server/migrations/migrateAttachments';
import '/server/migrations/restoreAllArchived';
import '/server/migrations/restoreLostCards';
// #6473: startup schema upgrade — checks what old-version data is already
// migrated and migrates only the rest (all platforms, both databases).
import '/server/startupSchemaUpgrade';
@@ -1,787 +0,0 @@
/**
* Comprehensive Board Migration System
*
* This migration handles all database structure changes from previous Wekan versions
* to the current per-swimlane lists structure. It ensures:
*
* 1. All cards are visible with proper swimlaneId and listId
* 2. Lists are per-swimlane (no shared lists across swimlanes)
* 3. No empty lists are created
* 4. Handles various database structure versions from git history
*
* Supported versions and their database structures:
* - v7.94 and earlier: Shared lists across all swimlanes
* - v8.00-v8.02: Transition period with mixed structures
* - v8.03+: Per-swimlane lists structure
*/
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';
import Users from '/models/users';
import Attachments from '/models/attachments';
import { generateUniversalAttachmentUrl, isUniversalFileUrl } from '/models/lib/universalUrlGenerator';
class ComprehensiveBoardMigration {
constructor() {
this.name = 'comprehensive-board-migration';
this.version = 1;
this.migrationSteps = [
'analyze_board_structure',
'fix_orphaned_cards',
'convert_shared_lists',
'ensure_per_swimlane_lists',
'validate_migration'
];
}
/**
* Check if migration is needed for a board
*/
async needsMigration(boardId) {
try {
const board = await ReactiveCache.getBoard(boardId);
if (!board) return false;
// Check if board has already been processed
if (board.comprehensiveMigrationCompleted) {
return false;
}
// Check for various issues that need migration
const issues = await this.detectMigrationIssues(boardId);
return issues.length > 0;
} catch (error) {
console.error('Error checking if migration is needed:', error);
return false;
}
}
/**
* Detect all migration issues in a board
*/
async detectMigrationIssues(boardId) {
const issues = [];
try {
const cards = await ReactiveCache.getCards({ boardId });
const lists = await ReactiveCache.getLists({ boardId });
const swimlanes = await ReactiveCache.getSwimlanes({ boardId });
// Issue 1: Cards with missing swimlaneId
const cardsWithoutSwimlane = cards.filter(card => !card.swimlaneId);
if (cardsWithoutSwimlane.length > 0) {
issues.push({
type: 'cards_without_swimlane',
count: cardsWithoutSwimlane.length,
description: `${cardsWithoutSwimlane.length} cards missing swimlaneId`
});
}
// Issue 2: Cards with missing listId
const cardsWithoutList = cards.filter(card => !card.listId);
if (cardsWithoutList.length > 0) {
issues.push({
type: 'cards_without_list',
count: cardsWithoutList.length,
description: `${cardsWithoutList.length} cards missing listId`
});
}
// Issue 3: Lists without swimlaneId (shared lists)
const sharedLists = lists.filter(list => !list.swimlaneId || list.swimlaneId === '');
if (sharedLists.length > 0) {
issues.push({
type: 'shared_lists',
count: sharedLists.length,
description: `${sharedLists.length} lists without swimlaneId (shared lists)`
});
}
// Issue 4: Cards with mismatched listId/swimlaneId
const listSwimlaneMap = new Map();
lists.forEach(list => {
listSwimlaneMap.set(list._id, list.swimlaneId || '');
});
const mismatchedCards = cards.filter(card => {
if (!card.listId || !card.swimlaneId) return false;
const listSwimlaneId = listSwimlaneMap.get(card.listId);
return listSwimlaneId && listSwimlaneId !== card.swimlaneId;
});
if (mismatchedCards.length > 0) {
issues.push({
type: 'mismatched_cards',
count: mismatchedCards.length,
description: `${mismatchedCards.length} cards with mismatched listId/swimlaneId`
});
}
// Issue 5: Empty lists (lists with no cards)
const emptyLists = lists.filter(list => {
const listCards = cards.filter(card => card.listId === list._id);
return listCards.length === 0;
});
if (emptyLists.length > 0) {
issues.push({
type: 'empty_lists',
count: emptyLists.length,
description: `${emptyLists.length} empty lists (no cards)`
});
}
} catch (error) {
console.error('Error detecting migration issues:', error);
issues.push({
type: 'detection_error',
count: 1,
description: `Error detecting issues: ${error.message}`
});
}
return issues;
}
/**
* Execute the comprehensive migration for a board
*/
async executeMigration(boardId, progressCallback = null) {
try {
if (process.env.DEBUG === 'true') {
console.log(`Starting comprehensive board migration for board ${boardId}`);
}
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
throw new Error(`Board ${boardId} not found`);
}
const results = {
boardId,
steps: {},
totalCardsProcessed: 0,
totalListsProcessed: 0,
totalListsCreated: 0,
errors: []
};
const totalSteps = this.migrationSteps.length;
let currentStep = 0;
// Helper function to update progress
const updateProgress = (stepName, stepProgress, stepStatus, stepDetails = null) => {
currentStep++;
const overallProgress = Math.round((currentStep / totalSteps) * 100);
const progressData = {
overallProgress,
currentStep: currentStep,
totalSteps,
stepName,
stepProgress,
stepStatus,
stepDetails,
boardId
};
if (progressCallback) {
progressCallback(progressData);
}
if (process.env.DEBUG === 'true') {
console.log(`Migration Progress: ${stepName} - ${stepStatus} (${stepProgress}%)`);
}
};
// Step 1: Analyze board structure
updateProgress('analyze_board_structure', 0, 'Starting analysis...');
results.steps.analyze = await this.analyzeBoardStructure(boardId);
updateProgress('analyze_board_structure', 100, 'Analysis complete', {
issuesFound: results.steps.analyze.issueCount,
needsMigration: results.steps.analyze.needsMigration
});
// Step 2: Fix orphaned cards
updateProgress('fix_orphaned_cards', 0, 'Fixing orphaned cards...');
results.steps.fixOrphanedCards = await this.fixOrphanedCards(boardId, (progress, status) => {
updateProgress('fix_orphaned_cards', progress, status);
});
results.totalCardsProcessed += results.steps.fixOrphanedCards.cardsFixed || 0;
updateProgress('fix_orphaned_cards', 100, 'Orphaned cards fixed', {
cardsFixed: results.steps.fixOrphanedCards.cardsFixed
});
// Step 3: Convert shared lists to per-swimlane lists
updateProgress('convert_shared_lists', 0, 'Converting shared lists...');
results.steps.convertSharedLists = await this.convertSharedListsToPerSwimlane(boardId, (progress, status) => {
updateProgress('convert_shared_lists', progress, status);
});
results.totalListsProcessed += results.steps.convertSharedLists.listsProcessed || 0;
results.totalListsCreated += results.steps.convertSharedLists.listsCreated || 0;
updateProgress('convert_shared_lists', 100, 'Shared lists converted', {
listsProcessed: results.steps.convertSharedLists.listsProcessed,
listsCreated: results.steps.convertSharedLists.listsCreated
});
// Step 4: Ensure all lists are per-swimlane
updateProgress('ensure_per_swimlane_lists', 0, 'Ensuring per-swimlane structure...');
results.steps.ensurePerSwimlane = await this.ensurePerSwimlaneLists(boardId);
results.totalListsProcessed += results.steps.ensurePerSwimlane.listsProcessed || 0;
updateProgress('ensure_per_swimlane_lists', 100, 'Per-swimlane structure ensured', {
listsProcessed: results.steps.ensurePerSwimlane.listsProcessed
});
// Step 5: Validate migration
updateProgress('validate_migration', 0, 'Validating migration...');
results.steps.validate = await this.validateMigration(boardId);
updateProgress('validate_migration', 100, 'Migration validated', {
migrationSuccessful: results.steps.validate.migrationSuccessful,
totalCards: results.steps.validate.totalCards,
totalLists: results.steps.validate.totalLists
});
// Step 6: Fix avatar URLs
updateProgress('fix_avatar_urls', 0, 'Fixing avatar URLs...');
results.steps.fixAvatarUrls = await this.fixAvatarUrls();
updateProgress('fix_avatar_urls', 100, 'Avatar URLs fixed', {
avatarsFixed: results.steps.fixAvatarUrls.avatarsFixed
});
// Step 8: Fix attachment URLs
updateProgress('fix_attachment_urls', 0, 'Fixing attachment URLs...');
results.steps.fixAttachmentUrls = await this.fixAttachmentUrls();
updateProgress('fix_attachment_urls', 100, 'Attachment URLs fixed', {
attachmentsFixed: results.steps.fixAttachmentUrls.attachmentsFixed
});
// Mark board as processed
await Boards.updateAsync(boardId, {
$set: {
comprehensiveMigrationCompleted: true,
comprehensiveMigrationCompletedAt: new Date(),
comprehensiveMigrationResults: results
}
});
if (process.env.DEBUG === 'true') {
console.log(`Comprehensive board migration completed for board ${boardId}:`, results);
}
return {
success: true,
results
};
} catch (error) {
console.error(`Error executing comprehensive migration for board ${boardId}:`, error);
throw error;
}
}
/**
* Step 1: Analyze board structure
*/
async analyzeBoardStructure(boardId) {
const issues = await this.detectMigrationIssues(boardId);
return {
issues,
issueCount: issues.length,
needsMigration: issues.length > 0
};
}
/**
* Step 2: Fix orphaned cards (cards with missing swimlaneId or listId)
*/
async fixOrphanedCards(boardId, progressCallback = null) {
const cards = await ReactiveCache.getCards({ boardId });
const swimlanes = await ReactiveCache.getSwimlanes({ boardId });
const lists = await ReactiveCache.getLists({ boardId });
let cardsFixed = 0;
const defaultSwimlane = swimlanes.find(s => s.title === 'Default') || swimlanes[0];
const totalCards = cards.length;
for (let i = 0; i < cards.length; i++) {
const card = cards[i];
let needsUpdate = false;
const updates = {};
// Fix missing swimlaneId
if (!card.swimlaneId) {
updates.swimlaneId = defaultSwimlane._id;
needsUpdate = true;
}
// Fix missing listId
if (!card.listId) {
// Find or create a default list for this swimlane
const swimlaneId = updates.swimlaneId || card.swimlaneId;
let defaultList = lists.find(list =>
list.swimlaneId === swimlaneId && list.title === 'Default'
);
if (!defaultList) {
// Create a default list for this swimlane
const newListId = await Lists.insertAsync({
title: 'Default',
boardId: boardId,
swimlaneId: swimlaneId,
sort: 0,
archived: false,
createdAt: new Date(),
modifiedAt: new Date(),
type: 'list'
});
defaultList = { _id: newListId };
}
updates.listId = defaultList._id;
needsUpdate = true;
}
if (needsUpdate) {
await Cards.updateAsync(card._id, {
$set: {
...updates,
modifiedAt: new Date()
}
});
cardsFixed++;
}
// Update progress
if (progressCallback && (i % 10 === 0 || i === totalCards - 1)) {
const progress = Math.round(((i + 1) / totalCards) * 100);
progressCallback(progress, `Processing card ${i + 1} of ${totalCards}...`);
}
}
return { cardsFixed };
}
/**
* Step 3: Convert shared lists to per-swimlane lists
*/
async convertSharedListsToPerSwimlane(boardId, progressCallback = null) {
const cards = await ReactiveCache.getCards({ boardId });
const lists = await ReactiveCache.getLists({ boardId });
const swimlanes = await ReactiveCache.getSwimlanes({ boardId });
let listsProcessed = 0;
let listsCreated = 0;
// 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);
});
const swimlaneEntries = Array.from(cardsBySwimlane.entries());
const totalSwimlanes = swimlaneEntries.length;
// Process each swimlane
for (let i = 0; i < swimlaneEntries.length; i++) {
const [swimlaneId, swimlaneCards] = swimlaneEntries[i];
if (!swimlaneId) continue;
if (progressCallback) {
const progress = Math.round(((i + 1) / totalSwimlanes) * 100);
progressCallback(progress, `Processing swimlane ${i + 1} of ${totalSwimlanes}...`);
}
// Get existing lists for this swimlane
const existingLists = lists.filter(list => list.swimlaneId === 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
if (originalList.swimlaneId === swimlaneId) {
// List is already correctly assigned to this swimlane
listsProcessed++;
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 = await Lists.insertAsync(newListData);
targetList = { _id: newListId, ...newListData };
listsCreated++;
}
// Update all cards in this group to use the correct listId
for (const card of cardsInList) {
await Cards.updateAsync(card._id, {
$set: {
listId: targetList._id,
modifiedAt: new Date()
}
});
}
listsProcessed++;
}
}
return { listsProcessed, listsCreated };
}
/**
* Step 4: Ensure all lists are per-swimlane
*/
async ensurePerSwimlaneLists(boardId) {
const lists = await ReactiveCache.getLists({ boardId });
const swimlanes = await ReactiveCache.getSwimlanes({ boardId });
const defaultSwimlane = swimlanes.find(s => s.title === 'Default') || swimlanes[0];
let listsProcessed = 0;
for (const list of lists) {
if (!list.swimlaneId || list.swimlaneId === '') {
// Assign to default swimlane
await Lists.updateAsync(list._id, {
$set: {
swimlaneId: defaultSwimlane._id,
modifiedAt: new Date()
}
});
listsProcessed++;
}
}
return { listsProcessed };
}
/**
* Step 5: Cleanup empty lists (lists with no cards)
*/
async cleanupEmptyLists(boardId) {
const lists = await ReactiveCache.getLists({ boardId });
const cards = await ReactiveCache.getCards({ boardId });
let listsRemoved = 0;
for (const list of lists) {
const listCards = cards.filter(card => card.listId === list._id);
if (listCards.length === 0) {
// Remove empty list
await Lists.removeAsync(list._id);
listsRemoved++;
if (process.env.DEBUG === 'true') {
console.log(`Removed empty list: ${list.title} (${list._id})`);
}
}
}
return { listsRemoved };
}
/**
* Step 6: Validate migration
*/
async validateMigration(boardId) {
const issues = await this.detectMigrationIssues(boardId);
const cards = await ReactiveCache.getCards({ boardId });
const lists = await ReactiveCache.getLists({ boardId });
// Check that all cards have valid swimlaneId and listId
const validCards = cards.filter(card => card.swimlaneId && card.listId);
const invalidCards = cards.length - validCards.length;
// Check that all lists have swimlaneId
const validLists = lists.filter(list => list.swimlaneId && list.swimlaneId !== '');
const invalidLists = lists.length - validLists.length;
return {
issuesRemaining: issues.length,
totalCards: cards.length,
validCards,
invalidCards,
totalLists: lists.length,
validLists,
invalidLists,
migrationSuccessful: issues.length === 0 && invalidCards === 0 && invalidLists === 0
};
}
/**
* Step 7: Fix avatar URLs (remove problematic auth parameters and fix URL formats)
*/
async fixAvatarUrls() {
const users = await ReactiveCache.getUsers({});
let avatarsFixed = 0;
for (const user of users) {
if (user.profile && user.profile.avatarUrl) {
const avatarUrl = user.profile.avatarUrl;
let needsUpdate = false;
let cleanUrl = avatarUrl;
// Check if URL has problematic parameters
if (avatarUrl.includes('auth=false') || avatarUrl.includes('brokenIsFine=true')) {
// Remove problematic parameters
cleanUrl = cleanUrl.replace(/[?&]auth=false/g, '');
cleanUrl = cleanUrl.replace(/[?&]brokenIsFine=true/g, '');
cleanUrl = cleanUrl.replace(/\?&/g, '?');
cleanUrl = cleanUrl.replace(/\?$/g, '');
needsUpdate = true;
}
// Check if URL is using old CollectionFS format
if (avatarUrl.includes('/cfs/files/avatars/')) {
cleanUrl = cleanUrl.replace('/cfs/files/avatars/', '/cdn/storage/avatars/');
needsUpdate = true;
}
// Check if URL is missing the /cdn/storage/avatars/ prefix
if (avatarUrl.includes('avatars/') && !avatarUrl.includes('/cdn/storage/avatars/') && !avatarUrl.includes('/cfs/files/avatars/')) {
// This might be a relative URL, make it absolute
if (!avatarUrl.startsWith('http') && !avatarUrl.startsWith('/')) {
cleanUrl = `/cdn/storage/avatars/${avatarUrl}`;
needsUpdate = true;
}
}
if (needsUpdate) {
// Update user's avatar URL
await Users.updateAsync(user._id, {
$set: {
'profile.avatarUrl': cleanUrl,
modifiedAt: new Date()
}
});
avatarsFixed++;
}
}
}
return { avatarsFixed };
}
/**
* Step 8: Fix attachment URLs (remove problematic auth parameters and fix URL formats)
*/
async fixAttachmentUrls() {
const attachments = await ReactiveCache.getAttachments({});
let attachmentsFixed = 0;
for (const attachment of attachments) {
// Check if attachment has URL field that needs fixing
if (attachment.url) {
const attachmentUrl = attachment.url;
let needsUpdate = false;
let cleanUrl = attachmentUrl;
// Check if URL has problematic parameters
if (attachmentUrl.includes('auth=false') || attachmentUrl.includes('brokenIsFine=true')) {
// Remove problematic parameters
cleanUrl = cleanUrl.replace(/[?&]auth=false/g, '');
cleanUrl = cleanUrl.replace(/[?&]brokenIsFine=true/g, '');
cleanUrl = cleanUrl.replace(/\?&/g, '?');
cleanUrl = cleanUrl.replace(/\?$/g, '');
needsUpdate = true;
}
// Check if URL is using old CollectionFS format
if (attachmentUrl.includes('/cfs/files/attachments/')) {
cleanUrl = cleanUrl.replace('/cfs/files/attachments/', '/cdn/storage/attachments/');
needsUpdate = true;
}
// Check if URL has /original/ path that should be removed
if (attachmentUrl.includes('/original/')) {
cleanUrl = cleanUrl.replace(/\/original\/[^\/\?#]+/, '');
needsUpdate = true;
}
// If we have a file ID, generate a universal URL
const fileId = attachment._id;
if (fileId && !isUniversalFileUrl(cleanUrl, 'attachment')) {
cleanUrl = generateUniversalAttachmentUrl(fileId);
needsUpdate = true;
}
if (needsUpdate) {
// Update attachment URL
await Attachments.updateAsync(attachment._id, {
$set: {
url: cleanUrl,
modifiedAt: new Date()
}
});
attachmentsFixed++;
}
}
}
return { attachmentsFixed };
}
/**
* Get migration status for a board
*/
async getMigrationStatus(boardId) {
try {
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
return { status: 'board_not_found' };
}
if (board.comprehensiveMigrationCompleted) {
return {
status: 'completed',
completedAt: board.comprehensiveMigrationCompletedAt,
results: board.comprehensiveMigrationResults
};
}
const needsMigration = await this.needsMigration(boardId);
const issues = await this.detectMigrationIssues(boardId);
return {
status: needsMigration ? 'needed' : 'not_needed',
issues,
issueCount: issues.length
};
} catch (error) {
console.error('Error getting migration status:', error);
return { status: 'error', error: error.message };
}
}
}
// Export singleton instance
export const comprehensiveBoardMigration = new ComprehensiveBoardMigration();
// Meteor methods
Meteor.methods({
async 'comprehensiveBoardMigration.check'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return await comprehensiveBoardMigration.getMigrationStatus(boardId);
},
async 'comprehensiveBoardMigration.execute'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
const user = await ReactiveCache.getUser(this.userId);
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('board-not-found');
}
const isBoardAdmin = board.hasAdmin(this.userId);
const isInstanceAdmin = user && user.isAdmin;
if (!isBoardAdmin && !isInstanceAdmin) {
throw new Meteor.Error('not-authorized', 'You must be a board admin or instance admin to perform this action.');
}
return await comprehensiveBoardMigration.executeMigration(boardId);
},
async 'comprehensiveBoardMigration.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return await comprehensiveBoardMigration.needsMigration(boardId);
},
async 'comprehensiveBoardMigration.detectIssues'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return await comprehensiveBoardMigration.detectMigrationIssues(boardId);
},
async 'comprehensiveBoardMigration.fixAvatarUrls'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
const user = await ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Only instance admins can perform this action.');
}
return await comprehensiveBoardMigration.fixAvatarUrls();
},
async 'comprehensiveBoardMigration.fixAttachmentUrls'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
const user = await ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Only instance admins can perform this action.');
}
return await comprehensiveBoardMigration.fixAttachmentUrls();
}
});
@@ -1,306 +0,0 @@
/**
* 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';
import Boards from '/models/boards';
import Lists from '/models/lists';
import Cards from '/models/cards';
class FixMissingListsMigration {
constructor() {
this.name = 'fix-missing-lists';
this.version = 1;
}
/**
* Check if migration is needed for a board
*/
async needsMigration(boardId) {
try {
const board = await 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 = await ReactiveCache.getCards({ boardId });
const lists = await 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) {
if (process.env.DEBUG === 'true') {
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 {
if (process.env.DEBUG === 'true') {
console.log(`Starting fix missing lists migration for board ${boardId}`);
}
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
throw new Error(`Board ${boardId} not found`);
}
const cards = await ReactiveCache.getCards({ boardId });
const lists = await ReactiveCache.getLists({ boardId });
const swimlanes = await 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 = await Lists.insertAsync(newListData);
targetList = { _id: newListId, ...newListData };
createdLists++;
if (process.env.DEBUG === 'true') {
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) {
await Cards.updateAsync(card._id, {
$set: {
listId: targetList._id,
modifiedAt: new Date()
}
});
updatedCards++;
}
}
}
// Mark board as processed
await Boards.updateAsync(boardId, {
$set: {
fixMissingListsCompleted: true,
fixMissingListsCompletedAt: new Date()
}
});
if (process.env.DEBUG === 'true') {
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
*/
async getMigrationStatus(boardId) {
try {
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
return { status: 'board_not_found' };
}
if (board.fixMissingListsCompleted) {
return {
status: 'completed',
completedAt: board.fixMissingListsCompletedAt
};
}
const needsMigration = await 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({
async 'fixMissingListsMigration.check'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
// Reading migration status discloses board structure, so require the
// caller to be allowed to see the board.
const board = await ReactiveCache.getBoard(boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized');
}
return await fixMissingListsMigration.getMigrationStatus(boardId);
},
async 'fixMissingListsMigration.execute'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
// This migration rewrites lists/cards on the board, so restrict it to
// board admins (or instance admins), matching the other *.execute
// migrations (e.g. restoreLostCards.execute).
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('board-not-found', 'Board not found');
}
const user = await ReactiveCache.getUser(this.userId);
const isBoardAdmin = board.members && board.members.some(
member => member.userId === this.userId && member.isAdmin
);
if (!isBoardAdmin && !(user && user.isAdmin)) {
throw new Meteor.Error('not-authorized', 'Only board administrators can run migrations');
}
return await fixMissingListsMigration.executeMigration(boardId);
},
async 'fixMissingListsMigration.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
// Reading migration status discloses board structure, so require the
// caller to be allowed to see the board.
const board = await ReactiveCache.getBoard(boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized');
}
return await fixMissingListsMigration.needsMigration(boardId);
}
});
-271
View File
@@ -1,271 +0,0 @@
/**
* 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';
function getTranslatedString(key, fallback, options) {
const translated = TAPi18n.__(key, options);
return typeof translated === 'string' ? translated : fallback;
}
class RestoreAllArchivedMigration {
constructor() {
this.name = 'restoreAllArchived';
this.version = 1;
}
/**
* Check if migration is needed for a board
*/
async needsMigration(boardId) {
try {
const archivedSwimlanes = await ReactiveCache.getSwimlanes({ boardId, archived: true });
const archivedLists = await ReactiveCache.getLists({ boardId, archived: true });
const archivedCards = await 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 = await ReactiveCache.getBoard(boardId);
if (!board) {
throw new Error('Board not found');
}
// Get archived items
const archivedSwimlanes = await ReactiveCache.getSwimlanes({ boardId, archived: true });
const archivedLists = await ReactiveCache.getLists({ boardId, archived: true });
const archivedCards = await ReactiveCache.getCards({ boardId, archived: true });
// Get active items for reference
const activeSwimlanes = await ReactiveCache.getSwimlanes({ boardId, archived: false });
const activeLists = await ReactiveCache.getLists({ boardId, archived: false });
// Restore all archived swimlanes
for (const swimlane of archivedSwimlanes) {
await Swimlanes.updateAsync(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 = await Swimlanes.insertAsync({
title: getTranslatedString('default', 'Default'),
boardId: boardId,
sort: 0,
createdAt: new Date(),
updatedAt: new Date(),
archived: false
});
targetSwimlane = await ReactiveCache.getSwimlane(swimlaneId);
}
updateFields.swimlaneId = targetSwimlane._id;
results.itemsFixed++;
if (process.env.DEBUG === 'true') {
console.log(`Fixed missing swimlaneId for list: ${list.title}`);
}
}
await Lists.updateAsync(list._id, {
$set: updateFields
});
results.listsRestored++;
if (process.env.DEBUG === 'true') {
console.log(`Restored list: ${list.title}`);
}
}
// Refresh lists after restoration
const allLists = await ReactiveCache.getLists({ boardId, archived: false });
const allSwimlanes = await 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 = await Lists.insertAsync({
title: getTranslatedString('default', 'Default'),
boardId: boardId,
swimlaneId: defaultSwimlane._id,
sort: 0,
createdAt: new Date(),
updatedAt: new Date(),
archived: false
});
targetList = await 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}`);
}
}
await Cards.updateAsync(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({
async 'restoreAllArchived.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
return await restoreAllArchivedMigration.needsMigration(boardId);
},
async '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 = await ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('board-not-found', 'Board not found');
}
const user = await 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 await restoreAllArchivedMigration.executeMigration(boardId);
}
});
export default restoreAllArchivedMigration;
-259
View File
@@ -1,259 +0,0 @@
/**
* 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
*/
async needsMigration(boardId) {
try {
const cards = await ReactiveCache.getCards({ boardId, archived: false });
const lists = await 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 = await ReactiveCache.getBoard(boardId);
if (!board) {
throw new Error('Board not found');
}
// Get all non-archived items
const cards = await ReactiveCache.getCards({ boardId, archived: false });
const lists = await ReactiveCache.getLists({ boardId, archived: false });
const swimlanes = await 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 = await Swimlanes.insertAsync({
title: TAPi18n.__('lost-cards'),
boardId: boardId,
sort: 999999, // Put at the end
color: 'red',
createdAt: new Date(),
updatedAt: new Date(),
archived: false
});
lostCardsSwimlane = await 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) {
await Lists.updateAsync(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 = await Lists.insertAsync({
title: TAPi18n.__('lost-cards-list'),
boardId: boardId,
swimlaneId: lostCardsSwimlane._id,
sort: 0,
createdAt: new Date(),
updatedAt: new Date(),
archived: false
});
defaultList = await 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;
await Cards.updateAsync(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) {
await Cards.updateAsync(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({
async 'restoreLostCards.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
return await restoreLostCardsMigration.needsMigration(boardId);
},
async '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 = await ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('board-not-found', 'Board not found');
}
const user = await 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 await restoreLostCardsMigration.executeMigration(boardId);
}
});
export default restoreLostCardsMigration;
@@ -0,0 +1,106 @@
'use strict';
// Guard for #6521: the per-swimlane-lists-era board migrations must stay removed.
//
// comprehensiveBoardMigration / fixMissingListsMigration / restoreLostCards /
// restoreAllArchived converted today's board-wide SHARED lists (swimlaneId '')
// back into per-swimlane DUPLICATE columns and created "Lost Cards" / "Restored
// Items" columns, moving real cards into them (treating cards on archived lists as
// "orphaned" and shared-swimlane cards as "lost"). They had no callers left, so a
// reporter upgrading to 10.33 saw a spurious "Restored Items" column full of cards
// that looked empty. This test fails if any of them (or their import, or their
// admin-callable Meteor methods) come back, and checks the legitimate replacements
// stayed.
//
// Run: node tests/obsoleteBoardMigrationsRemoved.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
const exists = (p) => fs.existsSync(path.join(root, p));
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
console.log('obsoleteBoardMigrationsRemoved:');
const REMOVED = [
'server/migrations/comprehensiveBoardMigration.js',
'server/migrations/fixMissingListsMigration.js',
'server/migrations/restoreLostCards.js',
'server/migrations/restoreAllArchived.js',
];
test('the four obsolete migration files are gone', () => {
for (const f of REMOVED) {
assert.ok(!exists(f), `${f} must not exist`);
}
});
test('server/imports.js no longer imports them', () => {
const imports = read('server/imports.js');
for (const f of REMOVED) {
const spec = f.replace(/^server/, '/server').replace(/\.js$/, '');
assert.ok(!imports.includes(`import '${spec}'`), `must not import ${spec}`);
}
});
test('no code registers their admin-callable Meteor methods anymore', () => {
// A brittle-but-cheap sweep: none of these method names should appear as a
// Meteor.methods key or a Meteor.call target anywhere in the loaded tree.
const NEEDLES = [
"'comprehensiveBoardMigration.execute'",
"'restoreLostCards.execute'",
"'fixMissingLists",
"'restoreAllArchived.execute'",
];
const dirs = ['server', 'client', 'imports', 'models'];
const hits = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
const rel = path.join(dir, entry.name);
if (entry.isDirectory()) { walk(rel); continue; }
if (!/\.(js|jade)$/.test(entry.name)) continue;
const txt = read(rel);
for (const n of NEEDLES) if (txt.includes(n)) hits.push(`${rel}: ${n}`);
}
};
dirs.forEach(walk);
assert.deepStrictEqual(hits, [], `obsolete migration methods must be gone:\n${hits.join('\n')}`);
});
test('the "Restored Items" / "Lost Cards" column-creating code is gone', () => {
// The literal i18n keys the removed migration used to name its columns must no
// longer be referenced by any code (the translation strings may linger harmlessly).
const dirs = ['server', 'client'];
const hits = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
const rel = path.join(dir, entry.name);
if (entry.isDirectory()) { walk(rel); continue; }
if (!/\.(js|jade)$/.test(entry.name)) continue;
const txt = read(rel);
if (/lost-cards-list|lostCardsSwimlane/.test(txt)) hits.push(rel);
}
};
dirs.forEach(walk);
assert.deepStrictEqual(hits, [], `no code should create a Lost Cards / Restored Items column:\n${hits.join('\n')}`);
});
test('the legitimate replacements are still in place', () => {
// The startup schema step that UNDOES the per-swimlane-lists damage...
assert.ok(read('server/lib/schemaUpgradeSteps.js').includes("name: 'merge-per-swimlane-lists'"),
'merge-per-swimlane-lists schema step must remain');
// ...and the board-open self-heal that fixes genuinely missing swimlaneId / orphans.
assert.ok(exists('server/lib/repairBoardData.js'), 'repairBoardData must remain');
// The schema upgrade runner (which runs merge-per-swimlane-lists) stays wired.
assert.ok(read('server/imports.js').includes("import '/server/startupSchemaUpgrade'"),
'startupSchemaUpgrade must still be imported');
assert.ok(read('server/startupSchemaUpgrade.js').includes('schemaUpgradeSteps'),
'startupSchemaUpgrade must run schemaUpgradeSteps');
});
console.log(`\n${passed} tests passed`);