comprehensiveBoardMigration.js 24 KB

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