deleteDuplicateEmptyLists.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. /**
  2. * Delete Duplicate Empty Lists Migration
  3. *
  4. * Safely deletes empty duplicate lists from a board:
  5. * 1. First converts any shared lists to per-swimlane lists
  6. * 2. Only deletes per-swimlane lists that:
  7. * - Have no cards
  8. * - Have another list with the same title on the same board that DOES have cards
  9. * 3. This prevents deleting unique empty lists and only removes redundant duplicates
  10. */
  11. import { Meteor } from 'meteor/meteor';
  12. import { check } from 'meteor/check';
  13. import { ReactiveCache } from '/imports/reactiveCache';
  14. import Boards from '/models/boards';
  15. import Lists from '/models/lists';
  16. import Cards from '/models/cards';
  17. import Swimlanes from '/models/swimlanes';
  18. class DeleteDuplicateEmptyListsMigration {
  19. constructor() {
  20. this.name = 'deleteDuplicateEmptyLists';
  21. this.version = 1;
  22. }
  23. /**
  24. * Check if migration is needed for a board
  25. */
  26. needsMigration(boardId) {
  27. try {
  28. const lists = ReactiveCache.getLists({ boardId });
  29. const cards = ReactiveCache.getCards({ boardId });
  30. // Check if there are any empty lists that have a duplicate with the same title containing cards
  31. for (const list of lists) {
  32. // Skip shared lists
  33. if (!list.swimlaneId || list.swimlaneId === '') {
  34. continue;
  35. }
  36. // Check if list is empty
  37. const listCards = cards.filter(card => card.listId === list._id);
  38. if (listCards.length === 0) {
  39. // Check if there's a duplicate list with the same title that has cards
  40. const duplicateListsWithSameTitle = lists.filter(l =>
  41. l._id !== list._id &&
  42. l.title === list.title &&
  43. l.boardId === boardId
  44. );
  45. for (const duplicateList of duplicateListsWithSameTitle) {
  46. const duplicateListCards = cards.filter(card => card.listId === duplicateList._id);
  47. if (duplicateListCards.length > 0) {
  48. return true; // Found an empty list with a duplicate that has cards
  49. }
  50. }
  51. }
  52. }
  53. return false;
  54. } catch (error) {
  55. console.error('Error checking if deleteDuplicateEmptyLists migration is needed:', error);
  56. return false;
  57. }
  58. }
  59. /**
  60. * Execute the migration
  61. */
  62. async executeMigration(boardId) {
  63. try {
  64. const results = {
  65. sharedListsConverted: 0,
  66. listsDeleted: 0,
  67. errors: []
  68. };
  69. // Step 1: Convert shared lists to per-swimlane lists first
  70. const conversionResult = await this.convertSharedListsToPerSwimlane(boardId);
  71. results.sharedListsConverted = conversionResult.listsConverted;
  72. // Step 2: Delete empty per-swimlane lists
  73. const deletionResult = await this.deleteEmptyPerSwimlaneLists(boardId);
  74. results.listsDeleted = deletionResult.listsDeleted;
  75. return {
  76. success: true,
  77. changes: [
  78. `Converted ${results.sharedListsConverted} shared lists to per-swimlane lists`,
  79. `Deleted ${results.listsDeleted} empty per-swimlane lists`
  80. ],
  81. results
  82. };
  83. } catch (error) {
  84. console.error('Error executing deleteDuplicateEmptyLists migration:', error);
  85. return {
  86. success: false,
  87. error: error.message
  88. };
  89. }
  90. }
  91. /**
  92. * Convert shared lists (lists without swimlaneId) to per-swimlane lists
  93. */
  94. async convertSharedListsToPerSwimlane(boardId) {
  95. const lists = ReactiveCache.getLists({ boardId });
  96. const swimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
  97. const cards = ReactiveCache.getCards({ boardId });
  98. let listsConverted = 0;
  99. // Find shared lists (lists without swimlaneId)
  100. const sharedLists = lists.filter(list => !list.swimlaneId || list.swimlaneId === '');
  101. if (sharedLists.length === 0) {
  102. return { listsConverted: 0 };
  103. }
  104. for (const sharedList of sharedLists) {
  105. // Get cards in this shared list
  106. const listCards = cards.filter(card => card.listId === sharedList._id);
  107. // Group cards by swimlane
  108. const cardsBySwimlane = {};
  109. for (const card of listCards) {
  110. const swimlaneId = card.swimlaneId || 'default';
  111. if (!cardsBySwimlane[swimlaneId]) {
  112. cardsBySwimlane[swimlaneId] = [];
  113. }
  114. cardsBySwimlane[swimlaneId].push(card);
  115. }
  116. // Create per-swimlane lists for each swimlane that has cards
  117. for (const swimlane of swimlanes) {
  118. const swimlaneCards = cardsBySwimlane[swimlane._id] || [];
  119. if (swimlaneCards.length > 0) {
  120. // Check if per-swimlane list already exists
  121. const existingList = lists.find(l =>
  122. l.title === sharedList.title &&
  123. l.swimlaneId === swimlane._id &&
  124. l._id !== sharedList._id
  125. );
  126. if (!existingList) {
  127. // Create new per-swimlane list
  128. const newListId = Lists.insert({
  129. title: sharedList.title,
  130. boardId: boardId,
  131. swimlaneId: swimlane._id,
  132. sort: sharedList.sort,
  133. createdAt: new Date(),
  134. updatedAt: new Date(),
  135. archived: false
  136. });
  137. // Move cards to the new list
  138. for (const card of swimlaneCards) {
  139. Cards.update(card._id, {
  140. $set: {
  141. listId: newListId,
  142. swimlaneId: swimlane._id
  143. }
  144. });
  145. }
  146. if (process.env.DEBUG === 'true') {
  147. console.log(`Created per-swimlane list "${sharedList.title}" for swimlane ${swimlane.title || swimlane._id}`);
  148. }
  149. } else {
  150. // Move cards to existing per-swimlane list
  151. for (const card of swimlaneCards) {
  152. Cards.update(card._id, {
  153. $set: {
  154. listId: existingList._id,
  155. swimlaneId: swimlane._id
  156. }
  157. });
  158. }
  159. if (process.env.DEBUG === 'true') {
  160. console.log(`Moved cards to existing per-swimlane list "${sharedList.title}" in swimlane ${swimlane.title || swimlane._id}`);
  161. }
  162. }
  163. }
  164. }
  165. // Remove the shared list (now that all cards are moved)
  166. Lists.remove(sharedList._id);
  167. listsConverted++;
  168. if (process.env.DEBUG === 'true') {
  169. console.log(`Removed shared list "${sharedList.title}"`);
  170. }
  171. }
  172. return { listsConverted };
  173. }
  174. /**
  175. * Delete empty per-swimlane lists
  176. * Only deletes lists that:
  177. * 1. Have a swimlaneId (are per-swimlane, not shared)
  178. * 2. Have no cards
  179. * 3. Have a duplicate list with the same title on the same board that contains cards
  180. */
  181. async deleteEmptyPerSwimlaneLists(boardId) {
  182. const lists = ReactiveCache.getLists({ boardId });
  183. const cards = ReactiveCache.getCards({ boardId });
  184. let listsDeleted = 0;
  185. for (const list of lists) {
  186. // Safety check 1: List must have a swimlaneId (must be per-swimlane, not shared)
  187. if (!list.swimlaneId || list.swimlaneId === '') {
  188. if (process.env.DEBUG === 'true') {
  189. console.log(`Skipping list "${list.title}" - no swimlaneId (shared list)`);
  190. }
  191. continue;
  192. }
  193. // Safety check 2: List must have no cards
  194. const listCards = cards.filter(card => card.listId === list._id);
  195. if (listCards.length > 0) {
  196. if (process.env.DEBUG === 'true') {
  197. console.log(`Skipping list "${list.title}" - has ${listCards.length} cards`);
  198. }
  199. continue;
  200. }
  201. // Safety check 3: There must be another list with the same title on the same board that has cards
  202. const duplicateListsWithSameTitle = lists.filter(l =>
  203. l._id !== list._id &&
  204. l.title === list.title &&
  205. l.boardId === boardId
  206. );
  207. let hasDuplicateWithCards = false;
  208. for (const duplicateList of duplicateListsWithSameTitle) {
  209. const duplicateListCards = cards.filter(card => card.listId === duplicateList._id);
  210. if (duplicateListCards.length > 0) {
  211. hasDuplicateWithCards = true;
  212. break;
  213. }
  214. }
  215. if (!hasDuplicateWithCards) {
  216. if (process.env.DEBUG === 'true') {
  217. console.log(`Skipping list "${list.title}" - no duplicate list with same title that has cards`);
  218. }
  219. continue;
  220. }
  221. // All safety checks passed - delete the empty per-swimlane list
  222. Lists.remove(list._id);
  223. listsDeleted++;
  224. if (process.env.DEBUG === 'true') {
  225. console.log(`Deleted empty per-swimlane list: "${list.title}" (swimlane: ${list.swimlaneId}) - duplicate with cards exists`);
  226. }
  227. }
  228. return { listsDeleted };
  229. }
  230. /**
  231. * Get detailed status of empty lists
  232. */
  233. async getStatus(boardId) {
  234. const lists = ReactiveCache.getLists({ boardId });
  235. const cards = ReactiveCache.getCards({ boardId });
  236. const sharedLists = [];
  237. const emptyPerSwimlaneLists = [];
  238. const nonEmptyLists = [];
  239. for (const list of lists) {
  240. const listCards = cards.filter(card => card.listId === list._id);
  241. const isShared = !list.swimlaneId || list.swimlaneId === '';
  242. const isEmpty = listCards.length === 0;
  243. if (isShared) {
  244. sharedLists.push({
  245. id: list._id,
  246. title: list.title,
  247. cardCount: listCards.length
  248. });
  249. } else if (isEmpty) {
  250. emptyPerSwimlaneLists.push({
  251. id: list._id,
  252. title: list.title,
  253. swimlaneId: list.swimlaneId
  254. });
  255. } else {
  256. nonEmptyLists.push({
  257. id: list._id,
  258. title: list.title,
  259. swimlaneId: list.swimlaneId,
  260. cardCount: listCards.length
  261. });
  262. }
  263. }
  264. return {
  265. sharedListsCount: sharedLists.length,
  266. emptyPerSwimlaneLists: emptyPerSwimlaneLists.length,
  267. totalLists: lists.length,
  268. details: {
  269. sharedLists,
  270. emptyPerSwimlaneLists,
  271. nonEmptyLists
  272. }
  273. };
  274. }
  275. }
  276. const deleteDuplicateEmptyListsMigration = new DeleteDuplicateEmptyListsMigration();
  277. // Register Meteor methods
  278. Meteor.methods({
  279. 'deleteDuplicateEmptyLists.needsMigration'(boardId) {
  280. check(boardId, String);
  281. if (!this.userId) {
  282. throw new Meteor.Error('not-authorized', 'You must be logged in');
  283. }
  284. return deleteDuplicateEmptyListsMigration.needsMigration(boardId);
  285. },
  286. 'deleteDuplicateEmptyLists.execute'(boardId) {
  287. check(boardId, String);
  288. if (!this.userId) {
  289. throw new Meteor.Error('not-authorized', 'You must be logged in');
  290. }
  291. // Check if user is board admin
  292. const board = ReactiveCache.getBoard(boardId);
  293. if (!board) {
  294. throw new Meteor.Error('board-not-found', 'Board not found');
  295. }
  296. const user = ReactiveCache.getUser(this.userId);
  297. if (!user) {
  298. throw new Meteor.Error('user-not-found', 'User not found');
  299. }
  300. // Only board admins can run migrations
  301. const isBoardAdmin = board.members && board.members.some(
  302. member => member.userId === this.userId && member.isAdmin
  303. );
  304. if (!isBoardAdmin && !user.isAdmin) {
  305. throw new Meteor.Error('not-authorized', 'Only board administrators can run migrations');
  306. }
  307. return deleteDuplicateEmptyListsMigration.executeMigration(boardId);
  308. },
  309. 'deleteDuplicateEmptyLists.getStatus'(boardId) {
  310. check(boardId, String);
  311. if (!this.userId) {
  312. throw new Meteor.Error('not-authorized', 'You must be logged in');
  313. }
  314. return deleteDuplicateEmptyListsMigration.getStatus(boardId);
  315. }
  316. });
  317. export default deleteDuplicateEmptyListsMigration;