comprehensiveBoardMigration.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767
  1. /**
  2. * Comprehensive Board Migration System
  3. *
  4. * This migration handles all database structure changes from previous Wekan versions
  5. * to the current per-swimlane lists structure. It ensures:
  6. *
  7. * 1. All cards are visible with proper swimlaneId and listId
  8. * 2. Lists are per-swimlane (no shared lists across swimlanes)
  9. * 3. No empty lists are created
  10. * 4. Handles various database structure versions from git history
  11. *
  12. * Supported versions and their database structures:
  13. * - v7.94 and earlier: Shared lists across all swimlanes
  14. * - v8.00-v8.02: Transition period with mixed structures
  15. * - v8.03+: Per-swimlane lists structure
  16. */
  17. import { Meteor } from 'meteor/meteor';
  18. import { check } from 'meteor/check';
  19. import { ReactiveCache } from '/imports/reactiveCache';
  20. import Boards from '/models/boards';
  21. import Lists from '/models/lists';
  22. import Cards from '/models/cards';
  23. import Swimlanes from '/models/swimlanes';
  24. import Attachments from '/models/attachments';
  25. import { generateUniversalAttachmentUrl, isUniversalFileUrl } from '/models/lib/universalUrlGenerator';
  26. class ComprehensiveBoardMigration {
  27. constructor() {
  28. this.name = 'comprehensive-board-migration';
  29. this.version = 1;
  30. this.migrationSteps = [
  31. 'analyze_board_structure',
  32. 'fix_orphaned_cards',
  33. 'convert_shared_lists',
  34. 'ensure_per_swimlane_lists',
  35. 'cleanup_empty_lists',
  36. 'validate_migration'
  37. ];
  38. }
  39. /**
  40. * Check if migration is needed for a board
  41. */
  42. needsMigration(boardId) {
  43. try {
  44. const board = ReactiveCache.getBoard(boardId);
  45. if (!board) return false;
  46. // Check if board has already been processed
  47. if (board.comprehensiveMigrationCompleted) {
  48. return false;
  49. }
  50. // Check for various issues that need migration
  51. const issues = this.detectMigrationIssues(boardId);
  52. return issues.length > 0;
  53. } catch (error) {
  54. console.error('Error checking if migration is needed:', error);
  55. return false;
  56. }
  57. }
  58. /**
  59. * Detect all migration issues in a board
  60. */
  61. detectMigrationIssues(boardId) {
  62. const issues = [];
  63. try {
  64. const cards = ReactiveCache.getCards({ boardId });
  65. const lists = ReactiveCache.getLists({ boardId });
  66. const swimlanes = ReactiveCache.getSwimlanes({ boardId });
  67. // Issue 1: Cards with missing swimlaneId
  68. const cardsWithoutSwimlane = cards.filter(card => !card.swimlaneId);
  69. if (cardsWithoutSwimlane.length > 0) {
  70. issues.push({
  71. type: 'cards_without_swimlane',
  72. count: cardsWithoutSwimlane.length,
  73. description: `${cardsWithoutSwimlane.length} cards missing swimlaneId`
  74. });
  75. }
  76. // Issue 2: Cards with missing listId
  77. const cardsWithoutList = cards.filter(card => !card.listId);
  78. if (cardsWithoutList.length > 0) {
  79. issues.push({
  80. type: 'cards_without_list',
  81. count: cardsWithoutList.length,
  82. description: `${cardsWithoutList.length} cards missing listId`
  83. });
  84. }
  85. // Issue 3: Lists without swimlaneId (shared lists)
  86. const sharedLists = lists.filter(list => !list.swimlaneId || list.swimlaneId === '');
  87. if (sharedLists.length > 0) {
  88. issues.push({
  89. type: 'shared_lists',
  90. count: sharedLists.length,
  91. description: `${sharedLists.length} lists without swimlaneId (shared lists)`
  92. });
  93. }
  94. // Issue 4: Cards with mismatched listId/swimlaneId
  95. const listSwimlaneMap = new Map();
  96. lists.forEach(list => {
  97. listSwimlaneMap.set(list._id, list.swimlaneId || '');
  98. });
  99. const mismatchedCards = cards.filter(card => {
  100. if (!card.listId || !card.swimlaneId) return false;
  101. const listSwimlaneId = listSwimlaneMap.get(card.listId);
  102. return listSwimlaneId && listSwimlaneId !== card.swimlaneId;
  103. });
  104. if (mismatchedCards.length > 0) {
  105. issues.push({
  106. type: 'mismatched_cards',
  107. count: mismatchedCards.length,
  108. description: `${mismatchedCards.length} cards with mismatched listId/swimlaneId`
  109. });
  110. }
  111. // Issue 5: Empty lists (lists with no cards)
  112. const emptyLists = lists.filter(list => {
  113. const listCards = cards.filter(card => card.listId === list._id);
  114. return listCards.length === 0;
  115. });
  116. if (emptyLists.length > 0) {
  117. issues.push({
  118. type: 'empty_lists',
  119. count: emptyLists.length,
  120. description: `${emptyLists.length} empty lists (no cards)`
  121. });
  122. }
  123. } catch (error) {
  124. console.error('Error detecting migration issues:', error);
  125. issues.push({
  126. type: 'detection_error',
  127. count: 1,
  128. description: `Error detecting issues: ${error.message}`
  129. });
  130. }
  131. return issues;
  132. }
  133. /**
  134. * Execute the comprehensive migration for a board
  135. */
  136. async executeMigration(boardId, progressCallback = null) {
  137. try {
  138. if (process.env.DEBUG === 'true') {
  139. console.log(`Starting comprehensive board migration for board ${boardId}`);
  140. }
  141. const board = ReactiveCache.getBoard(boardId);
  142. if (!board) {
  143. throw new Error(`Board ${boardId} not found`);
  144. }
  145. const results = {
  146. boardId,
  147. steps: {},
  148. totalCardsProcessed: 0,
  149. totalListsProcessed: 0,
  150. totalListsCreated: 0,
  151. totalListsRemoved: 0,
  152. errors: []
  153. };
  154. const totalSteps = this.migrationSteps.length;
  155. let currentStep = 0;
  156. // Helper function to update progress
  157. const updateProgress = (stepName, stepProgress, stepStatus, stepDetails = null) => {
  158. currentStep++;
  159. const overallProgress = Math.round((currentStep / totalSteps) * 100);
  160. const progressData = {
  161. overallProgress,
  162. currentStep: currentStep,
  163. totalSteps,
  164. stepName,
  165. stepProgress,
  166. stepStatus,
  167. stepDetails,
  168. boardId
  169. };
  170. if (progressCallback) {
  171. progressCallback(progressData);
  172. }
  173. if (process.env.DEBUG === 'true') {
  174. console.log(`Migration Progress: ${stepName} - ${stepStatus} (${stepProgress}%)`);
  175. }
  176. };
  177. // Step 1: Analyze board structure
  178. updateProgress('analyze_board_structure', 0, 'Starting analysis...');
  179. results.steps.analyze = await this.analyzeBoardStructure(boardId);
  180. updateProgress('analyze_board_structure', 100, 'Analysis complete', {
  181. issuesFound: results.steps.analyze.issueCount,
  182. needsMigration: results.steps.analyze.needsMigration
  183. });
  184. // Step 2: Fix orphaned cards
  185. updateProgress('fix_orphaned_cards', 0, 'Fixing orphaned cards...');
  186. results.steps.fixOrphanedCards = await this.fixOrphanedCards(boardId, (progress, status) => {
  187. updateProgress('fix_orphaned_cards', progress, status);
  188. });
  189. results.totalCardsProcessed += results.steps.fixOrphanedCards.cardsFixed || 0;
  190. updateProgress('fix_orphaned_cards', 100, 'Orphaned cards fixed', {
  191. cardsFixed: results.steps.fixOrphanedCards.cardsFixed
  192. });
  193. // Step 3: Convert shared lists to per-swimlane lists
  194. updateProgress('convert_shared_lists', 0, 'Converting shared lists...');
  195. results.steps.convertSharedLists = await this.convertSharedListsToPerSwimlane(boardId, (progress, status) => {
  196. updateProgress('convert_shared_lists', progress, status);
  197. });
  198. results.totalListsProcessed += results.steps.convertSharedLists.listsProcessed || 0;
  199. results.totalListsCreated += results.steps.convertSharedLists.listsCreated || 0;
  200. updateProgress('convert_shared_lists', 100, 'Shared lists converted', {
  201. listsProcessed: results.steps.convertSharedLists.listsProcessed,
  202. listsCreated: results.steps.convertSharedLists.listsCreated
  203. });
  204. // Step 4: Ensure all lists are per-swimlane
  205. updateProgress('ensure_per_swimlane_lists', 0, 'Ensuring per-swimlane structure...');
  206. results.steps.ensurePerSwimlane = await this.ensurePerSwimlaneLists(boardId);
  207. results.totalListsProcessed += results.steps.ensurePerSwimlane.listsProcessed || 0;
  208. updateProgress('ensure_per_swimlane_lists', 100, 'Per-swimlane structure ensured', {
  209. listsProcessed: results.steps.ensurePerSwimlane.listsProcessed
  210. });
  211. // Step 5: Cleanup empty lists
  212. updateProgress('cleanup_empty_lists', 0, 'Cleaning up empty lists...');
  213. results.steps.cleanupEmpty = await this.cleanupEmptyLists(boardId);
  214. results.totalListsRemoved += results.steps.cleanupEmpty.listsRemoved || 0;
  215. updateProgress('cleanup_empty_lists', 100, 'Empty lists cleaned up', {
  216. listsRemoved: results.steps.cleanupEmpty.listsRemoved
  217. });
  218. // Step 6: Validate migration
  219. updateProgress('validate_migration', 0, 'Validating migration...');
  220. results.steps.validate = await this.validateMigration(boardId);
  221. updateProgress('validate_migration', 100, 'Migration validated', {
  222. migrationSuccessful: results.steps.validate.migrationSuccessful,
  223. totalCards: results.steps.validate.totalCards,
  224. totalLists: results.steps.validate.totalLists
  225. });
  226. // Step 7: Fix avatar URLs
  227. updateProgress('fix_avatar_urls', 0, 'Fixing avatar URLs...');
  228. results.steps.fixAvatarUrls = await this.fixAvatarUrls(boardId);
  229. updateProgress('fix_avatar_urls', 100, 'Avatar URLs fixed', {
  230. avatarsFixed: results.steps.fixAvatarUrls.avatarsFixed
  231. });
  232. // Step 8: Fix attachment URLs
  233. updateProgress('fix_attachment_urls', 0, 'Fixing attachment URLs...');
  234. results.steps.fixAttachmentUrls = await this.fixAttachmentUrls(boardId);
  235. updateProgress('fix_attachment_urls', 100, 'Attachment URLs fixed', {
  236. attachmentsFixed: results.steps.fixAttachmentUrls.attachmentsFixed
  237. });
  238. // Mark board as processed
  239. Boards.update(boardId, {
  240. $set: {
  241. comprehensiveMigrationCompleted: true,
  242. comprehensiveMigrationCompletedAt: new Date(),
  243. comprehensiveMigrationResults: results
  244. }
  245. });
  246. if (process.env.DEBUG === 'true') {
  247. console.log(`Comprehensive board migration completed for board ${boardId}:`, results);
  248. }
  249. return {
  250. success: true,
  251. results
  252. };
  253. } catch (error) {
  254. console.error(`Error executing comprehensive migration for board ${boardId}:`, error);
  255. throw error;
  256. }
  257. }
  258. /**
  259. * Step 1: Analyze board structure
  260. */
  261. async analyzeBoardStructure(boardId) {
  262. const issues = this.detectMigrationIssues(boardId);
  263. return {
  264. issues,
  265. issueCount: issues.length,
  266. needsMigration: issues.length > 0
  267. };
  268. }
  269. /**
  270. * Step 2: Fix orphaned cards (cards with missing swimlaneId or listId)
  271. */
  272. async fixOrphanedCards(boardId, progressCallback = null) {
  273. const cards = ReactiveCache.getCards({ boardId });
  274. const swimlanes = ReactiveCache.getSwimlanes({ boardId });
  275. const lists = ReactiveCache.getLists({ boardId });
  276. let cardsFixed = 0;
  277. const defaultSwimlane = swimlanes.find(s => s.title === 'Default') || swimlanes[0];
  278. const totalCards = cards.length;
  279. for (let i = 0; i < cards.length; i++) {
  280. const card = cards[i];
  281. let needsUpdate = false;
  282. const updates = {};
  283. // Fix missing swimlaneId
  284. if (!card.swimlaneId) {
  285. updates.swimlaneId = defaultSwimlane._id;
  286. needsUpdate = true;
  287. }
  288. // Fix missing listId
  289. if (!card.listId) {
  290. // Find or create a default list for this swimlane
  291. const swimlaneId = updates.swimlaneId || card.swimlaneId;
  292. let defaultList = lists.find(list =>
  293. list.swimlaneId === swimlaneId && list.title === 'Default'
  294. );
  295. if (!defaultList) {
  296. // Create a default list for this swimlane
  297. const newListId = Lists.insert({
  298. title: 'Default',
  299. boardId: boardId,
  300. swimlaneId: swimlaneId,
  301. sort: 0,
  302. archived: false,
  303. createdAt: new Date(),
  304. modifiedAt: new Date(),
  305. type: 'list'
  306. });
  307. defaultList = { _id: newListId };
  308. }
  309. updates.listId = defaultList._id;
  310. needsUpdate = true;
  311. }
  312. if (needsUpdate) {
  313. Cards.update(card._id, {
  314. $set: {
  315. ...updates,
  316. modifiedAt: new Date()
  317. }
  318. });
  319. cardsFixed++;
  320. }
  321. // Update progress
  322. if (progressCallback && (i % 10 === 0 || i === totalCards - 1)) {
  323. const progress = Math.round(((i + 1) / totalCards) * 100);
  324. progressCallback(progress, `Processing card ${i + 1} of ${totalCards}...`);
  325. }
  326. }
  327. return { cardsFixed };
  328. }
  329. /**
  330. * Step 3: Convert shared lists to per-swimlane lists
  331. */
  332. async convertSharedListsToPerSwimlane(boardId, progressCallback = null) {
  333. const cards = ReactiveCache.getCards({ boardId });
  334. const lists = ReactiveCache.getLists({ boardId });
  335. const swimlanes = ReactiveCache.getSwimlanes({ boardId });
  336. let listsProcessed = 0;
  337. let listsCreated = 0;
  338. // Group cards by swimlaneId
  339. const cardsBySwimlane = new Map();
  340. cards.forEach(card => {
  341. if (!cardsBySwimlane.has(card.swimlaneId)) {
  342. cardsBySwimlane.set(card.swimlaneId, []);
  343. }
  344. cardsBySwimlane.get(card.swimlaneId).push(card);
  345. });
  346. const swimlaneEntries = Array.from(cardsBySwimlane.entries());
  347. const totalSwimlanes = swimlaneEntries.length;
  348. // Process each swimlane
  349. for (let i = 0; i < swimlaneEntries.length; i++) {
  350. const [swimlaneId, swimlaneCards] = swimlaneEntries[i];
  351. if (!swimlaneId) continue;
  352. if (progressCallback) {
  353. const progress = Math.round(((i + 1) / totalSwimlanes) * 100);
  354. progressCallback(progress, `Processing swimlane ${i + 1} of ${totalSwimlanes}...`);
  355. }
  356. // Get existing lists for this swimlane
  357. const existingLists = lists.filter(list => list.swimlaneId === swimlaneId);
  358. const existingListTitles = new Set(existingLists.map(list => list.title));
  359. // Group cards by their current listId
  360. const cardsByListId = new Map();
  361. swimlaneCards.forEach(card => {
  362. if (!cardsByListId.has(card.listId)) {
  363. cardsByListId.set(card.listId, []);
  364. }
  365. cardsByListId.get(card.listId).push(card);
  366. });
  367. // For each listId used by cards in this swimlane
  368. for (const [listId, cardsInList] of cardsByListId) {
  369. const originalList = lists.find(l => l._id === listId);
  370. if (!originalList) continue;
  371. // Check if this list's swimlaneId matches the card's swimlaneId
  372. if (originalList.swimlaneId === swimlaneId) {
  373. // List is already correctly assigned to this swimlane
  374. listsProcessed++;
  375. continue;
  376. }
  377. // Check if we already have a list with the same title in this swimlane
  378. let targetList = existingLists.find(list => list.title === originalList.title);
  379. if (!targetList) {
  380. // Create a new list for this swimlane
  381. const newListData = {
  382. title: originalList.title,
  383. boardId: boardId,
  384. swimlaneId: swimlaneId,
  385. sort: originalList.sort || 0,
  386. archived: originalList.archived || false,
  387. createdAt: new Date(),
  388. modifiedAt: new Date(),
  389. type: originalList.type || 'list'
  390. };
  391. // Copy other properties if they exist
  392. if (originalList.color) newListData.color = originalList.color;
  393. if (originalList.wipLimit) newListData.wipLimit = originalList.wipLimit;
  394. if (originalList.wipLimitEnabled) newListData.wipLimitEnabled = originalList.wipLimitEnabled;
  395. if (originalList.wipLimitSoft) newListData.wipLimitSoft = originalList.wipLimitSoft;
  396. if (originalList.starred) newListData.starred = originalList.starred;
  397. if (originalList.collapsed) newListData.collapsed = originalList.collapsed;
  398. // Insert the new list
  399. const newListId = Lists.insert(newListData);
  400. targetList = { _id: newListId, ...newListData };
  401. listsCreated++;
  402. }
  403. // Update all cards in this group to use the correct listId
  404. for (const card of cardsInList) {
  405. Cards.update(card._id, {
  406. $set: {
  407. listId: targetList._id,
  408. modifiedAt: new Date()
  409. }
  410. });
  411. }
  412. listsProcessed++;
  413. }
  414. }
  415. return { listsProcessed, listsCreated };
  416. }
  417. /**
  418. * Step 4: Ensure all lists are per-swimlane
  419. */
  420. async ensurePerSwimlaneLists(boardId) {
  421. const lists = ReactiveCache.getLists({ boardId });
  422. const swimlanes = ReactiveCache.getSwimlanes({ boardId });
  423. const defaultSwimlane = swimlanes.find(s => s.title === 'Default') || swimlanes[0];
  424. let listsProcessed = 0;
  425. for (const list of lists) {
  426. if (!list.swimlaneId || list.swimlaneId === '') {
  427. // Assign to default swimlane
  428. Lists.update(list._id, {
  429. $set: {
  430. swimlaneId: defaultSwimlane._id,
  431. modifiedAt: new Date()
  432. }
  433. });
  434. listsProcessed++;
  435. }
  436. }
  437. return { listsProcessed };
  438. }
  439. /**
  440. * Step 5: Cleanup empty lists (lists with no cards)
  441. */
  442. async cleanupEmptyLists(boardId) {
  443. const lists = ReactiveCache.getLists({ boardId });
  444. const cards = ReactiveCache.getCards({ boardId });
  445. let listsRemoved = 0;
  446. for (const list of lists) {
  447. const listCards = cards.filter(card => card.listId === list._id);
  448. if (listCards.length === 0) {
  449. // Remove empty list
  450. Lists.remove(list._id);
  451. listsRemoved++;
  452. if (process.env.DEBUG === 'true') {
  453. console.log(`Removed empty list: ${list.title} (${list._id})`);
  454. }
  455. }
  456. }
  457. return { listsRemoved };
  458. }
  459. /**
  460. * Step 6: Validate migration
  461. */
  462. async validateMigration(boardId) {
  463. const issues = this.detectMigrationIssues(boardId);
  464. const cards = ReactiveCache.getCards({ boardId });
  465. const lists = ReactiveCache.getLists({ boardId });
  466. // Check that all cards have valid swimlaneId and listId
  467. const validCards = cards.filter(card => card.swimlaneId && card.listId);
  468. const invalidCards = cards.length - validCards.length;
  469. // Check that all lists have swimlaneId
  470. const validLists = lists.filter(list => list.swimlaneId && list.swimlaneId !== '');
  471. const invalidLists = lists.length - validLists.length;
  472. return {
  473. issuesRemaining: issues.length,
  474. totalCards: cards.length,
  475. validCards,
  476. invalidCards,
  477. totalLists: lists.length,
  478. validLists,
  479. invalidLists,
  480. migrationSuccessful: issues.length === 0 && invalidCards === 0 && invalidLists === 0
  481. };
  482. }
  483. /**
  484. * Step 7: Fix avatar URLs (remove problematic auth parameters and fix URL formats)
  485. */
  486. async fixAvatarUrls(boardId) {
  487. const users = ReactiveCache.getUsers({});
  488. let avatarsFixed = 0;
  489. for (const user of users) {
  490. if (user.profile && user.profile.avatarUrl) {
  491. const avatarUrl = user.profile.avatarUrl;
  492. let needsUpdate = false;
  493. let cleanUrl = avatarUrl;
  494. // Check if URL has problematic parameters
  495. if (avatarUrl.includes('auth=false') || avatarUrl.includes('brokenIsFine=true')) {
  496. // Remove problematic parameters
  497. cleanUrl = cleanUrl.replace(/[?&]auth=false/g, '');
  498. cleanUrl = cleanUrl.replace(/[?&]brokenIsFine=true/g, '');
  499. cleanUrl = cleanUrl.replace(/\?&/g, '?');
  500. cleanUrl = cleanUrl.replace(/\?$/g, '');
  501. needsUpdate = true;
  502. }
  503. // Check if URL is using old CollectionFS format
  504. if (avatarUrl.includes('/cfs/files/avatars/')) {
  505. cleanUrl = cleanUrl.replace('/cfs/files/avatars/', '/cdn/storage/avatars/');
  506. needsUpdate = true;
  507. }
  508. // Check if URL is missing the /cdn/storage/avatars/ prefix
  509. if (avatarUrl.includes('avatars/') && !avatarUrl.includes('/cdn/storage/avatars/') && !avatarUrl.includes('/cfs/files/avatars/')) {
  510. // This might be a relative URL, make it absolute
  511. if (!avatarUrl.startsWith('http') && !avatarUrl.startsWith('/')) {
  512. cleanUrl = `/cdn/storage/avatars/${avatarUrl}`;
  513. needsUpdate = true;
  514. }
  515. }
  516. if (needsUpdate) {
  517. // Update user's avatar URL
  518. Users.update(user._id, {
  519. $set: {
  520. 'profile.avatarUrl': cleanUrl,
  521. modifiedAt: new Date()
  522. }
  523. });
  524. avatarsFixed++;
  525. }
  526. }
  527. }
  528. return { avatarsFixed };
  529. }
  530. /**
  531. * Step 8: Fix attachment URLs (remove problematic auth parameters and fix URL formats)
  532. */
  533. async fixAttachmentUrls(boardId) {
  534. const attachments = ReactiveCache.getAttachments({});
  535. let attachmentsFixed = 0;
  536. for (const attachment of attachments) {
  537. // Check if attachment has URL field that needs fixing
  538. if (attachment.url) {
  539. const attachmentUrl = attachment.url;
  540. let needsUpdate = false;
  541. let cleanUrl = attachmentUrl;
  542. // Check if URL has problematic parameters
  543. if (attachmentUrl.includes('auth=false') || attachmentUrl.includes('brokenIsFine=true')) {
  544. // Remove problematic parameters
  545. cleanUrl = cleanUrl.replace(/[?&]auth=false/g, '');
  546. cleanUrl = cleanUrl.replace(/[?&]brokenIsFine=true/g, '');
  547. cleanUrl = cleanUrl.replace(/\?&/g, '?');
  548. cleanUrl = cleanUrl.replace(/\?$/g, '');
  549. needsUpdate = true;
  550. }
  551. // Check if URL is using old CollectionFS format
  552. if (attachmentUrl.includes('/cfs/files/attachments/')) {
  553. cleanUrl = cleanUrl.replace('/cfs/files/attachments/', '/cdn/storage/attachments/');
  554. needsUpdate = true;
  555. }
  556. // Check if URL has /original/ path that should be removed
  557. if (attachmentUrl.includes('/original/')) {
  558. cleanUrl = cleanUrl.replace(/\/original\/[^\/\?#]+/, '');
  559. needsUpdate = true;
  560. }
  561. // If we have a file ID, generate a universal URL
  562. const fileId = attachment._id;
  563. if (fileId && !isUniversalFileUrl(cleanUrl, 'attachment')) {
  564. cleanUrl = generateUniversalAttachmentUrl(fileId);
  565. needsUpdate = true;
  566. }
  567. if (needsUpdate) {
  568. // Update attachment URL
  569. Attachments.update(attachment._id, {
  570. $set: {
  571. url: cleanUrl,
  572. modifiedAt: new Date()
  573. }
  574. });
  575. attachmentsFixed++;
  576. }
  577. }
  578. }
  579. return { attachmentsFixed };
  580. }
  581. /**
  582. * Get migration status for a board
  583. */
  584. getMigrationStatus(boardId) {
  585. try {
  586. const board = ReactiveCache.getBoard(boardId);
  587. if (!board) {
  588. return { status: 'board_not_found' };
  589. }
  590. if (board.comprehensiveMigrationCompleted) {
  591. return {
  592. status: 'completed',
  593. completedAt: board.comprehensiveMigrationCompletedAt,
  594. results: board.comprehensiveMigrationResults
  595. };
  596. }
  597. const needsMigration = this.needsMigration(boardId);
  598. const issues = this.detectMigrationIssues(boardId);
  599. return {
  600. status: needsMigration ? 'needed' : 'not_needed',
  601. issues,
  602. issueCount: issues.length
  603. };
  604. } catch (error) {
  605. console.error('Error getting migration status:', error);
  606. return { status: 'error', error: error.message };
  607. }
  608. }
  609. }
  610. // Export singleton instance
  611. export const comprehensiveBoardMigration = new ComprehensiveBoardMigration();
  612. // Meteor methods
  613. Meteor.methods({
  614. 'comprehensiveBoardMigration.check'(boardId) {
  615. check(boardId, String);
  616. if (!this.userId) {
  617. throw new Meteor.Error('not-authorized');
  618. }
  619. return comprehensiveBoardMigration.getMigrationStatus(boardId);
  620. },
  621. 'comprehensiveBoardMigration.execute'(boardId) {
  622. check(boardId, String);
  623. if (!this.userId) {
  624. throw new Meteor.Error('not-authorized');
  625. }
  626. return comprehensiveBoardMigration.executeMigration(boardId);
  627. },
  628. 'comprehensiveBoardMigration.needsMigration'(boardId) {
  629. check(boardId, String);
  630. if (!this.userId) {
  631. throw new Meteor.Error('not-authorized');
  632. }
  633. return comprehensiveBoardMigration.needsMigration(boardId);
  634. },
  635. 'comprehensiveBoardMigration.detectIssues'(boardId) {
  636. check(boardId, String);
  637. if (!this.userId) {
  638. throw new Meteor.Error('not-authorized');
  639. }
  640. return comprehensiveBoardMigration.detectMigrationIssues(boardId);
  641. },
  642. 'comprehensiveBoardMigration.fixAvatarUrls'(boardId) {
  643. check(boardId, String);
  644. if (!this.userId) {
  645. throw new Meteor.Error('not-authorized');
  646. }
  647. return comprehensiveBoardMigration.fixAvatarUrls(boardId);
  648. }
  649. });