Security Fix 1: There was not enough permission checks. Moved migrations to Admin Panel/Settings/Cron.

Thanks to [Joshua Rogers](https://joshua.hu) of [Aisle Research](https://aisle.com) and xet7.
This commit is contained in:
Lauri Ojansivu 2026-01-06 00:15:16 +02:00
parent d6834d0287
commit cbb1cd78de
18 changed files with 397 additions and 1805 deletions

View file

@ -10,8 +10,8 @@ import '/client/lib/boardConverter';
import '/client/components/boardConversionProgress';
// Import migration manager and progress UI
import '/client/lib/migrationManager';
import '/client/components/migrationProgress';
import '/client/lib/attachmentMigrationManager';
import '/client/components/settings/migrationProgress';
// Import cron settings
import '/client/components/settings/cronSettings';

View file

@ -1,8 +1,6 @@
template(name="board")
if isMigrating.get
+migrationProgress
else if isConverting.get
if isConverting.get
+boardConversionProgress
else if isBoardReady.get
if currentBoard

View file

@ -3,9 +3,6 @@ import '../gantt/gantt.js';
import { TAPi18n } from '/imports/i18n';
import dragscroll from '@wekanteam/dragscroll';
import { boardConverter } from '/client/lib/boardConverter';
import { migrationManager } from '/client/lib/migrationManager';
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
import { migrationProgressManager } from '/client/components/migrationProgress';
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
import Swimlanes from '/models/swimlanes';
import Lists from '/models/lists';
@ -18,7 +15,6 @@ BlazeComponent.extendComponent({
onCreated() {
this.isBoardReady = new ReactiveVar(false);
this.isConverting = new ReactiveVar(false);
this.isMigrating = new ReactiveVar(false);
this._swimlaneCreated = new Set(); // Track boards where we've created swimlanes
this._boardProcessed = false; // Track if board has been processed
this._lastProcessedBoardId = null; // Track last processed board ID
@ -36,7 +32,6 @@ BlazeComponent.extendComponent({
// Use a separate autorun for subscription ready state to avoid reactive loops
this.subscriptionReadyAutorun = Tracker.autorun(() => {
if (handle.ready()) {
// Only run conversion/migration logic once per board
if (!this._boardProcessed || this._lastProcessedBoardId !== currentBoardId) {
this._boardProcessed = true;
this._lastProcessedBoardId = currentBoardId;
@ -101,416 +96,15 @@ BlazeComponent.extendComponent({
return;
}
// Automatic migration disabled - migrations must be run manually from sidebar
// Board admins can run migrations from the sidebar Migrations menu
this.isBoardReady.set(true);
} catch (error) {
console.error('Error during board conversion check:', error);
this.isConverting.set(false);
this.isMigrating.set(false);
this.isBoardReady.set(true); // Show board even if conversion check failed
}
},
/**
* Check if board needs comprehensive migration
*/
async checkComprehensiveMigration(boardId) {
try {
return new Promise((resolve, reject) => {
Meteor.call('comprehensiveBoardMigration.needsMigration', boardId, (error, result) => {
if (error) {
console.error('Error checking comprehensive migration:', error);
reject(error);
} else {
resolve(result);
}
});
});
} catch (error) {
console.error('Error checking comprehensive migration:', error);
return false;
}
},
/**
* Execute comprehensive migration for a board
*/
async executeComprehensiveMigration(boardId) {
try {
// Start progress tracking
migrationProgressManager.startMigration();
// Simulate progress updates since we can't easily pass callbacks through Meteor methods
const progressSteps = [
{ step: 'analyze_board_structure', name: 'Analyze Board Structure', duration: 1000 },
{ step: 'fix_orphaned_cards', name: 'Fix Orphaned Cards', duration: 2000 },
{ step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 3000 },
{ step: 'ensure_per_swimlane_lists', name: 'Ensure Per-Swimlane Lists', duration: 1500 },
{ step: 'validate_migration', name: 'Validate Migration', duration: 1000 },
{ step: 'fix_avatar_urls', name: 'Fix Avatar URLs', duration: 1000 },
{ step: 'fix_attachment_urls', name: 'Fix Attachment URLs', duration: 1000 }
];
// Start the actual migration
const migrationPromise = new Promise((resolve, reject) => {
Meteor.call('comprehensiveBoardMigration.execute', boardId, (error, result) => {
if (error) {
console.error('Error executing comprehensive migration:', error);
migrationProgressManager.failMigration(error);
reject(error);
} else {
if (process.env.DEBUG === 'true') {
console.log('Comprehensive migration completed for board:', boardId, result);
}
resolve(result.success);
}
});
});
// Simulate progress updates
const progressPromise = this.simulateMigrationProgress(progressSteps);
// Wait for both to complete
const [migrationResult] = await Promise.all([migrationPromise, progressPromise]);
migrationProgressManager.completeMigration();
return migrationResult;
} catch (error) {
console.error('Error executing comprehensive migration:', error);
migrationProgressManager.failMigration(error);
return false;
}
},
/**
* Simulate migration progress updates
*/
async simulateMigrationProgress(progressSteps) {
const totalSteps = progressSteps.length;
for (let i = 0; i < progressSteps.length; i++) {
const step = progressSteps[i];
const stepProgress = Math.round(((i + 1) / totalSteps) * 100);
// Update progress for this step
migrationProgressManager.updateProgress({
overallProgress: stepProgress,
currentStep: i + 1,
totalSteps,
stepName: step.step,
stepProgress: 0,
stepStatus: `Starting ${step.name}...`,
stepDetails: null,
boardId: Session.get('currentBoard')
});
// Simulate step progress
const stepDuration = step.duration;
const updateInterval = 100; // Update every 100ms
const totalUpdates = stepDuration / updateInterval;
for (let j = 0; j < totalUpdates; j++) {
const stepStepProgress = Math.round(((j + 1) / totalUpdates) * 100);
migrationProgressManager.updateProgress({
overallProgress: stepProgress,
currentStep: i + 1,
totalSteps,
stepName: step.step,
stepProgress: stepStepProgress,
stepStatus: `Processing ${step.name}...`,
stepDetails: { progress: `${stepStepProgress}%` },
boardId: Session.get('currentBoard')
});
await new Promise(resolve => setTimeout(resolve, updateInterval));
}
// Complete the step
migrationProgressManager.updateProgress({
overallProgress: stepProgress,
currentStep: i + 1,
totalSteps,
stepName: step.step,
stepProgress: 100,
stepStatus: `${step.name} completed`,
stepDetails: { status: 'completed' },
boardId: Session.get('currentBoard')
});
}
},
async startBackgroundMigration(boardId) {
try {
// Start background migration using the cron system
Meteor.call('boardMigration.startBoardMigration', boardId, (error, result) => {
if (error) {
console.error('Failed to start background migration:', error);
} else {
if (process.env.DEBUG === 'true') {
console.log('Background migration started for board:', boardId);
}
}
});
} catch (error) {
console.error('Error starting background migration:', error);
}
},
async convertSharedListsToPerSwimlane(boardId) {
try {
const board = ReactiveCache.getBoard(boardId);
if (!board) return;
// Check if board has already been processed for shared lists conversion
if (board.hasSharedListsConverted) {
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} has already been processed for shared lists conversion`);
}
return;
}
// Get all lists for this board
const allLists = board.lists();
const swimlanes = board.swimlanes();
if (swimlanes.length === 0) {
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} has no swimlanes, skipping shared lists conversion`);
}
return;
}
// Find shared lists (lists with empty swimlaneId or null swimlaneId)
const sharedLists = allLists.filter(list => !list.swimlaneId || list.swimlaneId === '');
if (sharedLists.length === 0) {
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} has no shared lists to convert`);
}
// Mark as processed even if no shared lists
Boards.update(boardId, { $set: { hasSharedListsConverted: true } });
return;
}
if (process.env.DEBUG === 'true') {
console.log(`Converting ${sharedLists.length} shared lists to per-swimlane lists for board ${boardId}`);
}
// Convert each shared list to per-swimlane lists
for (const sharedList of sharedLists) {
// Create a copy of the list for each swimlane
for (const swimlane of swimlanes) {
// Check if this list already exists in this swimlane
const existingList = Lists.findOne({
boardId: boardId,
swimlaneId: swimlane._id,
title: sharedList.title
});
if (!existingList) {
// Double-check to avoid race conditions
const doubleCheckList = ReactiveCache.getList({
boardId: boardId,
swimlaneId: swimlane._id,
title: sharedList.title
});
if (!doubleCheckList) {
// Create a new list in this swimlane
const newListData = {
title: sharedList.title,
boardId: boardId,
swimlaneId: swimlane._id,
sort: sharedList.sort || 0,
archived: sharedList.archived || false, // Preserve archived state from original list
createdAt: new Date(),
modifiedAt: new Date()
};
// Copy other properties if they exist
if (sharedList.color) newListData.color = sharedList.color;
if (sharedList.wipLimit) newListData.wipLimit = sharedList.wipLimit;
if (sharedList.wipLimitEnabled) newListData.wipLimitEnabled = sharedList.wipLimitEnabled;
if (sharedList.wipLimitSoft) newListData.wipLimitSoft = sharedList.wipLimitSoft;
Lists.insert(newListData);
if (process.env.DEBUG === 'true') {
const archivedStatus = sharedList.archived ? ' (archived)' : ' (active)';
console.log(`Created list "${sharedList.title}"${archivedStatus} for swimlane ${swimlane.title || swimlane._id}`);
}
} else {
if (process.env.DEBUG === 'true') {
console.log(`List "${sharedList.title}" already exists in swimlane ${swimlane.title || swimlane._id} (double-check), skipping`);
}
}
} else {
if (process.env.DEBUG === 'true') {
console.log(`List "${sharedList.title}" already exists in swimlane ${swimlane.title || swimlane._id}, skipping`);
}
}
}
// Remove the original shared list completely
Lists.remove(sharedList._id);
if (process.env.DEBUG === 'true') {
console.log(`Removed shared list "${sharedList.title}"`);
}
}
// Mark board as processed
Boards.update(boardId, { $set: { hasSharedListsConverted: true } });
if (process.env.DEBUG === 'true') {
console.log(`Successfully converted ${sharedLists.length} shared lists to per-swimlane lists for board ${boardId}`);
}
} catch (error) {
console.error('Error converting shared lists to per-swimlane:', error);
}
},
async fixMissingLists(boardId) {
try {
const board = ReactiveCache.getBoard(boardId);
if (!board) return;
// Check if board has already been processed for missing lists fix
if (board.fixMissingListsCompleted) {
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} has already been processed for missing lists fix`);
}
return;
}
// Check if migration is needed
const needsMigration = await new Promise((resolve, reject) => {
Meteor.call('fixMissingListsMigration.needsMigration', boardId, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
if (!needsMigration) {
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} does not need missing lists fix`);
}
return;
}
if (process.env.DEBUG === 'true') {
console.log(`Starting fix missing lists migration for board ${boardId}`);
}
// Execute the migration
const result = await new Promise((resolve, reject) => {
Meteor.call('fixMissingListsMigration.execute', boardId, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
if (result && result.success) {
if (process.env.DEBUG === 'true') {
console.log(`Successfully fixed missing lists for board ${boardId}: created ${result.createdLists} lists, updated ${result.updatedCards} cards`);
}
}
} catch (error) {
console.error('Error fixing missing lists:', error);
}
},
async fixDuplicateLists(boardId) {
try {
const board = ReactiveCache.getBoard(boardId);
if (!board) return;
// Check if board has already been processed for duplicate lists fix
if (board.fixDuplicateListsCompleted) {
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} has already been processed for duplicate lists fix`);
}
return;
}
if (process.env.DEBUG === 'true') {
console.log(`Starting duplicate lists fix for board ${boardId}`);
}
// Execute the duplicate lists fix
const result = await new Promise((resolve, reject) => {
Meteor.call('fixDuplicateLists.fixBoard', boardId, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
if (result && result.fixed > 0) {
if (process.env.DEBUG === 'true') {
console.log(`Successfully fixed ${result.fixed} duplicate lists for board ${boardId}: ${result.fixedSwimlanes} swimlanes, ${result.fixedLists} lists`);
}
// Mark board as processed
Boards.update(boardId, { $set: { fixDuplicateListsCompleted: true } });
} else if (process.env.DEBUG === 'true') {
console.log(`No duplicate lists found for board ${boardId}`);
// Still mark as processed to avoid repeated checks
Boards.update(boardId, { $set: { fixDuplicateListsCompleted: true } });
} else {
// Still mark as processed to avoid repeated checks
Boards.update(boardId, { $set: { fixDuplicateListsCompleted: true } });
}
} catch (error) {
console.error('Error fixing duplicate lists:', error);
}
},
async startAttachmentMigrationIfNeeded(boardId) {
try {
// Check if board has already been migrated
if (attachmentMigrationManager.isBoardMigrated(boardId)) {
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} has already been migrated, skipping`);
}
return;
}
// Check if there are unconverted attachments
const unconvertedAttachments = attachmentMigrationManager.getUnconvertedAttachments(boardId);
if (unconvertedAttachments.length > 0) {
if (process.env.DEBUG === 'true') {
console.log(`Starting attachment migration for ${unconvertedAttachments.length} attachments in board ${boardId}`);
}
await attachmentMigrationManager.startAttachmentMigration(boardId);
} else {
// No attachments to migrate, mark board as migrated
// This will be handled by the migration manager itself
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} has no attachments to migrate`);
}
}
} catch (error) {
console.error('Error starting attachment migration:', error);
}
},
onlyShowCurrentCard() {
const isMiniScreen = Utils.isMiniScreen();
const currentCardId = Utils.getCurrentCardId(true);
@ -535,10 +129,6 @@ BlazeComponent.extendComponent({
return this.isConverting.get();
},
isMigrating() {
return this.isMigrating.get();
},
isBoardReady() {
return this.isBoardReady.get();
},
@ -1046,7 +636,6 @@ BlazeComponent.extendComponent({
const currentBoardId = Session.get('currentBoard');
const isBoardReady = this.isBoardReady.get();
const isConverting = this.isConverting.get();
const isMigrating = this.isMigrating.get();
const boardView = Utils.boardView();
if (process.env.DEBUG === 'true') {
@ -1055,7 +644,6 @@ BlazeComponent.extendComponent({
console.log('currentBoard:', !!currentBoard, currentBoard ? currentBoard.title : 'none');
console.log('isBoardReady:', isBoardReady);
console.log('isConverting:', isConverting);
console.log('isMigrating:', isMigrating);
console.log('boardView:', boardView);
console.log('========================');
}
@ -1066,7 +654,6 @@ BlazeComponent.extendComponent({
currentBoardTitle: currentBoard ? currentBoard.title : 'none',
isBoardReady,
isConverting,
isMigrating,
boardView
};
},

View file

@ -336,36 +336,3 @@
margin-top: 10px;
}
}
/* Attachment migration styles */
.attachment-item.migrating {
position: relative;
opacity: 0.7;
}
.attachment-migration-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
border-radius: 4px;
}
.migration-spinner {
font-size: 24px;
color: #007cba;
margin-bottom: 8px;
}
.migration-text {
font-size: 12px;
color: #666;
text-align: center;
}

View file

@ -79,7 +79,6 @@ template(name="defaultLayout")
| {{{afterBodyStart}}}
+Template.dynamic(template=content)
| {{{beforeBodyEnd}}}
+migrationProgress
+boardConversionProgress
if (Modal.isOpen)
#modal

View file

@ -266,4 +266,36 @@
.migration-progress-note {
color: #a0aec0;
}
}
}
/* Attachment migration styles */
.attachment-item.migrating {
position: relative;
opacity: 0.7;
}
.attachment-migration-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
border-radius: 4px;
}
.migration-spinner {
font-size: 24px;
color: #007cba;
margin-bottom: 8px;
}
.migration-text {
font-size: 12px;
color: #666;
text-align: center;
}

View file

@ -170,7 +170,10 @@ template(name="setting")
label {{_ 'migration-status'}}
.status-indicator
span.status-label {{_ 'status'}}:
span.status-value {{migrationStatus}}
span.status-value {{#if isMigrating}}{{migrationStatus}}{{else}}{{_ 'idle'}}{{/if}}
.current-step(class="{{#unless migrationCurrentStep}}hide{{/unless}}")
span.step-label {{_ 'current-step'}}:
span.step-value {{migrationCurrentStep}}
.progress-section
.progress
.progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100")
@ -179,9 +182,13 @@ template(name="setting")
| {{migrationProgress}}% {{_ 'complete'}}
.form-group
button.js-start-all-migrations.btn.btn-primary {{_ 'start-all-migrations'}}
button.js-pause-all-migrations.btn.btn-warning {{_ 'pause-all-migrations'}}
button.js-stop-all-migrations.btn.btn-danger {{_ 'stop-all-migrations'}}
button.js-start-all-migrations.btn.btn-primary {{#if isMigrating}}disabled{{/if}} {{_ 'start-all-migrations'}}
button.js-pause-all-migrations.btn.btn-warning {{#unless isMigrating}}disabled{{/unless}} {{_ 'pause-all-migrations'}}
button.js-stop-all-migrations.btn.btn-danger {{#unless isMigrating}}disabled{{/unless}} {{_ 'stop-all-migrations'}}
li
h3 {{_ 'migration-steps'}}
p Migration steps section temporarily removed
li
h3 {{_ 'board-operations'}}
@ -200,7 +207,7 @@ template(name="setting")
.job-info
.job-name {{name}}
.job-schedule {{schedule}}
.job-description {{description}}
.job-status {{status}}
.job-actions
button.js-pause-job.btn.btn-sm.btn-warning(data-job-id="{{_id}}") {{_ 'pause'}}
button.js-delete-job.btn.btn-sm.btn-danger(data-job-id="{{_id}}") {{_ 'delete'}}
@ -274,7 +281,7 @@ template(name='email')
// li.smtp-form
// .title {{_ 'smtp-username'}}
// .form-group
// input.wekan-form-control#mail-server-u"accounts-allowUserNameChange": "Allow Username Change",sername(type="text", placeholder="{{_ 'username'}}" value="{{currentSetting.mailServer.username}}")
// input.wekan-form-control#mail-server-username(type="text", placeholder="{{_ 'username'}}" value="{{currentSetting.mailServer.username}}")
// li.smtp-form
// .title {{_ 'smtp-password'}}
// .form-group

View file

@ -2,6 +2,14 @@ import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { ALLOWED_WAIT_SPINNERS } from '/config/const';
import LockoutSettings from '/models/lockoutSettings';
import {
cronMigrationProgress,
cronMigrationStatus,
cronMigrationCurrentStep,
cronMigrationSteps,
cronIsMigrating,
cronJobs
} from '/imports/cronMigrationClient';
BlazeComponent.extendComponent({
@ -115,15 +123,27 @@ BlazeComponent.extendComponent({
// Cron settings helpers
migrationStatus() {
return TAPi18n.__('idle'); // Placeholder
return cronMigrationStatus.get() || TAPi18n.__('idle');
},
migrationProgress() {
return 0; // Placeholder
return cronMigrationProgress.get() || 0;
},
migrationCurrentStep() {
return cronMigrationCurrentStep.get() || '';
},
isMigrating() {
return cronIsMigrating.get() || false;
},
migrationSteps() {
return cronMigrationSteps.get() || [];
},
cronJobs() {
return []; // Placeholder
return cronJobs.get() || [];
},
setLoading(w) {
@ -169,7 +189,9 @@ BlazeComponent.extendComponent({
// Event handlers for cron settings
'click button.js-start-all-migrations'(event) {
event.preventDefault();
Meteor.call('startAllMigrations', (error, result) => {
this.setLoading(true);
Meteor.call('cron.startAllMigrations', (error, result) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('migration-start-failed') + ': ' + error.reason);
} else {
@ -180,7 +202,9 @@ BlazeComponent.extendComponent({
'click button.js-pause-all-migrations'(event) {
event.preventDefault();
Meteor.call('pauseAllMigrations', (error, result) => {
this.setLoading(true);
Meteor.call('cron.pauseAllMigrations', (error, result) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('migration-pause-failed') + ': ' + error.reason);
} else {
@ -192,7 +216,9 @@ BlazeComponent.extendComponent({
'click button.js-stop-all-migrations'(event) {
event.preventDefault();
if (confirm(TAPi18n.__('migration-stop-confirm'))) {
Meteor.call('stopAllMigrations', (error, result) => {
this.setLoading(true);
Meteor.call('cron.stopAllMigrations', (error, result) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('migration-stop-failed') + ': ' + error.reason);
} else {
@ -204,41 +230,28 @@ BlazeComponent.extendComponent({
'click button.js-schedule-board-cleanup'(event) {
event.preventDefault();
Meteor.call('scheduleBoardCleanup', (error, result) => {
if (error) {
alert(TAPi18n.__('board-cleanup-failed') + ': ' + error.reason);
} else {
alert(TAPi18n.__('board-cleanup-scheduled'));
}
});
// Placeholder - board cleanup scheduling
alert(TAPi18n.__('board-cleanup-scheduled'));
},
'click button.js-schedule-board-archive'(event) {
event.preventDefault();
Meteor.call('scheduleBoardArchive', (error, result) => {
if (error) {
alert(TAPi18n.__('board-archive-failed') + ': ' + error.reason);
} else {
alert(TAPi18n.__('board-archive-scheduled'));
}
});
// Placeholder - board archive scheduling
alert(TAPi18n.__('board-archive-scheduled'));
},
'click button.js-schedule-board-backup'(event) {
event.preventDefault();
Meteor.call('scheduleBoardBackup', (error, result) => {
if (error) {
alert(TAPi18n.__('board-backup-failed') + ': ' + error.reason);
} else {
alert(TAPi18n.__('board-backup-scheduled'));
}
});
// Placeholder - board backup scheduling
alert(TAPi18n.__('board-backup-scheduled'));
},
'click button.js-pause-job'(event) {
event.preventDefault();
const jobId = $(event.target).data('job-id');
Meteor.call('pauseCronJob', jobId, (error, result) => {
this.setLoading(true);
Meteor.call('cron.pauseJob', jobId, (error, result) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('cron-job-pause-failed') + ': ' + error.reason);
} else {
@ -251,7 +264,9 @@ BlazeComponent.extendComponent({
event.preventDefault();
const jobId = $(event.target).data('job-id');
if (confirm(TAPi18n.__('cron-job-delete-confirm'))) {
Meteor.call('deleteCronJob', jobId, (error, result) => {
this.setLoading(true);
Meteor.call('cron.removeJob', jobId, (error, result) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('cron-job-delete-failed') + ': ' + error.reason);
} else {

View file

@ -588,10 +588,6 @@ template(name="boardMenuPopup")
| 📦
| {{_ 'archived-items'}}
if currentUser.isBoardAdmin
li
a.js-open-migrations
| 🔧
| {{_ 'migrations'}}
li
a.js-change-board-color
| 🎨

View file

@ -13,7 +13,6 @@ const viewTitles = {
multiselection: 'multi-selection',
customFields: 'custom-fields',
archives: 'archives',
migrations: 'migrations',
};
BlazeComponent.extendComponent({
@ -284,10 +283,6 @@ Template.boardMenuPopup.events({
Sidebar.setView('archives');
Popup.back();
},
'click .js-open-migrations'() {
Sidebar.setView('migrations');
Popup.back();
},
'click .js-change-board-color': Popup.open('boardChangeColor'),
'click .js-change-background-image': Popup.open('boardChangeBackgroundImage'),
'click .js-board-info-on-my-boards': Popup.open('boardInfoOnMyBoards'),

View file

@ -1,109 +0,0 @@
template(name='migrationsSidebar')
if currentUser.isBoardAdmin
.sidebar-migrations
h3
| 🔧
| {{_ 'migrations'}}
p.quiet {{_ 'migrations-description'}}
.migrations-list
h4 {{_ 'board-migrations'}}
.migration-item
a.js-run-migration(data-migration="comprehensive")
.migration-name
| {{_ 'comprehensive-board-migration'}}
.migration-status
if comprehensiveMigrationNeeded
span.badge.badge-warning {{_ 'migration-needed'}}
else
span.badge.badge-success {{_ 'migration-complete'}}
.migration-item
a.js-run-migration(data-migration="fixMissingLists")
.migration-name
| {{_ 'fix-missing-lists-migration'}}
.migration-status
if fixMissingListsNeeded
span.badge.badge-warning {{_ 'migration-needed'}}
else
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'}}
.migration-item
a.js-run-migration(data-migration="fixAvatarUrls")
.migration-name
| {{_ 'fix-avatar-urls-migration'}}
.migration-status
if fixAvatarUrlsNeeded
span.badge.badge-warning {{_ 'migration-needed'}}
else
span.badge.badge-success {{_ 'migration-complete'}}
.migration-item
a.js-run-migration(data-migration="fixAllFileUrls")
.migration-name
| {{_ 'fix-all-file-urls-migration'}}
.migration-status
if fixAllFileUrlsNeeded
span.badge.badge-warning {{_ 'migration-needed'}}
else
span.badge.badge-success {{_ 'migration-complete'}}
else
p.quiet {{_ 'migrations-admin-only'}}
template(name='runComprehensiveMigrationPopup')
p {{_ 'run-comprehensive-migration-confirm'}}
button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
template(name='runFixMissingListsMigrationPopup')
p {{_ 'run-fix-missing-lists-migration-confirm'}}
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')
p {{_ 'run-fix-avatar-urls-migration-confirm'}}
button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
template(name='runFixAllFileUrlsMigrationPopup')
p {{_ 'run-fix-all-file-urls-migration-confirm'}}
button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}

View file

@ -1,341 +0,0 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { migrationProgressManager } from '/client/components/migrationProgress';
BlazeComponent.extendComponent({
onCreated() {
this.migrationStatuses = new ReactiveVar({});
this.loadMigrationStatuses();
},
loadMigrationStatuses() {
const boardId = Session.get('currentBoard');
if (!boardId) return;
// Check comprehensive migration
Meteor.call('comprehensiveBoardMigration.needsMigration', boardId, (err, res) => {
if (!err) {
const statuses = this.migrationStatuses.get();
statuses.comprehensive = res;
this.migrationStatuses.set(statuses);
}
});
// Check fix missing lists migration
Meteor.call('fixMissingListsMigration.needsMigration', boardId, (err, res) => {
if (!err) {
const statuses = this.migrationStatuses.get();
statuses.fixMissingLists = res;
this.migrationStatuses.set(statuses);
}
});
// Check delete duplicate empty lists migration
Meteor.call('deleteDuplicateEmptyLists.needsMigration', boardId, (err, res) => {
if (!err) {
const statuses = this.migrationStatuses.get();
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 (board-specific)
Meteor.call('fixAvatarUrls.needsMigration', boardId, (err, res) => {
if (!err) {
const statuses = this.migrationStatuses.get();
statuses.fixAvatarUrls = res;
this.migrationStatuses.set(statuses);
}
});
// Check fix all file URLs migration (board-specific)
Meteor.call('fixAllFileUrls.needsMigration', boardId, (err, res) => {
if (!err) {
const statuses = this.migrationStatuses.get();
statuses.fixAllFileUrls = res;
this.migrationStatuses.set(statuses);
}
});
},
comprehensiveMigrationNeeded() {
return this.migrationStatuses.get().comprehensive === true;
},
fixMissingListsNeeded() {
return this.migrationStatuses.get().fixMissingLists === true;
},
deleteDuplicateEmptyListsNeeded() {
return this.migrationStatuses.get().deleteDuplicateEmptyLists === true;
},
restoreLostCardsNeeded() {
return this.migrationStatuses.get().restoreLostCards === true;
},
restoreAllArchivedNeeded() {
return this.migrationStatuses.get().restoreAllArchived === true;
},
fixAvatarUrlsNeeded() {
return this.migrationStatuses.get().fixAvatarUrls === true;
},
fixAllFileUrlsNeeded() {
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) {
const boardId = Session.get('currentBoard');
let methodName;
let methodArgs = [];
switch (migrationType) {
case 'comprehensive':
methodName = 'comprehensiveBoardMigration.execute';
methodArgs = [boardId];
break;
case 'fixMissingLists':
methodName = 'fixMissingListsMigration.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':
methodName = 'fixAvatarUrls.execute';
methodArgs = [boardId];
break;
case 'fixAllFileUrls':
methodName = 'fixAllFileUrls.execute';
methodArgs = [boardId];
break;
}
if (methodName) {
// Define simulated steps per migration type
const stepsByType = {
comprehensive: [
{ step: 'analyze_board_structure', name: 'Analyze Board Structure', duration: 800 },
{ 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 },
],
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: 'Checking board member avatars', duration: 500 },
{ step: 'fix_urls', name: 'Fixing avatar URLs', duration: 900 },
],
fixAllFileUrls: [
{ step: 'scan_files', name: 'Checking board file attachments', duration: 600 },
{ step: 'fix_urls', name: 'Fixing 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 {
const result = callRes.value;
// Summarize result details in the popup
let summary = {};
if (result && result.results) {
// 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(() => {
this.loadMigrationStatuses();
}, 1000);
}
});
}
},
events() {
const self = this; // Capture component reference
return [
{
'click .js-run-migration[data-migration="comprehensive"]': Popup.afterConfirm('runComprehensiveMigration', function() {
self.runMigration('comprehensive');
Popup.back();
}),
'click .js-run-migration[data-migration="fixMissingLists"]': Popup.afterConfirm('runFixMissingListsMigration', function() {
self.runMigration('fixMissingLists');
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() {
self.runMigration('fixAvatarUrls');
Popup.back();
}),
'click .js-run-migration[data-migration="fixAllFileUrls"]': Popup.afterConfirm('runFixAllFileUrlsMigration', function() {
self.runMigration('fixAllFileUrls');
Popup.back();
}),
},
];
},
}).register('migrationsSidebar');

View file

@ -1,815 +0,0 @@
/**
* Migration Manager
* Handles all database migrations as steps during board loading
* with detailed progress tracking and background persistence
*/
import { ReactiveVar } from 'meteor/reactive-var';
import { ReactiveCache } from '/imports/reactiveCache';
// Reactive variables for migration progress
export const migrationProgress = new ReactiveVar(0);
export const migrationStatus = new ReactiveVar('');
export const migrationCurrentStep = new ReactiveVar('');
export const migrationSteps = new ReactiveVar([]);
export const isMigrating = new ReactiveVar(false);
export const migrationEstimatedTime = new ReactiveVar('');
class MigrationManager {
constructor() {
this.migrationCache = new Map(); // Cache completed migrations
this.steps = this.initializeMigrationSteps();
this.currentStepIndex = 0;
this.startTime = null;
}
/**
* Initialize all migration steps with their details
*/
initializeMigrationSteps() {
return [
{
id: 'board-background-color',
name: 'Board Background Colors',
description: 'Setting up board background colors',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-cardcounterlist-allowed',
name: 'Card Counter List Settings',
description: 'Adding card counter list permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-boardmemberlist-allowed',
name: 'Board Member List Settings',
description: 'Adding board member list permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'lowercase-board-permission',
name: 'Board Permission Standardization',
description: 'Converting board permissions to lowercase',
weight: 1,
completed: false,
progress: 0
},
{
id: 'change-attachments-type-for-non-images',
name: 'Attachment Type Standardization',
description: 'Updating attachment types for non-images',
weight: 2,
completed: false,
progress: 0
},
{
id: 'card-covers',
name: 'Card Covers System',
description: 'Setting up card cover functionality',
weight: 2,
completed: false,
progress: 0
},
{
id: 'use-css-class-for-boards-colors',
name: 'Board Color CSS Classes',
description: 'Converting board colors to CSS classes',
weight: 2,
completed: false,
progress: 0
},
{
id: 'denormalize-star-number-per-board',
name: 'Board Star Counts',
description: 'Calculating star counts per board',
weight: 3,
completed: false,
progress: 0
},
{
id: 'add-member-isactive-field',
name: 'Member Activity Status',
description: 'Adding member activity tracking',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-sort-checklists',
name: 'Checklist Sorting',
description: 'Adding sort order to checklists',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-swimlanes',
name: 'Swimlanes System',
description: 'Setting up swimlanes functionality',
weight: 4,
completed: false,
progress: 0
},
{
id: 'add-views',
name: 'Board Views',
description: 'Adding board view options',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-checklist-items',
name: 'Checklist Items',
description: 'Setting up checklist items system',
weight: 3,
completed: false,
progress: 0
},
{
id: 'add-card-types',
name: 'Card Types',
description: 'Adding card type functionality',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-custom-fields-to-cards',
name: 'Custom Fields',
description: 'Adding custom fields to cards',
weight: 3,
completed: false,
progress: 0
},
{
id: 'add-requester-field',
name: 'Requester Field',
description: 'Adding requester field to cards',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-assigner-field',
name: 'Assigner Field',
description: 'Adding assigner field to cards',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-parent-field-to-cards',
name: 'Card Parent Relationships',
description: 'Adding parent field to cards',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-subtasks-boards',
name: 'Subtasks Boards',
description: 'Setting up subtasks board functionality',
weight: 3,
completed: false,
progress: 0
},
{
id: 'add-subtasks-sort',
name: 'Subtasks Sorting',
description: 'Adding sort order to subtasks',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-subtasks-allowed',
name: 'Subtasks Permissions',
description: 'Adding subtasks permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-authenticationMethod',
name: 'Authentication Methods',
description: 'Adding authentication method tracking',
weight: 2,
completed: false,
progress: 0
},
{
id: 'remove-tag',
name: 'Remove Tag Field',
description: 'Removing deprecated tag field',
weight: 1,
completed: false,
progress: 0
},
{
id: 'remove-customFields-references-broken',
name: 'Fix Custom Fields References',
description: 'Fixing broken custom field references',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-product-name',
name: 'Product Name Settings',
description: 'Adding product name configuration',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-hide-logo',
name: 'Hide Logo Setting',
description: 'Adding hide logo option',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-hide-card-counter-list',
name: 'Hide Card Counter Setting',
description: 'Adding hide card counter option',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-hide-board-member-list',
name: 'Hide Board Member List Setting',
description: 'Adding hide board member list option',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-displayAuthenticationMethod',
name: 'Display Authentication Method',
description: 'Adding authentication method display option',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-defaultAuthenticationMethod',
name: 'Default Authentication Method',
description: 'Setting default authentication method',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-templates',
name: 'Board Templates',
description: 'Setting up board templates system',
weight: 3,
completed: false,
progress: 0
},
{
id: 'fix-circular-reference_',
name: 'Fix Circular References',
description: 'Fixing circular references in cards',
weight: 2,
completed: false,
progress: 0
},
{
id: 'mutate-boardIds-in-customfields',
name: 'Custom Fields Board IDs',
description: 'Updating board IDs in custom fields',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-missing-created-and-modified',
name: 'Missing Timestamps',
description: 'Adding missing created and modified timestamps',
weight: 4,
completed: false,
progress: 0
},
{
id: 'fix-incorrect-dates',
name: 'Fix Incorrect Dates',
description: 'Correcting incorrect date values',
weight: 3,
completed: false,
progress: 0
},
{
id: 'add-assignee',
name: 'Assignee Field',
description: 'Adding assignee field to cards',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-profile-showDesktopDragHandles',
name: 'Desktop Drag Handles',
description: 'Adding desktop drag handles preference',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-profile-hiddenMinicardLabelText',
name: 'Hidden Minicard Labels',
description: 'Adding hidden minicard label text preference',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-receiveddate-allowed',
name: 'Received Date Permissions',
description: 'Adding received date permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-startdate-allowed',
name: 'Start Date Permissions',
description: 'Adding start date permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-duedate-allowed',
name: 'Due Date Permissions',
description: 'Adding due date permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-enddate-allowed',
name: 'End Date Permissions',
description: 'Adding end date permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-members-allowed',
name: 'Members Permissions',
description: 'Adding members permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-assignee-allowed',
name: 'Assignee Permissions',
description: 'Adding assignee permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-labels-allowed',
name: 'Labels Permissions',
description: 'Adding labels permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-checklists-allowed',
name: 'Checklists Permissions',
description: 'Adding checklists permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-attachments-allowed',
name: 'Attachments Permissions',
description: 'Adding attachments permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-comments-allowed',
name: 'Comments Permissions',
description: 'Adding comments permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-assigned-by-allowed',
name: 'Assigned By Permissions',
description: 'Adding assigned by permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-requested-by-allowed',
name: 'Requested By Permissions',
description: 'Adding requested by permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-activities-allowed',
name: 'Activities Permissions',
description: 'Adding activities permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-description-title-allowed',
name: 'Description Title Permissions',
description: 'Adding description title permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-description-text-allowed',
name: 'Description Text Permissions',
description: 'Adding description text permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-description-text-allowed-on-minicard',
name: 'Minicard Description Permissions',
description: 'Adding minicard description permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-sort-field-to-boards',
name: 'Board Sort Field',
description: 'Adding sort field to boards',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-default-profile-view',
name: 'Default Profile View',
description: 'Setting default profile view',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-hide-logo-by-default',
name: 'Hide Logo Default',
description: 'Setting hide logo as default',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-hide-card-counter-list-by-default',
name: 'Hide Card Counter Default',
description: 'Setting hide card counter as default',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-hide-board-member-list-by-default',
name: 'Hide Board Member List Default',
description: 'Setting hide board member list as default',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-card-number-allowed',
name: 'Card Number Permissions',
description: 'Adding card number permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'assign-boardwise-card-numbers',
name: 'Board Card Numbers',
description: 'Assigning board-wise card numbers',
weight: 3,
completed: false,
progress: 0
},
{
id: 'add-card-details-show-lists',
name: 'Card Details Show Lists',
description: 'Adding card details show lists option',
weight: 1,
completed: false,
progress: 0
},
{
id: 'migrate-attachments-collectionFS-to-ostrioFiles',
name: 'Migrate Attachments to Meteor-Files',
description: 'Migrating attachments from CollectionFS to Meteor-Files',
weight: 8,
completed: false,
progress: 0
},
{
id: 'migrate-avatars-collectionFS-to-ostrioFiles',
name: 'Migrate Avatars to Meteor-Files',
description: 'Migrating avatars from CollectionFS to Meteor-Files',
weight: 6,
completed: false,
progress: 0
},
{
id: 'migrate-attachment-drop-index-cardId',
name: 'Drop Attachment Index',
description: 'Dropping old attachment index',
weight: 1,
completed: false,
progress: 0
},
{
id: 'migrate-attachment-migration-fix-source-import',
name: 'Fix Attachment Source Import',
description: 'Fixing attachment source import field',
weight: 2,
completed: false,
progress: 0
},
{
id: 'attachment-cardCopy-fix-boardId-etc',
name: 'Fix Attachment Card Copy',
description: 'Fixing attachment card copy board IDs',
weight: 2,
completed: false,
progress: 0
},
{
id: 'remove-unused-planning-poker',
name: 'Remove Planning Poker',
description: 'Removing unused planning poker fields',
weight: 1,
completed: false,
progress: 0
},
{
id: 'remove-user-profile-hiddenSystemMessages',
name: 'Remove Hidden System Messages',
description: 'Removing hidden system messages field',
weight: 1,
completed: false,
progress: 0
},
{
id: 'remove-user-profile-hideCheckedItems',
name: 'Remove Hide Checked Items',
description: 'Removing hide checked items field',
weight: 1,
completed: false,
progress: 0
},
{
id: 'migrate-lists-to-per-swimlane',
name: 'Migrate Lists to Per-Swimlane',
description: 'Migrating lists to per-swimlane structure',
weight: 5,
completed: false,
progress: 0
}
];
}
/**
* Check if any migrations need to be run for a specific board
*/
needsMigration(boardId = null) {
if (boardId) {
// Check if specific board needs migration based on version
const board = ReactiveCache.getBoard(boardId);
return !board || !board.migrationVersion || board.migrationVersion < 1;
}
// Check if any migration step is not completed (global migrations)
return this.steps.some(step => !step.completed);
}
/**
* Get total weight of all migrations
*/
getTotalWeight() {
return this.steps.reduce((total, step) => total + step.weight, 0);
}
/**
* Get completed weight
*/
getCompletedWeight() {
return this.steps.reduce((total, step) => {
return total + (step.completed ? step.weight : step.progress * step.weight / 100);
}, 0);
}
/**
* Mark a board as migrated
*/
markBoardAsMigrated(boardId) {
try {
Meteor.call('boardMigration.markAsMigrated', boardId, 'full_board_migration', (error, result) => {
if (error) {
console.error('Failed to mark board as migrated:', error);
} else {
console.log('Board marked as migrated:', boardId);
}
});
} catch (error) {
console.error('Error marking board as migrated:', error);
}
}
/**
* Fix boards that are stuck in migration loop
*/
fixStuckBoards() {
try {
Meteor.call('boardMigration.fixStuckBoards', (error, result) => {
if (error) {
console.error('Failed to fix stuck boards:', error);
} else {
console.log('Fix stuck boards result:', result);
}
});
} catch (error) {
console.error('Error fixing stuck boards:', error);
}
}
/**
* Start migration process using cron system
*/
async startMigration() {
if (isMigrating.get()) {
return; // Already migrating
}
isMigrating.set(true);
migrationSteps.set([...this.steps]);
this.startTime = Date.now();
try {
// Start server-side cron migrations
Meteor.call('cron.startAllMigrations', (error, result) => {
if (error) {
console.error('Failed to start cron migrations:', error);
migrationStatus.set(`Migration failed: ${error.message}`);
isMigrating.set(false);
}
});
// Poll for progress updates
this.pollCronMigrationProgress();
} catch (error) {
console.error('Migration failed:', error);
migrationStatus.set(`Migration failed: ${error.message}`);
isMigrating.set(false);
}
}
/**
* Poll for cron migration progress updates
*/
pollCronMigrationProgress() {
const pollInterval = setInterval(() => {
Meteor.call('cron.getMigrationProgress', (error, result) => {
if (error) {
console.error('Failed to get cron migration progress:', error);
clearInterval(pollInterval);
return;
}
if (result) {
migrationProgress.set(result.progress);
migrationStatus.set(result.status);
migrationCurrentStep.set(result.currentStep);
migrationSteps.set(result.steps);
isMigrating.set(result.isMigrating);
// Update local steps
if (result.steps) {
this.steps = result.steps;
}
// If migration is complete, stop polling
if (!result.isMigrating && result.progress === 100) {
clearInterval(pollInterval);
// Clear status after delay
setTimeout(() => {
migrationStatus.set('');
migrationProgress.set(0);
migrationEstimatedTime.set('');
}, 3000);
}
}
});
}, 1000); // Poll every second
}
/**
* Run a single migration step
*/
async runMigrationStep(step) {
// Simulate migration progress
const steps = 10;
for (let i = 0; i <= steps; i++) {
step.progress = (i / steps) * 100;
this.updateProgress();
// Simulate work
await new Promise(resolve => setTimeout(resolve, 50));
}
// In a real implementation, this would call the actual migration
// For now, we'll simulate the migration
// Running migration step
}
/**
* Update progress variables
*/
updateProgress() {
const totalWeight = this.getTotalWeight();
const completedWeight = this.getCompletedWeight();
const progress = Math.round((completedWeight / totalWeight) * 100);
migrationProgress.set(progress);
migrationSteps.set([...this.steps]);
// Calculate estimated time remaining
if (this.startTime && progress > 0) {
const elapsed = Date.now() - this.startTime;
const rate = progress / elapsed; // progress per millisecond
const remaining = 100 - progress;
const estimatedMs = remaining / rate;
migrationEstimatedTime.set(this.formatTime(estimatedMs));
}
}
/**
* Format time in milliseconds to human readable format
*/
formatTime(ms) {
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
const remainingMinutes = minutes % 60;
const remainingSeconds = seconds % 60;
return `${hours}h ${remainingMinutes}m ${remainingSeconds}s`;
} else if (minutes > 0) {
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
} else {
return `${seconds}s`;
}
}
/**
* Clear migration cache (for testing)
*/
clearCache() {
this.migrationCache.clear();
this.steps.forEach(step => {
step.completed = false;
step.progress = 0;
});
}
}
// Export singleton instance
export const migrationManager = new MigrationManager();

View file

@ -0,0 +1,50 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
export const cronMigrationProgress = new ReactiveVar(0);
export const cronMigrationStatus = new ReactiveVar('');
export const cronMigrationCurrentStep = new ReactiveVar('');
export const cronMigrationSteps = new ReactiveVar([]);
export const cronIsMigrating = new ReactiveVar(false);
export const cronJobs = new ReactiveVar([]);
function fetchProgress() {
Meteor.call('cron.getMigrationProgress', (err, res) => {
if (err) return;
if (!res) return;
cronMigrationProgress.set(res.progress || 0);
cronMigrationStatus.set(res.status || '');
cronMigrationCurrentStep.set(res.currentStep || '');
cronMigrationSteps.set(res.steps || []);
cronIsMigrating.set(res.isMigrating || false);
});
}
// Expose cron jobs via method
function fetchJobs() {
Meteor.call('cron.getJobs', (err, res) => {
if (err) return;
cronJobs.set(res || []);
});
}
if (Meteor.isClient) {
// Initial fetch
fetchProgress();
fetchJobs();
// Poll periodically
Meteor.setInterval(() => {
fetchProgress();
fetchJobs();
}, 2000);
}
export default {
cronMigrationProgress,
cronMigrationStatus,
cronMigrationCurrentStep,
cronMigrationSteps,
cronIsMigrating,
cronJobs,
};

View file

@ -1570,6 +1570,7 @@
"operation-type": "Operation Type",
"overall-progress": "Overall Progress",
"page": "Page",
"pause": "Pause",
"pause-migration": "Pause Migration",
"previous": "Previous",
"refresh": "Refresh",
@ -1599,5 +1600,6 @@
"weight": "Weight",
"idle": "Idle",
"complete": "Complete",
"cron": "Cron"
"cron": "Cron",
"current-step": "Current Step"
}

View file

@ -1357,66 +1357,119 @@ Meteor.startup(() => {
// Meteor methods for client-server communication
Meteor.methods({
'cron.startAllMigrations'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.startAllMigrations();
},
'cron.startJob'(cronName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.startCronJob(cronName);
},
'cron.stopJob'(cronName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.stopCronJob(cronName);
},
'cron.pauseJob'(cronName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.pauseCronJob(cronName);
},
'cron.resumeJob'(cronName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.resumeCronJob(cronName);
},
'cron.removeJob'(cronName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.removeCronJob(cronName);
},
'cron.addJob'(jobData) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.addCronJob(jobData);
},
'cron.getJobs'() {
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.getAllCronJobs();
},
'cron.getMigrationProgress'() {
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return {
progress: cronMigrationProgress.get(),
status: cronMigrationStatus.get(),
@ -1427,72 +1480,153 @@ Meteor.methods({
},
'cron.startBoardOperation'(boardId, operationType, operationData) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
// Check if user is global admin OR board admin
const user = ReactiveCache.getUser(userId);
const board = ReactiveCache.getBoard(boardId);
if (!user) {
throw new Meteor.Error('not-authorized', 'User not found');
}
if (!board) {
throw new Meteor.Error('not-found', 'Board not found');
}
// Check global admin or board admin
const isGlobalAdmin = user.isAdmin;
const isBoardAdmin = board.members && board.members.some(member =>
member.userId === userId && member.isAdmin
);
if (!isGlobalAdmin && !isBoardAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required for this board');
}
return cronMigrationManager.startBoardOperation(boardId, operationType, operationData);
},
'cron.getBoardOperations'(boardId) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
// Check if user is global admin OR board admin
const user = ReactiveCache.getUser(userId);
const board = ReactiveCache.getBoard(boardId);
if (!user) {
throw new Meteor.Error('not-authorized', 'User not found');
}
if (!board) {
throw new Meteor.Error('not-found', 'Board not found');
}
// Check global admin or board admin
const isGlobalAdmin = user.isAdmin;
const isBoardAdmin = board.members && board.members.some(member =>
member.userId === userId && member.isAdmin
);
if (!isGlobalAdmin && !isBoardAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required for this board');
}
return cronMigrationManager.getBoardOperations(boardId);
},
'cron.getAllBoardOperations'(page, limit, searchTerm) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.getAllBoardOperations(page, limit, searchTerm);
},
'cron.getBoardOperationStats'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.getBoardOperationStats();
},
'cron.getJobDetails'(jobId) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronJobStorage.getJobDetails(jobId);
},
'cron.getQueueStats'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronJobStorage.getQueueStats();
},
'cron.getSystemResources'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronJobStorage.getSystemResources();
},
'cron.clearAllJobs'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.clearAllCronJobs();
},
'cron.pauseJob'(jobId) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
cronJobStorage.updateQueueStatus(jobId, 'paused');
@ -1501,8 +1635,13 @@ Meteor.methods({
},
'cron.resumeJob'(jobId) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
cronJobStorage.updateQueueStatus(jobId, 'pending');
@ -1511,8 +1650,13 @@ Meteor.methods({
},
'cron.stopJob'(jobId) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
cronJobStorage.updateQueueStatus(jobId, 'stopped');
@ -1524,16 +1668,76 @@ Meteor.methods({
},
'cron.cleanupOldJobs'(daysOld) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronJobStorage.cleanupOldJobs(daysOld);
},
'cron.pauseAllMigrations'() {
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
// Pause all running jobs in the queue
const runningJobs = cronJobStorage.getIncompleteJobs().filter(job => job.status === 'running');
runningJobs.forEach(job => {
cronJobStorage.updateQueueStatus(job.jobId, 'paused');
cronJobStorage.saveJobStatus(job.jobId, { status: 'paused' });
});
cronMigrationStatus.set('All migrations paused');
return { success: true, message: 'All migrations paused' };
},
'cron.stopAllMigrations'() {
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
// Stop all running and pending jobs
const incompleteJobs = cronJobStorage.getIncompleteJobs();
incompleteJobs.forEach(job => {
cronJobStorage.updateQueueStatus(job.jobId, 'stopped', { stoppedAt: new Date() });
cronJobStorage.saveJobStatus(job.jobId, {
status: 'stopped',
stoppedAt: new Date()
});
});
// Reset migration state
cronIsMigrating.set(false);
cronMigrationStatus.set('All migrations stopped');
cronMigrationProgress.set(0);
cronMigrationCurrentStep.set('');
return { success: true, message: 'All migrations stopped' };
},
'cron.getBoardMigrationStats'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
// Import the board migration detector
@ -1542,8 +1746,13 @@ Meteor.methods({
},
'cron.forceBoardMigrationScan'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
// Import the board migration detector