mirror of
https://github.com/wekan/wekan.git
synced 2025-12-16 15:30:13 +01:00
Fix 8.16 Lists with no items are deleted every time when board is opened. Moved migrations to right sidebar.
Thanks to xet7 ! Fixes #5994
This commit is contained in:
parent
91a0aa7387
commit
7713e613b4
8 changed files with 1278 additions and 42 deletions
|
|
@ -146,7 +146,6 @@ BlazeComponent.extendComponent({
|
||||||
{ step: 'fix_orphaned_cards', name: 'Fix Orphaned Cards', duration: 2000 },
|
{ step: 'fix_orphaned_cards', name: 'Fix Orphaned Cards', duration: 2000 },
|
||||||
{ step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 3000 },
|
{ step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 3000 },
|
||||||
{ step: 'ensure_per_swimlane_lists', name: 'Ensure Per-Swimlane Lists', duration: 1500 },
|
{ step: 'ensure_per_swimlane_lists', name: 'Ensure Per-Swimlane Lists', duration: 1500 },
|
||||||
{ step: 'cleanup_empty_lists', name: 'Cleanup Empty Lists', duration: 1000 },
|
|
||||||
{ step: 'validate_migration', name: 'Validate Migration', duration: 1000 },
|
{ step: 'validate_migration', name: 'Validate Migration', duration: 1000 },
|
||||||
{ step: 'fix_avatar_urls', name: 'Fix Avatar URLs', duration: 1000 },
|
{ step: 'fix_avatar_urls', name: 'Fix Avatar URLs', duration: 1000 },
|
||||||
{ step: 'fix_attachment_urls', name: 'Fix Attachment URLs', duration: 1000 }
|
{ step: 'fix_attachment_urls', name: 'Fix Attachment URLs', duration: 1000 }
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,36 @@ template(name='migrationsSidebar')
|
||||||
else
|
else
|
||||||
span.badge.badge-success {{_ 'migration-complete'}}
|
span.badge.badge-success {{_ 'migration-complete'}}
|
||||||
|
|
||||||
|
.migration-item
|
||||||
|
a.js-run-migration(data-migration="deleteDuplicateEmptyLists")
|
||||||
|
.migration-name
|
||||||
|
| {{_ 'delete-duplicate-empty-lists-migration'}}
|
||||||
|
.migration-status
|
||||||
|
if deleteDuplicateEmptyListsNeeded
|
||||||
|
span.badge.badge-warning {{_ 'migration-needed'}}
|
||||||
|
else
|
||||||
|
span.badge.badge-success {{_ 'migration-complete'}}
|
||||||
|
|
||||||
|
.migration-item
|
||||||
|
a.js-run-migration(data-migration="restoreLostCards")
|
||||||
|
.migration-name
|
||||||
|
| {{_ 'restore-lost-cards-migration'}}
|
||||||
|
.migration-status
|
||||||
|
if restoreLostCardsNeeded
|
||||||
|
span.badge.badge-warning {{_ 'migration-needed'}}
|
||||||
|
else
|
||||||
|
span.badge.badge-success {{_ 'migration-complete'}}
|
||||||
|
|
||||||
|
.migration-item
|
||||||
|
a.js-run-migration(data-migration="restoreAllArchived")
|
||||||
|
.migration-name
|
||||||
|
| {{_ 'restore-all-archived-migration'}}
|
||||||
|
.migration-status
|
||||||
|
if restoreAllArchivedNeeded
|
||||||
|
span.badge.badge-warning {{_ 'migration-needed'}}
|
||||||
|
else
|
||||||
|
span.badge.badge-success {{_ 'migration-complete'}}
|
||||||
|
|
||||||
hr
|
hr
|
||||||
h4 {{_ 'global-migrations'}}
|
h4 {{_ 'global-migrations'}}
|
||||||
.migration-item
|
.migration-item
|
||||||
|
|
@ -60,6 +90,18 @@ template(name='runFixMissingListsMigrationPopup')
|
||||||
p {{_ 'run-fix-missing-lists-migration-confirm'}}
|
p {{_ 'run-fix-missing-lists-migration-confirm'}}
|
||||||
button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
|
button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
|
||||||
|
|
||||||
|
template(name='runDeleteDuplicateEmptyListsMigrationPopup')
|
||||||
|
p {{_ 'run-delete-duplicate-empty-lists-migration-confirm'}}
|
||||||
|
button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
|
||||||
|
|
||||||
|
template(name='runRestoreLostCardsMigrationPopup')
|
||||||
|
p {{_ 'run-restore-lost-cards-migration-confirm'}}
|
||||||
|
button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
|
||||||
|
|
||||||
|
template(name='runRestoreAllArchivedMigrationPopup')
|
||||||
|
p {{_ 'run-restore-all-archived-migration-confirm'}}
|
||||||
|
button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
|
||||||
|
|
||||||
template(name='runFixAvatarUrlsMigrationPopup')
|
template(name='runFixAvatarUrlsMigrationPopup')
|
||||||
p {{_ 'run-fix-avatar-urls-migration-confirm'}}
|
p {{_ 'run-fix-avatar-urls-migration-confirm'}}
|
||||||
button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
|
button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { ReactiveCache } from '/imports/reactiveCache';
|
import { ReactiveCache } from '/imports/reactiveCache';
|
||||||
import { TAPi18n } from '/imports/i18n';
|
import { TAPi18n } from '/imports/i18n';
|
||||||
|
import { migrationProgressManager } from '/client/components/migrationProgress';
|
||||||
|
|
||||||
BlazeComponent.extendComponent({
|
BlazeComponent.extendComponent({
|
||||||
onCreated() {
|
onCreated() {
|
||||||
|
|
@ -29,11 +30,38 @@ BlazeComponent.extendComponent({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check fix avatar URLs migration (global)
|
// Check delete duplicate empty lists migration
|
||||||
Meteor.call('fixAvatarUrls.needsMigration', (err, res) => {
|
Meteor.call('deleteDuplicateEmptyLists.needsMigration', boardId, (err, res) => {
|
||||||
if (!err) {
|
if (!err) {
|
||||||
const statuses = this.migrationStatuses.get();
|
const statuses = this.migrationStatuses.get();
|
||||||
statuses.fixAvatarUrls = res;
|
statuses.deleteDuplicateEmptyLists = res;
|
||||||
|
this.migrationStatuses.set(statuses);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check restore lost cards migration
|
||||||
|
Meteor.call('restoreLostCards.needsMigration', boardId, (err, res) => {
|
||||||
|
if (!err) {
|
||||||
|
const statuses = this.migrationStatuses.get();
|
||||||
|
statuses.restoreLostCards = res;
|
||||||
|
this.migrationStatuses.set(statuses);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check restore all archived migration
|
||||||
|
Meteor.call('restoreAllArchived.needsMigration', boardId, (err, res) => {
|
||||||
|
if (!err) {
|
||||||
|
const statuses = this.migrationStatuses.get();
|
||||||
|
statuses.restoreAllArchived = res;
|
||||||
|
this.migrationStatuses.set(statuses);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check fix avatar URLs migration (global)
|
||||||
|
Meteor.call('fixAvatarUrls.needsMigration', (err, res) => {
|
||||||
|
if (!err) {
|
||||||
|
const statuses = this.migrationStatuses.get();
|
||||||
|
statuses.fixAvatarUrls = res;
|
||||||
this.migrationStatuses.set(statuses);
|
this.migrationStatuses.set(statuses);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -56,6 +84,22 @@ BlazeComponent.extendComponent({
|
||||||
return this.migrationStatuses.get().fixMissingLists === true;
|
return this.migrationStatuses.get().fixMissingLists === true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteEmptyListsNeeded() {
|
||||||
|
return this.migrationStatuses.get().deleteEmptyLists === true;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDuplicateEmptyListsNeeded() {
|
||||||
|
return this.migrationStatuses.get().deleteDuplicateEmptyLists === true;
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreLostCardsNeeded() {
|
||||||
|
return this.migrationStatuses.get().restoreLostCards === true;
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreAllArchivedNeeded() {
|
||||||
|
return this.migrationStatuses.get().restoreAllArchived === true;
|
||||||
|
},
|
||||||
|
|
||||||
fixAvatarUrlsNeeded() {
|
fixAvatarUrlsNeeded() {
|
||||||
return this.migrationStatuses.get().fixAvatarUrls === true;
|
return this.migrationStatuses.get().fixAvatarUrls === true;
|
||||||
},
|
},
|
||||||
|
|
@ -64,6 +108,58 @@ BlazeComponent.extendComponent({
|
||||||
return this.migrationStatuses.get().fixAllFileUrls === true;
|
return this.migrationStatuses.get().fixAllFileUrls === true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Simulate migration progress updates using the global progress popup
|
||||||
|
async simulateMigrationProgress(progressSteps) {
|
||||||
|
const totalSteps = progressSteps.length;
|
||||||
|
for (let i = 0; i < progressSteps.length; i++) {
|
||||||
|
const step = progressSteps[i];
|
||||||
|
const overall = Math.round(((i + 1) / totalSteps) * 100);
|
||||||
|
|
||||||
|
// Start step
|
||||||
|
migrationProgressManager.updateProgress({
|
||||||
|
overallProgress: overall,
|
||||||
|
currentStep: i + 1,
|
||||||
|
totalSteps,
|
||||||
|
stepName: step.step,
|
||||||
|
stepProgress: 0,
|
||||||
|
stepStatus: `Starting ${step.name}...`,
|
||||||
|
stepDetails: null,
|
||||||
|
boardId: Session.get('currentBoard'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepDuration = step.duration;
|
||||||
|
const updateInterval = 100;
|
||||||
|
const totalUpdates = Math.max(1, Math.floor(stepDuration / updateInterval));
|
||||||
|
for (let j = 0; j < totalUpdates; j++) {
|
||||||
|
const per = Math.round(((j + 1) / totalUpdates) * 100);
|
||||||
|
migrationProgressManager.updateProgress({
|
||||||
|
overallProgress: overall,
|
||||||
|
currentStep: i + 1,
|
||||||
|
totalSteps,
|
||||||
|
stepName: step.step,
|
||||||
|
stepProgress: per,
|
||||||
|
stepStatus: `Processing ${step.name}...`,
|
||||||
|
stepDetails: { progress: `${per}%` },
|
||||||
|
boardId: Session.get('currentBoard'),
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await new Promise((r) => setTimeout(r, updateInterval));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete step
|
||||||
|
migrationProgressManager.updateProgress({
|
||||||
|
overallProgress: overall,
|
||||||
|
currentStep: i + 1,
|
||||||
|
totalSteps,
|
||||||
|
stepName: step.step,
|
||||||
|
stepProgress: 100,
|
||||||
|
stepStatus: `${step.name} completed`,
|
||||||
|
stepDetails: { status: 'completed' },
|
||||||
|
boardId: Session.get('currentBoard'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
runMigration(migrationType) {
|
runMigration(migrationType) {
|
||||||
const boardId = Session.get('currentBoard');
|
const boardId = Session.get('currentBoard');
|
||||||
|
|
||||||
|
|
@ -81,6 +177,26 @@ BlazeComponent.extendComponent({
|
||||||
methodArgs = [boardId];
|
methodArgs = [boardId];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'deleteEmptyLists':
|
||||||
|
methodName = 'deleteEmptyLists.execute';
|
||||||
|
methodArgs = [boardId];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'deleteDuplicateEmptyLists':
|
||||||
|
methodName = 'deleteDuplicateEmptyLists.execute';
|
||||||
|
methodArgs = [boardId];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'restoreLostCards':
|
||||||
|
methodName = 'restoreLostCards.execute';
|
||||||
|
methodArgs = [boardId];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'restoreAllArchived':
|
||||||
|
methodName = 'restoreAllArchived.execute';
|
||||||
|
methodArgs = [boardId];
|
||||||
|
break;
|
||||||
|
|
||||||
case 'fixAvatarUrls':
|
case 'fixAvatarUrls':
|
||||||
methodName = 'fixAvatarUrls.execute';
|
methodName = 'fixAvatarUrls.execute';
|
||||||
break;
|
break;
|
||||||
|
|
@ -91,17 +207,104 @@ BlazeComponent.extendComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (methodName) {
|
if (methodName) {
|
||||||
Meteor.call(methodName, ...methodArgs, (err, result) => {
|
// Define simulated steps per migration type
|
||||||
if (err) {
|
const stepsByType = {
|
||||||
console.error('Migration failed:', err);
|
comprehensive: [
|
||||||
// Show error notification
|
{ step: 'analyze_board_structure', name: 'Analyze Board Structure', duration: 800 },
|
||||||
Alert.error(TAPi18n.__('migration-failed') + ': ' + (err.message || err.reason));
|
{ step: 'fix_orphaned_cards', name: 'Fix Orphaned Cards', duration: 1200 },
|
||||||
|
{ step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 1000 },
|
||||||
|
{ step: 'ensure_per_swimlane_lists', name: 'Ensure Per-Swimlane Lists', duration: 800 },
|
||||||
|
{ step: 'validate_migration', name: 'Validate Migration', duration: 800 },
|
||||||
|
{ step: 'fix_avatar_urls', name: 'Fix Avatar URLs', duration: 600 },
|
||||||
|
{ step: 'fix_attachment_urls', name: 'Fix Attachment URLs', duration: 600 },
|
||||||
|
],
|
||||||
|
fixMissingLists: [
|
||||||
|
{ step: 'analyze_lists', name: 'Analyze Lists', duration: 600 },
|
||||||
|
{ step: 'create_missing_lists', name: 'Create Missing Lists', duration: 900 },
|
||||||
|
{ step: 'update_cards', name: 'Update Cards', duration: 900 },
|
||||||
|
{ step: 'finalize', name: 'Finalize', duration: 400 },
|
||||||
|
],
|
||||||
|
deleteEmptyLists: [
|
||||||
|
{ step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 700 },
|
||||||
|
{ step: 'delete_empty_lists', name: 'Delete Empty Lists', duration: 800 },
|
||||||
|
],
|
||||||
|
deleteDuplicateEmptyLists: [
|
||||||
|
{ step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 700 },
|
||||||
|
{ step: 'delete_duplicate_empty_lists', name: 'Delete Duplicate Empty Lists', duration: 800 },
|
||||||
|
],
|
||||||
|
restoreLostCards: [
|
||||||
|
{ step: 'ensure_lost_cards_swimlane', name: 'Ensure Lost Cards Swimlane', duration: 600 },
|
||||||
|
{ step: 'restore_lists', name: 'Restore Lists', duration: 800 },
|
||||||
|
{ step: 'restore_cards', name: 'Restore Cards', duration: 1000 },
|
||||||
|
],
|
||||||
|
restoreAllArchived: [
|
||||||
|
{ step: 'restore_swimlanes', name: 'Restore Swimlanes', duration: 800 },
|
||||||
|
{ step: 'restore_lists', name: 'Restore Lists', duration: 900 },
|
||||||
|
{ step: 'restore_cards', name: 'Restore Cards', duration: 1000 },
|
||||||
|
{ step: 'fix_missing_ids', name: 'Fix Missing IDs', duration: 600 },
|
||||||
|
],
|
||||||
|
fixAvatarUrls: [
|
||||||
|
{ step: 'scan_users', name: 'Scan Users', duration: 500 },
|
||||||
|
{ step: 'fix_urls', name: 'Fix Avatar URLs', duration: 900 },
|
||||||
|
],
|
||||||
|
fixAllFileUrls: [
|
||||||
|
{ step: 'scan_files', name: 'Scan Files', duration: 600 },
|
||||||
|
{ step: 'fix_urls', name: 'Fix File URLs', duration: 1000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = stepsByType[migrationType] || [
|
||||||
|
{ step: 'running', name: 'Running Migration', duration: 1000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Kick off popup and simulated progress
|
||||||
|
migrationProgressManager.startMigration();
|
||||||
|
const progressPromise = this.simulateMigrationProgress(steps);
|
||||||
|
|
||||||
|
// Start migration call
|
||||||
|
const callPromise = new Promise((resolve, reject) => {
|
||||||
|
Meteor.call(methodName, ...methodArgs, (err, result) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
return resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.allSettled([callPromise, progressPromise]).then(([callRes]) => {
|
||||||
|
if (callRes.status === 'rejected') {
|
||||||
|
migrationProgressManager.failMigration(callRes.reason);
|
||||||
} else {
|
} else {
|
||||||
console.log('Migration completed:', result);
|
const result = callRes.value;
|
||||||
// Show success notification
|
// Summarize result details in the popup
|
||||||
Alert.success(TAPi18n.__('migration-successful'));
|
let summary = {};
|
||||||
|
if (result && result.results) {
|
||||||
// Reload migration statuses
|
// Comprehensive returns {success, results}
|
||||||
|
const r = result.results;
|
||||||
|
summary = {
|
||||||
|
totalCardsProcessed: r.totalCardsProcessed,
|
||||||
|
totalListsProcessed: r.totalListsProcessed,
|
||||||
|
totalListsCreated: r.totalListsCreated,
|
||||||
|
};
|
||||||
|
} else if (result && result.changes) {
|
||||||
|
// Many migrations return a changes string array
|
||||||
|
summary = { changes: result.changes.join(' | ') };
|
||||||
|
} else if (result && typeof result === 'object') {
|
||||||
|
summary = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
migrationProgressManager.updateProgress({
|
||||||
|
overallProgress: 100,
|
||||||
|
currentStep: steps.length,
|
||||||
|
totalSteps: steps.length,
|
||||||
|
stepName: 'completed',
|
||||||
|
stepProgress: 100,
|
||||||
|
stepStatus: 'Migration completed',
|
||||||
|
stepDetails: summary,
|
||||||
|
boardId: Session.get('currentBoard'),
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationProgressManager.completeMigration();
|
||||||
|
|
||||||
|
// Refresh status badges slightly after
|
||||||
Meteor.setTimeout(() => {
|
Meteor.setTimeout(() => {
|
||||||
this.loadMigrationStatuses();
|
this.loadMigrationStatuses();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
@ -111,31 +314,41 @@ BlazeComponent.extendComponent({
|
||||||
},
|
},
|
||||||
|
|
||||||
events() {
|
events() {
|
||||||
|
const self = this; // Capture component reference
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
'click .js-run-migration[data-migration="comprehensive"]': Popup.afterConfirm('runComprehensiveMigration', function() {
|
'click .js-run-migration[data-migration="comprehensive"]': Popup.afterConfirm('runComprehensiveMigration', function() {
|
||||||
const component = BlazeComponent.getComponentForElement(this);
|
self.runMigration('comprehensive');
|
||||||
if (component) {
|
Popup.back();
|
||||||
component.runMigration('comprehensive');
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
'click .js-run-migration[data-migration="fixMissingLists"]': Popup.afterConfirm('runFixMissingListsMigration', function() {
|
'click .js-run-migration[data-migration="fixMissingLists"]': Popup.afterConfirm('runFixMissingListsMigration', function() {
|
||||||
const component = BlazeComponent.getComponentForElement(this);
|
self.runMigration('fixMissingLists');
|
||||||
if (component) {
|
Popup.back();
|
||||||
component.runMigration('fixMissingLists');
|
}),
|
||||||
}
|
'click .js-run-migration[data-migration="deleteEmptyLists"]': Popup.afterConfirm('runDeleteEmptyListsMigration', function() {
|
||||||
|
self.runMigration('deleteEmptyLists');
|
||||||
|
Popup.back();
|
||||||
|
}),
|
||||||
|
'click .js-run-migration[data-migration="deleteDuplicateEmptyLists"]': Popup.afterConfirm('runDeleteDuplicateEmptyListsMigration', function() {
|
||||||
|
self.runMigration('deleteDuplicateEmptyLists');
|
||||||
|
Popup.back();
|
||||||
|
}),
|
||||||
|
'click .js-run-migration[data-migration="restoreLostCards"]': Popup.afterConfirm('runRestoreLostCardsMigration', function() {
|
||||||
|
self.runMigration('restoreLostCards');
|
||||||
|
Popup.back();
|
||||||
|
}),
|
||||||
|
'click .js-run-migration[data-migration="restoreAllArchived"]': Popup.afterConfirm('runRestoreAllArchivedMigration', function() {
|
||||||
|
self.runMigration('restoreAllArchived');
|
||||||
|
Popup.back();
|
||||||
}),
|
}),
|
||||||
'click .js-run-migration[data-migration="fixAvatarUrls"]': Popup.afterConfirm('runFixAvatarUrlsMigration', function() {
|
'click .js-run-migration[data-migration="fixAvatarUrls"]': Popup.afterConfirm('runFixAvatarUrlsMigration', function() {
|
||||||
const component = BlazeComponent.getComponentForElement(this);
|
self.runMigration('fixAvatarUrls');
|
||||||
if (component) {
|
Popup.back();
|
||||||
component.runMigration('fixAvatarUrls');
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
'click .js-run-migration[data-migration="fixAllFileUrls"]': Popup.afterConfirm('runFixAllFileUrlsMigration', function() {
|
'click .js-run-migration[data-migration="fixAllFileUrls"]': Popup.afterConfirm('runFixAllFileUrlsMigration', function() {
|
||||||
const component = BlazeComponent.getComponentForElement(this);
|
self.runMigration('fixAllFileUrls');
|
||||||
if (component) {
|
Popup.back();
|
||||||
component.runMigration('fixAllFileUrls');
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1408,6 +1408,16 @@
|
||||||
"card-show-lists-on-minicard": "Show Lists on Minicard",
|
"card-show-lists-on-minicard": "Show Lists on Minicard",
|
||||||
"comprehensive-board-migration": "Comprehensive Board Migration",
|
"comprehensive-board-migration": "Comprehensive Board Migration",
|
||||||
"comprehensive-board-migration-description": "Performs comprehensive checks and fixes for board data integrity, including list ordering, card positions, and swimlane structure.",
|
"comprehensive-board-migration-description": "Performs comprehensive checks and fixes for board data integrity, including list ordering, card positions, and swimlane structure.",
|
||||||
|
"delete-empty-lists-migration": "Delete Empty Lists",
|
||||||
|
"delete-empty-lists-migration-description": "Safely deletes empty duplicate lists. Only removes lists that have no cards AND have another list with the same title that contains cards.",
|
||||||
|
"delete-duplicate-empty-lists-migration": "Delete Duplicate Empty Lists",
|
||||||
|
"delete-duplicate-empty-lists-migration-description": "Safely deletes empty duplicate lists. Only removes lists that have no cards AND have another list with the same title that contains cards.",
|
||||||
|
"lost-cards": "Lost Cards",
|
||||||
|
"lost-cards-list": "Restored Items",
|
||||||
|
"restore-lost-cards-migration": "Restore Lost Cards",
|
||||||
|
"restore-lost-cards-migration-description": "Finds and restores cards and lists with missing swimlaneId or listId. Creates a 'Lost Cards' swimlane to make all lost items visible again.",
|
||||||
|
"restore-all-archived-migration": "Restore All Archived",
|
||||||
|
"restore-all-archived-migration-description": "Restores all archived swimlanes, lists, and cards. Automatically fixes any missing swimlaneId or listId to make items visible.",
|
||||||
"fix-missing-lists-migration": "Fix Missing Lists",
|
"fix-missing-lists-migration": "Fix Missing Lists",
|
||||||
"fix-missing-lists-migration-description": "Detects and repairs missing or corrupted lists in the board structure.",
|
"fix-missing-lists-migration-description": "Detects and repairs missing or corrupted lists in the board structure.",
|
||||||
"fix-avatar-urls-migration": "Fix Avatar URLs",
|
"fix-avatar-urls-migration": "Fix Avatar URLs",
|
||||||
|
|
@ -1426,9 +1436,43 @@
|
||||||
"no-issues-found": "No issues found",
|
"no-issues-found": "No issues found",
|
||||||
"run-migration": "Run Migration",
|
"run-migration": "Run Migration",
|
||||||
"run-comprehensive-migration-confirm": "This will perform a comprehensive migration to check and fix board data integrity. This may take a few moments. Continue?",
|
"run-comprehensive-migration-confirm": "This will perform a comprehensive migration to check and fix board data integrity. This may take a few moments. Continue?",
|
||||||
|
"run-delete-empty-lists-migration-confirm": "This will first convert any shared lists to per-swimlane lists, then delete empty lists that have a duplicate list with the same title containing cards. Only truly redundant empty lists will be removed. Continue?",
|
||||||
|
"run-delete-duplicate-empty-lists-migration-confirm": "This will first convert any shared lists to per-swimlane lists, then delete empty lists that have a duplicate list with the same title containing cards. Only truly redundant empty lists will be removed. Continue?",
|
||||||
|
"run-restore-lost-cards-migration-confirm": "This will create a 'Lost Cards' swimlane and restore all cards and lists with missing swimlaneId or listId. This only affects non-archived items. Continue?",
|
||||||
|
"run-restore-all-archived-migration-confirm": "This will restore ALL archived swimlanes, lists, and cards, making them visible again. Any items with missing IDs will be automatically fixed. This cannot be easily undone. Continue?",
|
||||||
"run-fix-missing-lists-migration-confirm": "This will detect and repair missing or corrupted lists in the board structure. Continue?",
|
"run-fix-missing-lists-migration-confirm": "This will detect and repair missing or corrupted lists in the board structure. Continue?",
|
||||||
"run-fix-avatar-urls-migration-confirm": "This will update avatar URLs across all boards to use the correct storage backend. This is a global operation. Continue?",
|
"run-fix-avatar-urls-migration-confirm": "This will update avatar URLs across all boards to use the correct storage backend. This is a global operation. Continue?",
|
||||||
"run-fix-all-file-urls-migration-confirm": "This will update all file attachment URLs across all boards to use the correct storage backend. This is a global operation. Continue?",
|
"run-fix-all-file-urls-migration-confirm": "This will update all file attachment URLs across all boards to use the correct storage backend. This is a global operation. Continue?",
|
||||||
|
"restore-lost-cards-nothing-to-restore": "No lost swimlanes, lists, or cards to restore",
|
||||||
|
|
||||||
|
"migration-progress-title": "Board Migration in Progress",
|
||||||
|
"migration-progress-overall": "Overall Progress",
|
||||||
|
"migration-progress-current-step": "Current Step",
|
||||||
|
"migration-progress-status": "Status",
|
||||||
|
"migration-progress-details": "Details",
|
||||||
|
"migration-progress-note": "Please wait while we migrate your board to the latest structure...",
|
||||||
|
|
||||||
|
"step-analyze-board-structure": "Analyze Board Structure",
|
||||||
|
"step-fix-orphaned-cards": "Fix Orphaned Cards",
|
||||||
|
"step-convert-shared-lists": "Convert Shared Lists",
|
||||||
|
"step-ensure-per-swimlane-lists": "Ensure Per-Swimlane Lists",
|
||||||
|
"step-validate-migration": "Validate Migration",
|
||||||
|
"step-fix-avatar-urls": "Fix Avatar URLs",
|
||||||
|
"step-fix-attachment-urls": "Fix Attachment URLs",
|
||||||
|
"step-analyze-lists": "Analyze Lists",
|
||||||
|
"step-create-missing-lists": "Create Missing Lists",
|
||||||
|
"step-update-cards": "Update Cards",
|
||||||
|
"step-finalize": "Finalize",
|
||||||
|
"step-delete-empty-lists": "Delete Empty Lists",
|
||||||
|
"step-delete-duplicate-empty-lists": "Delete Duplicate Empty Lists",
|
||||||
|
"step-ensure-lost-cards-swimlane": "Ensure Lost Cards Swimlane",
|
||||||
|
"step-restore-lists": "Restore Lists",
|
||||||
|
"step-restore-cards": "Restore Cards",
|
||||||
|
"step-restore-swimlanes": "Restore Swimlanes",
|
||||||
|
"step-fix-missing-ids": "Fix Missing IDs",
|
||||||
|
"step-scan-users": "Scan Users",
|
||||||
|
"step-scan-files": "Scan Files",
|
||||||
|
"step-fix-file-urls": "Fix File URLs",
|
||||||
"cleanup": "Cleanup",
|
"cleanup": "Cleanup",
|
||||||
"cleanup-old-jobs": "Cleanup Old Jobs",
|
"cleanup-old-jobs": "Cleanup Old Jobs",
|
||||||
"completed": "Completed",
|
"completed": "Completed",
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ class ComprehensiveBoardMigration {
|
||||||
'fix_orphaned_cards',
|
'fix_orphaned_cards',
|
||||||
'convert_shared_lists',
|
'convert_shared_lists',
|
||||||
'ensure_per_swimlane_lists',
|
'ensure_per_swimlane_lists',
|
||||||
'cleanup_empty_lists',
|
|
||||||
'validate_migration'
|
'validate_migration'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -169,7 +168,6 @@ class ComprehensiveBoardMigration {
|
||||||
totalCardsProcessed: 0,
|
totalCardsProcessed: 0,
|
||||||
totalListsProcessed: 0,
|
totalListsProcessed: 0,
|
||||||
totalListsCreated: 0,
|
totalListsCreated: 0,
|
||||||
totalListsRemoved: 0,
|
|
||||||
errors: []
|
errors: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -239,15 +237,7 @@ class ComprehensiveBoardMigration {
|
||||||
listsProcessed: results.steps.ensurePerSwimlane.listsProcessed
|
listsProcessed: results.steps.ensurePerSwimlane.listsProcessed
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 5: Cleanup empty lists
|
// Step 5: Validate migration
|
||||||
updateProgress('cleanup_empty_lists', 0, 'Cleaning up empty lists...');
|
|
||||||
results.steps.cleanupEmpty = await this.cleanupEmptyLists(boardId);
|
|
||||||
results.totalListsRemoved += results.steps.cleanupEmpty.listsRemoved || 0;
|
|
||||||
updateProgress('cleanup_empty_lists', 100, 'Empty lists cleaned up', {
|
|
||||||
listsRemoved: results.steps.cleanupEmpty.listsRemoved
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 6: Validate migration
|
|
||||||
updateProgress('validate_migration', 0, 'Validating migration...');
|
updateProgress('validate_migration', 0, 'Validating migration...');
|
||||||
results.steps.validate = await this.validateMigration(boardId);
|
results.steps.validate = await this.validateMigration(boardId);
|
||||||
updateProgress('validate_migration', 100, 'Migration validated', {
|
updateProgress('validate_migration', 100, 'Migration validated', {
|
||||||
|
|
@ -256,7 +246,7 @@ class ComprehensiveBoardMigration {
|
||||||
totalLists: results.steps.validate.totalLists
|
totalLists: results.steps.validate.totalLists
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 7: Fix avatar URLs
|
// Step 6: Fix avatar URLs
|
||||||
updateProgress('fix_avatar_urls', 0, 'Fixing avatar URLs...');
|
updateProgress('fix_avatar_urls', 0, 'Fixing avatar URLs...');
|
||||||
results.steps.fixAvatarUrls = await this.fixAvatarUrls(boardId);
|
results.steps.fixAvatarUrls = await this.fixAvatarUrls(boardId);
|
||||||
updateProgress('fix_avatar_urls', 100, 'Avatar URLs fixed', {
|
updateProgress('fix_avatar_urls', 100, 'Avatar URLs fixed', {
|
||||||
|
|
|
||||||
423
server/migrations/deleteDuplicateEmptyLists.js
Normal file
423
server/migrations/deleteDuplicateEmptyLists.js
Normal file
|
|
@ -0,0 +1,423 @@
|
||||||
|
/**
|
||||||
|
* Delete Duplicate Empty Lists Migration
|
||||||
|
*
|
||||||
|
* Safely deletes empty duplicate lists from a board:
|
||||||
|
* 1. First converts any shared lists to per-swimlane lists
|
||||||
|
* 2. Only deletes per-swimlane lists that:
|
||||||
|
* - Have no cards
|
||||||
|
* - Have another list with the same title on the same board that DOES have cards
|
||||||
|
* 3. This prevents deleting unique empty lists and only removes redundant duplicates
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import { check } from 'meteor/check';
|
||||||
|
import { ReactiveCache } from '/imports/reactiveCache';
|
||||||
|
import Boards from '/models/boards';
|
||||||
|
import Lists from '/models/lists';
|
||||||
|
import Cards from '/models/cards';
|
||||||
|
import Swimlanes from '/models/swimlanes';
|
||||||
|
|
||||||
|
class DeleteDuplicateEmptyListsMigration {
|
||||||
|
constructor() {
|
||||||
|
this.name = 'deleteDuplicateEmptyLists';
|
||||||
|
this.version = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if migration is needed for a board
|
||||||
|
*/
|
||||||
|
needsMigration(boardId) {
|
||||||
|
try {
|
||||||
|
const lists = ReactiveCache.getLists({ boardId });
|
||||||
|
const cards = ReactiveCache.getCards({ boardId });
|
||||||
|
|
||||||
|
// Check if there are any empty lists that have a duplicate with the same title containing cards
|
||||||
|
for (const list of lists) {
|
||||||
|
// Skip shared lists
|
||||||
|
if (!list.swimlaneId || list.swimlaneId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if list is empty
|
||||||
|
const listCards = cards.filter(card => card.listId === list._id);
|
||||||
|
if (listCards.length === 0) {
|
||||||
|
// Check if there's a duplicate list with the same title that has cards
|
||||||
|
const duplicateListsWithSameTitle = lists.filter(l =>
|
||||||
|
l._id !== list._id &&
|
||||||
|
l.title === list.title &&
|
||||||
|
l.boardId === boardId
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const duplicateList of duplicateListsWithSameTitle) {
|
||||||
|
const duplicateListCards = cards.filter(card => card.listId === duplicateList._id);
|
||||||
|
if (duplicateListCards.length > 0) {
|
||||||
|
return true; // Found an empty list with a duplicate that has cards
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking if deleteEmptyLists migration is needed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the migration
|
||||||
|
*/
|
||||||
|
async executeMigration(boardId) {
|
||||||
|
try {
|
||||||
|
const results = {
|
||||||
|
sharedListsConverted: 0,
|
||||||
|
listsDeleted: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1: Convert shared lists to per-swimlane lists first
|
||||||
|
const conversionResult = await this.convertSharedListsToPerSwimlane(boardId);
|
||||||
|
results.sharedListsConverted = conversionResult.listsConverted;
|
||||||
|
|
||||||
|
// Step 2: Delete empty per-swimlane lists
|
||||||
|
const deletionResult = await this.deleteEmptyPerSwimlaneLists(boardId);
|
||||||
|
results.listsDeleted = deletionResult.listsDeleted;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
changes: [
|
||||||
|
`Converted ${results.sharedListsConverted} shared lists to per-swimlane lists`,
|
||||||
|
`Deleted ${results.listsDeleted} empty per-swimlane lists`
|
||||||
|
],
|
||||||
|
results
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error executing deleteEmptyLists migration:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert shared lists (lists without swimlaneId) to per-swimlane lists
|
||||||
|
*/
|
||||||
|
async convertSharedListsToPerSwimlane(boardId) {
|
||||||
|
const lists = ReactiveCache.getLists({ boardId });
|
||||||
|
const swimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
|
||||||
|
const cards = ReactiveCache.getCards({ boardId });
|
||||||
|
|
||||||
|
let listsConverted = 0;
|
||||||
|
|
||||||
|
// Find shared lists (lists without swimlaneId)
|
||||||
|
const sharedLists = lists.filter(list => !list.swimlaneId || list.swimlaneId === '');
|
||||||
|
|
||||||
|
if (sharedLists.length === 0) {
|
||||||
|
return { listsConverted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sharedList of sharedLists) {
|
||||||
|
// Get cards in this shared list
|
||||||
|
const listCards = cards.filter(card => card.listId === sharedList._id);
|
||||||
|
|
||||||
|
// Group cards by swimlane
|
||||||
|
const cardsBySwimlane = {};
|
||||||
|
for (const card of listCards) {
|
||||||
|
const swimlaneId = card.swimlaneId || 'default';
|
||||||
|
if (!cardsBySwimlane[swimlaneId]) {
|
||||||
|
cardsBySwimlane[swimlaneId] = [];
|
||||||
|
}
|
||||||
|
cardsBySwimlane[swimlaneId].push(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create per-swimlane lists for each swimlane that has cards
|
||||||
|
for (const swimlane of swimlanes) {
|
||||||
|
const swimlaneCards = cardsBySwimlane[swimlane._id] || [];
|
||||||
|
|
||||||
|
if (swimlaneCards.length > 0) {
|
||||||
|
// Check if per-swimlane list already exists
|
||||||
|
const existingList = lists.find(l =>
|
||||||
|
l.title === sharedList.title &&
|
||||||
|
l.swimlaneId === swimlane._id &&
|
||||||
|
l._id !== sharedList._id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingList) {
|
||||||
|
// Create new per-swimlane list
|
||||||
|
const newListId = Lists.insert({
|
||||||
|
title: sharedList.title,
|
||||||
|
boardId: boardId,
|
||||||
|
swimlaneId: swimlane._id,
|
||||||
|
sort: sharedList.sort,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
archived: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move cards to the new list
|
||||||
|
for (const card of swimlaneCards) {
|
||||||
|
Cards.update(card._id, {
|
||||||
|
$set: {
|
||||||
|
listId: newListId,
|
||||||
|
swimlaneId: swimlane._id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Created per-swimlane list "${sharedList.title}" for swimlane ${swimlane.title || swimlane._id}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Move cards to existing per-swimlane list
|
||||||
|
for (const card of swimlaneCards) {
|
||||||
|
Cards.update(card._id, {
|
||||||
|
$set: {
|
||||||
|
listId: existingList._id,
|
||||||
|
swimlaneId: swimlane._id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Moved cards to existing per-swimlane list "${sharedList.title}" in swimlane ${swimlane.title || swimlane._id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the shared list (now that all cards are moved)
|
||||||
|
Lists.remove(sharedList._id);
|
||||||
|
listsConverted++;
|
||||||
|
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Removed shared list "${sharedList.title}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { listsConverted };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete empty per-swimlane lists
|
||||||
|
* Only deletes lists that:
|
||||||
|
* 1. Have a swimlaneId (are per-swimlane, not shared)
|
||||||
|
* 2. Have no cards
|
||||||
|
* 3. Have a duplicate list with the same title on the same board that contains cards
|
||||||
|
*/
|
||||||
|
async deleteEmptyPerSwimlaneLists(boardId) {
|
||||||
|
const lists = ReactiveCache.getLists({ boardId });
|
||||||
|
const cards = ReactiveCache.getCards({ boardId });
|
||||||
|
|
||||||
|
let listsDeleted = 0;
|
||||||
|
|
||||||
|
for (const list of lists) {
|
||||||
|
// Safety check 1: List must have a swimlaneId (must be per-swimlane, not shared)
|
||||||
|
if (!list.swimlaneId || list.swimlaneId === '') {
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Skipping list "${list.title}" - no swimlaneId (shared list)`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety check 2: List must have no cards
|
||||||
|
const listCards = cards.filter(card => card.listId === list._id);
|
||||||
|
if (listCards.length > 0) {
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Skipping list "${list.title}" - has ${listCards.length} cards`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety check 3: There must be another list with the same title on the same board that has cards
|
||||||
|
const duplicateListsWithSameTitle = lists.filter(l =>
|
||||||
|
l._id !== list._id &&
|
||||||
|
l.title === list.title &&
|
||||||
|
l.boardId === boardId
|
||||||
|
);
|
||||||
|
|
||||||
|
let hasDuplicateWithCards = false;
|
||||||
|
for (const duplicateList of duplicateListsWithSameTitle) {
|
||||||
|
const duplicateListCards = cards.filter(card => card.listId === duplicateList._id);
|
||||||
|
if (duplicateListCards.length > 0) {
|
||||||
|
hasDuplicateWithCards = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasDuplicateWithCards) {
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Skipping list "${list.title}" - no duplicate list with same title that has cards`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All safety checks passed - delete the empty per-swimlane list
|
||||||
|
Lists.remove(list._id);
|
||||||
|
listsDeleted++;
|
||||||
|
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Deleted empty per-swimlane list: "${list.title}" (swimlane: ${list.swimlaneId}) - duplicate with cards exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { listsDeleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed status of empty lists
|
||||||
|
*/
|
||||||
|
async getStatus(boardId) {
|
||||||
|
const lists = ReactiveCache.getLists({ boardId });
|
||||||
|
const cards = ReactiveCache.getCards({ boardId });
|
||||||
|
|
||||||
|
const sharedLists = [];
|
||||||
|
const emptyPerSwimlaneLists = [];
|
||||||
|
const nonEmptyLists = [];
|
||||||
|
|
||||||
|
for (const list of lists) {
|
||||||
|
const listCards = cards.filter(card => card.listId === list._id);
|
||||||
|
const isShared = !list.swimlaneId || list.swimlaneId === '';
|
||||||
|
const isEmpty = listCards.length === 0;
|
||||||
|
|
||||||
|
if (isShared) {
|
||||||
|
sharedLists.push({
|
||||||
|
id: list._id,
|
||||||
|
title: list.title,
|
||||||
|
cardCount: listCards.length
|
||||||
|
});
|
||||||
|
} else if (isEmpty) {
|
||||||
|
emptyPerSwimlaneLists.push({
|
||||||
|
id: list._id,
|
||||||
|
title: list.title,
|
||||||
|
swimlaneId: list.swimlaneId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
nonEmptyLists.push({
|
||||||
|
id: list._id,
|
||||||
|
title: list.title,
|
||||||
|
swimlaneId: list.swimlaneId,
|
||||||
|
cardCount: listCards.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sharedListsCount: sharedLists.length,
|
||||||
|
emptyPerSwimlaneLists: emptyPerSwimlaneLists.length,
|
||||||
|
totalLists: lists.length,
|
||||||
|
details: {
|
||||||
|
sharedLists,
|
||||||
|
emptyPerSwimlaneLists,
|
||||||
|
nonEmptyLists
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteDuplicateEmptyListsMigration = new DeleteDuplicateEmptyListsMigration();
|
||||||
|
|
||||||
|
// Register Meteor methods
|
||||||
|
Meteor.methods({
|
||||||
|
'deleteEmptyLists.needsMigration'(boardId) {
|
||||||
|
check(boardId, String);
|
||||||
|
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'You must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleteDuplicateEmptyListsMigration.needsMigration(boardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
'deleteDuplicateEmptyLists.needsMigration'(boardId) {
|
||||||
|
check(boardId, String);
|
||||||
|
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'You must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleteDuplicateEmptyListsMigration.needsMigration(boardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
'deleteEmptyLists.execute'(boardId) {
|
||||||
|
check(boardId, String);
|
||||||
|
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'You must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is board admin
|
||||||
|
const board = ReactiveCache.getBoard(boardId);
|
||||||
|
if (!board) {
|
||||||
|
throw new Meteor.Error('board-not-found', 'Board not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = ReactiveCache.getUser(this.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Meteor.Error('user-not-found', 'User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only board admins can run migrations
|
||||||
|
const isBoardAdmin = board.members && board.members.some(
|
||||||
|
member => member.userId === this.userId && member.isAdmin
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isBoardAdmin && !user.isAdmin) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'Only board administrators can run migrations');
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleteDuplicateEmptyListsMigration.executeMigration(boardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
'deleteDuplicateEmptyLists.execute'(boardId) {
|
||||||
|
check(boardId, String);
|
||||||
|
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'You must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is board admin
|
||||||
|
const board = ReactiveCache.getBoard(boardId);
|
||||||
|
if (!board) {
|
||||||
|
throw new Meteor.Error('board-not-found', 'Board not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = ReactiveCache.getUser(this.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Meteor.Error('user-not-found', 'User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only board admins can run migrations
|
||||||
|
const isBoardAdmin = board.members && board.members.some(
|
||||||
|
member => member.userId === this.userId && member.isAdmin
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isBoardAdmin && !user.isAdmin) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'Only board administrators can run migrations');
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleteDuplicateEmptyListsMigration.executeMigration(boardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
'deleteEmptyLists.getStatus'(boardId) {
|
||||||
|
check(boardId, String);
|
||||||
|
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'You must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleteDuplicateEmptyListsMigration.getStatus(boardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
'deleteDuplicateEmptyLists.getStatus'(boardId) {
|
||||||
|
check(boardId, String);
|
||||||
|
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'You must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleteDuplicateEmptyListsMigration.getStatus(boardId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default deleteDuplicateEmptyListsMigration;
|
||||||
266
server/migrations/restoreAllArchived.js
Normal file
266
server/migrations/restoreAllArchived.js
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
/**
|
||||||
|
* Restore All Archived Migration
|
||||||
|
*
|
||||||
|
* Restores all archived swimlanes, lists, and cards.
|
||||||
|
* If any restored items are missing swimlaneId, listId, or cardId,
|
||||||
|
* creates/assigns proper IDs to make them visible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import { check } from 'meteor/check';
|
||||||
|
import { ReactiveCache } from '/imports/reactiveCache';
|
||||||
|
import { TAPi18n } from '/imports/i18n';
|
||||||
|
import Boards from '/models/boards';
|
||||||
|
import Lists from '/models/lists';
|
||||||
|
import Cards from '/models/cards';
|
||||||
|
import Swimlanes from '/models/swimlanes';
|
||||||
|
|
||||||
|
class RestoreAllArchivedMigration {
|
||||||
|
constructor() {
|
||||||
|
this.name = 'restoreAllArchived';
|
||||||
|
this.version = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if migration is needed for a board
|
||||||
|
*/
|
||||||
|
needsMigration(boardId) {
|
||||||
|
try {
|
||||||
|
const archivedSwimlanes = ReactiveCache.getSwimlanes({ boardId, archived: true });
|
||||||
|
const archivedLists = ReactiveCache.getLists({ boardId, archived: true });
|
||||||
|
const archivedCards = ReactiveCache.getCards({ boardId, archived: true });
|
||||||
|
|
||||||
|
return archivedSwimlanes.length > 0 || archivedLists.length > 0 || archivedCards.length > 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking if restoreAllArchived migration is needed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the migration
|
||||||
|
*/
|
||||||
|
async executeMigration(boardId) {
|
||||||
|
try {
|
||||||
|
const results = {
|
||||||
|
swimlanesRestored: 0,
|
||||||
|
listsRestored: 0,
|
||||||
|
cardsRestored: 0,
|
||||||
|
itemsFixed: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const board = ReactiveCache.getBoard(boardId);
|
||||||
|
if (!board) {
|
||||||
|
throw new Error('Board not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get archived items
|
||||||
|
const archivedSwimlanes = ReactiveCache.getSwimlanes({ boardId, archived: true });
|
||||||
|
const archivedLists = ReactiveCache.getLists({ boardId, archived: true });
|
||||||
|
const archivedCards = ReactiveCache.getCards({ boardId, archived: true });
|
||||||
|
|
||||||
|
// Get active items for reference
|
||||||
|
const activeSwimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
|
||||||
|
const activeLists = ReactiveCache.getLists({ boardId, archived: false });
|
||||||
|
|
||||||
|
// Restore all archived swimlanes
|
||||||
|
for (const swimlane of archivedSwimlanes) {
|
||||||
|
Swimlanes.update(swimlane._id, {
|
||||||
|
$set: {
|
||||||
|
archived: false,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
results.swimlanesRestored++;
|
||||||
|
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Restored swimlane: ${swimlane.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore all archived lists and fix missing swimlaneId
|
||||||
|
for (const list of archivedLists) {
|
||||||
|
const updateFields = {
|
||||||
|
archived: false,
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fix missing swimlaneId
|
||||||
|
if (!list.swimlaneId) {
|
||||||
|
// Try to find a suitable swimlane or use default
|
||||||
|
let targetSwimlane = activeSwimlanes.find(s => !s.archived);
|
||||||
|
|
||||||
|
if (!targetSwimlane) {
|
||||||
|
// No active swimlane found, create default
|
||||||
|
const swimlaneId = Swimlanes.insert({
|
||||||
|
title: TAPi18n.__('default'),
|
||||||
|
boardId: boardId,
|
||||||
|
sort: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
archived: false
|
||||||
|
});
|
||||||
|
targetSwimlane = ReactiveCache.getSwimlane(swimlaneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.swimlaneId = targetSwimlane._id;
|
||||||
|
results.itemsFixed++;
|
||||||
|
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Fixed missing swimlaneId for list: ${list.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Lists.update(list._id, {
|
||||||
|
$set: updateFields
|
||||||
|
});
|
||||||
|
results.listsRestored++;
|
||||||
|
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Restored list: ${list.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh lists after restoration
|
||||||
|
const allLists = ReactiveCache.getLists({ boardId, archived: false });
|
||||||
|
const allSwimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
|
||||||
|
|
||||||
|
// Restore all archived cards and fix missing IDs
|
||||||
|
for (const card of archivedCards) {
|
||||||
|
const updateFields = {
|
||||||
|
archived: false,
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
let needsFix = false;
|
||||||
|
|
||||||
|
// Fix missing listId
|
||||||
|
if (!card.listId) {
|
||||||
|
// Find or create a default list
|
||||||
|
let targetList = allLists.find(l => !l.archived);
|
||||||
|
|
||||||
|
if (!targetList) {
|
||||||
|
// No active list found, create one
|
||||||
|
const defaultSwimlane = allSwimlanes.find(s => !s.archived) || allSwimlanes[0];
|
||||||
|
|
||||||
|
const listId = Lists.insert({
|
||||||
|
title: TAPi18n.__('default'),
|
||||||
|
boardId: boardId,
|
||||||
|
swimlaneId: defaultSwimlane._id,
|
||||||
|
sort: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
archived: false
|
||||||
|
});
|
||||||
|
targetList = ReactiveCache.getList(listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.listId = targetList._id;
|
||||||
|
needsFix = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix missing swimlaneId
|
||||||
|
if (!card.swimlaneId) {
|
||||||
|
// Try to get swimlaneId from the card's list
|
||||||
|
if (card.listId || updateFields.listId) {
|
||||||
|
const cardList = allLists.find(l => l._id === (updateFields.listId || card.listId));
|
||||||
|
if (cardList && cardList.swimlaneId) {
|
||||||
|
updateFields.swimlaneId = cardList.swimlaneId;
|
||||||
|
} else {
|
||||||
|
// Fall back to first available swimlane
|
||||||
|
const defaultSwimlane = allSwimlanes.find(s => !s.archived) || allSwimlanes[0];
|
||||||
|
updateFields.swimlaneId = defaultSwimlane._id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fall back to first available swimlane
|
||||||
|
const defaultSwimlane = allSwimlanes.find(s => !s.archived) || allSwimlanes[0];
|
||||||
|
updateFields.swimlaneId = defaultSwimlane._id;
|
||||||
|
}
|
||||||
|
needsFix = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsFix) {
|
||||||
|
results.itemsFixed++;
|
||||||
|
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Fixed missing IDs for card: ${card.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cards.update(card._id, {
|
||||||
|
$set: updateFields
|
||||||
|
});
|
||||||
|
results.cardsRestored++;
|
||||||
|
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Restored card: ${card.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
changes: [
|
||||||
|
`Restored ${results.swimlanesRestored} archived swimlanes`,
|
||||||
|
`Restored ${results.listsRestored} archived lists`,
|
||||||
|
`Restored ${results.cardsRestored} archived cards`,
|
||||||
|
`Fixed ${results.itemsFixed} items with missing IDs`
|
||||||
|
],
|
||||||
|
results
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error executing restoreAllArchived migration:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreAllArchivedMigration = new RestoreAllArchivedMigration();
|
||||||
|
|
||||||
|
// Register Meteor methods
|
||||||
|
Meteor.methods({
|
||||||
|
'restoreAllArchived.needsMigration'(boardId) {
|
||||||
|
check(boardId, String);
|
||||||
|
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'You must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
return restoreAllArchivedMigration.needsMigration(boardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
'restoreAllArchived.execute'(boardId) {
|
||||||
|
check(boardId, String);
|
||||||
|
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'You must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is board admin
|
||||||
|
const board = ReactiveCache.getBoard(boardId);
|
||||||
|
if (!board) {
|
||||||
|
throw new Meteor.Error('board-not-found', 'Board not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = ReactiveCache.getUser(this.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Meteor.Error('user-not-found', 'User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only board admins can run migrations
|
||||||
|
const isBoardAdmin = board.members && board.members.some(
|
||||||
|
member => member.userId === this.userId && member.isAdmin
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isBoardAdmin && !user.isAdmin) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'Only board administrators can run migrations');
|
||||||
|
}
|
||||||
|
|
||||||
|
return restoreAllArchivedMigration.executeMigration(boardId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default restoreAllArchivedMigration;
|
||||||
259
server/migrations/restoreLostCards.js
Normal file
259
server/migrations/restoreLostCards.js
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
/**
|
||||||
|
* Restore Lost Cards Migration
|
||||||
|
*
|
||||||
|
* Finds and restores cards and lists that have missing swimlaneId, listId, or are orphaned.
|
||||||
|
* Creates a "Lost Cards" swimlane and restores visibility of lost items.
|
||||||
|
* Only processes non-archived items.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import { check } from 'meteor/check';
|
||||||
|
import { ReactiveCache } from '/imports/reactiveCache';
|
||||||
|
import { TAPi18n } from '/imports/i18n';
|
||||||
|
import Boards from '/models/boards';
|
||||||
|
import Lists from '/models/lists';
|
||||||
|
import Cards from '/models/cards';
|
||||||
|
import Swimlanes from '/models/swimlanes';
|
||||||
|
|
||||||
|
class RestoreLostCardsMigration {
|
||||||
|
constructor() {
|
||||||
|
this.name = 'restoreLostCards';
|
||||||
|
this.version = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if migration is needed for a board
|
||||||
|
*/
|
||||||
|
needsMigration(boardId) {
|
||||||
|
try {
|
||||||
|
const cards = ReactiveCache.getCards({ boardId, archived: false });
|
||||||
|
const lists = ReactiveCache.getLists({ boardId, archived: false });
|
||||||
|
|
||||||
|
// Check for cards missing swimlaneId or listId
|
||||||
|
const lostCards = cards.filter(card => !card.swimlaneId || !card.listId);
|
||||||
|
if (lostCards.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for lists missing swimlaneId
|
||||||
|
const lostLists = lists.filter(list => !list.swimlaneId);
|
||||||
|
if (lostLists.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for orphaned cards (cards whose list doesn't exist)
|
||||||
|
for (const card of cards) {
|
||||||
|
if (card.listId) {
|
||||||
|
const listExists = lists.some(list => list._id === card.listId);
|
||||||
|
if (!listExists) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking if restoreLostCards migration is needed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the migration
|
||||||
|
*/
|
||||||
|
async executeMigration(boardId) {
|
||||||
|
try {
|
||||||
|
const results = {
|
||||||
|
lostCardsSwimlaneCreated: false,
|
||||||
|
cardsRestored: 0,
|
||||||
|
listsRestored: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const board = ReactiveCache.getBoard(boardId);
|
||||||
|
if (!board) {
|
||||||
|
throw new Error('Board not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all non-archived items
|
||||||
|
const cards = ReactiveCache.getCards({ boardId, archived: false });
|
||||||
|
const lists = ReactiveCache.getLists({ boardId, archived: false });
|
||||||
|
const swimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
|
||||||
|
|
||||||
|
// Detect items to restore BEFORE creating anything
|
||||||
|
const lostLists = lists.filter(list => !list.swimlaneId);
|
||||||
|
const lostCards = cards.filter(card => !card.swimlaneId || !card.listId);
|
||||||
|
const orphanedCards = cards.filter(card => card.listId && !lists.some(list => list._id === card.listId));
|
||||||
|
|
||||||
|
const hasCardsWork = lostCards.length > 0 || orphanedCards.length > 0;
|
||||||
|
const hasListsWork = lostLists.length > 0;
|
||||||
|
const hasAnyWork = hasCardsWork || hasListsWork;
|
||||||
|
|
||||||
|
if (!hasAnyWork) {
|
||||||
|
// Nothing to restore; do not create swimlane or list
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
changes: [
|
||||||
|
'No lost swimlanes, lists, or cards to restore'
|
||||||
|
],
|
||||||
|
results: {
|
||||||
|
lostCardsSwimlaneCreated: false,
|
||||||
|
cardsRestored: 0,
|
||||||
|
listsRestored: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create "Lost Cards" swimlane (only if there is actual work)
|
||||||
|
let lostCardsSwimlane = swimlanes.find(s => s.title === TAPi18n.__('lost-cards'));
|
||||||
|
if (!lostCardsSwimlane) {
|
||||||
|
const swimlaneId = Swimlanes.insert({
|
||||||
|
title: TAPi18n.__('lost-cards'),
|
||||||
|
boardId: boardId,
|
||||||
|
sort: 999999, // Put at the end
|
||||||
|
color: 'red',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
archived: false
|
||||||
|
});
|
||||||
|
lostCardsSwimlane = ReactiveCache.getSwimlane(swimlaneId);
|
||||||
|
results.lostCardsSwimlaneCreated = true;
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Created "Lost Cards" swimlane for board ${boardId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore lost lists (lists without swimlaneId)
|
||||||
|
if (hasListsWork) {
|
||||||
|
for (const list of lostLists) {
|
||||||
|
Lists.update(list._id, {
|
||||||
|
$set: {
|
||||||
|
swimlaneId: lostCardsSwimlane._id,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
results.listsRestored++;
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Restored lost list: ${list.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create default list only if we need to move cards
|
||||||
|
let defaultList = null;
|
||||||
|
if (hasCardsWork) {
|
||||||
|
defaultList = lists.find(l =>
|
||||||
|
l.swimlaneId === lostCardsSwimlane._id &&
|
||||||
|
l.title === TAPi18n.__('lost-cards-list')
|
||||||
|
);
|
||||||
|
if (!defaultList) {
|
||||||
|
const listId = Lists.insert({
|
||||||
|
title: TAPi18n.__('lost-cards-list'),
|
||||||
|
boardId: boardId,
|
||||||
|
swimlaneId: lostCardsSwimlane._id,
|
||||||
|
sort: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
archived: false
|
||||||
|
});
|
||||||
|
defaultList = ReactiveCache.getList(listId);
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Created default list in Lost Cards swimlane`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore cards missing swimlaneId or listId
|
||||||
|
if (hasCardsWork) {
|
||||||
|
for (const card of lostCards) {
|
||||||
|
const updateFields = { updatedAt: new Date() };
|
||||||
|
if (!card.swimlaneId) updateFields.swimlaneId = lostCardsSwimlane._id;
|
||||||
|
if (!card.listId) updateFields.listId = defaultList._id;
|
||||||
|
Cards.update(card._id, { $set: updateFields });
|
||||||
|
results.cardsRestored++;
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Restored lost card: ${card.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore orphaned cards (cards whose list doesn't exist)
|
||||||
|
for (const card of orphanedCards) {
|
||||||
|
Cards.update(card._id, {
|
||||||
|
$set: {
|
||||||
|
listId: defaultList._id,
|
||||||
|
swimlaneId: lostCardsSwimlane._id,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
results.cardsRestored++;
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Restored orphaned card: ${card.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
changes: [
|
||||||
|
results.lostCardsSwimlaneCreated ? 'Created "Lost Cards" swimlane' : 'Using existing "Lost Cards" swimlane',
|
||||||
|
`Restored ${results.listsRestored} lost lists`,
|
||||||
|
`Restored ${results.cardsRestored} lost cards`
|
||||||
|
],
|
||||||
|
results
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error executing restoreLostCards migration:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreLostCardsMigration = new RestoreLostCardsMigration();
|
||||||
|
|
||||||
|
// Register Meteor methods
|
||||||
|
Meteor.methods({
|
||||||
|
'restoreLostCards.needsMigration'(boardId) {
|
||||||
|
check(boardId, String);
|
||||||
|
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'You must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
return restoreLostCardsMigration.needsMigration(boardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
'restoreLostCards.execute'(boardId) {
|
||||||
|
check(boardId, String);
|
||||||
|
|
||||||
|
if (!this.userId) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'You must be logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is board admin
|
||||||
|
const board = ReactiveCache.getBoard(boardId);
|
||||||
|
if (!board) {
|
||||||
|
throw new Meteor.Error('board-not-found', 'Board not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = ReactiveCache.getUser(this.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Meteor.Error('user-not-found', 'User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only board admins can run migrations
|
||||||
|
const isBoardAdmin = board.members && board.members.some(
|
||||||
|
member => member.userId === this.userId && member.isAdmin
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isBoardAdmin && !user.isAdmin) {
|
||||||
|
throw new Meteor.Error('not-authorized', 'Only board administrators can run migrations');
|
||||||
|
}
|
||||||
|
|
||||||
|
return restoreLostCardsMigration.executeMigration(boardId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default restoreLostCardsMigration;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue