userPositionHistory.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. import { ReactiveCache } from '/imports/reactiveCache';
  2. /**
  3. * UserPositionHistory collection - Per-user history of entity movements
  4. * Similar to Activities but specifically for tracking position changes with undo/redo support
  5. */
  6. UserPositionHistory = new Mongo.Collection('userPositionHistory');
  7. UserPositionHistory.attachSchema(
  8. new SimpleSchema({
  9. userId: {
  10. /**
  11. * The user who made this change
  12. */
  13. type: String,
  14. },
  15. boardId: {
  16. /**
  17. * The board where the change occurred
  18. */
  19. type: String,
  20. },
  21. entityType: {
  22. /**
  23. * Type of entity: 'swimlane', 'list', or 'card'
  24. */
  25. type: String,
  26. allowedValues: ['swimlane', 'list', 'card', 'checklist', 'checklistItem'],
  27. },
  28. entityId: {
  29. /**
  30. * The ID of the entity that was moved
  31. */
  32. type: String,
  33. },
  34. actionType: {
  35. /**
  36. * Type of action performed
  37. */
  38. type: String,
  39. allowedValues: ['move', 'create', 'delete', 'restore', 'archive'],
  40. },
  41. previousState: {
  42. /**
  43. * The state before the change
  44. */
  45. type: Object,
  46. blackbox: true,
  47. optional: true,
  48. },
  49. newState: {
  50. /**
  51. * The state after the change
  52. */
  53. type: Object,
  54. blackbox: true,
  55. },
  56. // For easier undo operations, store specific fields
  57. previousSort: {
  58. type: Number,
  59. decimal: true,
  60. optional: true,
  61. },
  62. newSort: {
  63. type: Number,
  64. decimal: true,
  65. optional: true,
  66. },
  67. previousSwimlaneId: {
  68. type: String,
  69. optional: true,
  70. },
  71. newSwimlaneId: {
  72. type: String,
  73. optional: true,
  74. },
  75. previousListId: {
  76. type: String,
  77. optional: true,
  78. },
  79. newListId: {
  80. type: String,
  81. optional: true,
  82. },
  83. previousBoardId: {
  84. type: String,
  85. optional: true,
  86. },
  87. newBoardId: {
  88. type: String,
  89. optional: true,
  90. },
  91. createdAt: {
  92. /**
  93. * When this history entry was created
  94. */
  95. type: Date,
  96. autoValue() {
  97. if (this.isInsert) {
  98. return new Date();
  99. } else if (this.isUpsert) {
  100. return { $setOnInsert: new Date() };
  101. } else {
  102. this.unset();
  103. }
  104. },
  105. },
  106. // For savepoint/checkpoint feature
  107. isCheckpoint: {
  108. /**
  109. * Whether this is a user-marked checkpoint/savepoint
  110. */
  111. type: Boolean,
  112. defaultValue: false,
  113. optional: true,
  114. },
  115. checkpointName: {
  116. /**
  117. * User-defined name for the checkpoint
  118. */
  119. type: String,
  120. optional: true,
  121. },
  122. // For grouping related changes
  123. batchId: {
  124. /**
  125. * ID to group related changes (e.g., moving multiple cards at once)
  126. */
  127. type: String,
  128. optional: true,
  129. },
  130. }),
  131. );
  132. UserPositionHistory.allow({
  133. insert(userId, doc) {
  134. // Only allow users to create their own history
  135. return userId && doc.userId === userId;
  136. },
  137. update(userId, doc) {
  138. // Only allow users to update their own history (for checkpoints)
  139. return userId && doc.userId === userId;
  140. },
  141. remove() {
  142. // Don't allow removal - history is permanent
  143. return false;
  144. },
  145. fetch: ['userId'],
  146. });
  147. UserPositionHistory.helpers({
  148. /**
  149. * Get a human-readable description of this change
  150. */
  151. getDescription() {
  152. const entityName = this.entityType;
  153. const action = this.actionType;
  154. let desc = `${action} ${entityName}`;
  155. if (this.actionType === 'move') {
  156. if (this.previousListId && this.newListId && this.previousListId !== this.newListId) {
  157. desc += ' to different list';
  158. } else if (this.previousSwimlaneId && this.newSwimlaneId && this.previousSwimlaneId !== this.newSwimlaneId) {
  159. desc += ' to different swimlane';
  160. } else if (this.previousSort !== this.newSort) {
  161. desc += ' position';
  162. }
  163. }
  164. return desc;
  165. },
  166. /**
  167. * Can this change be undone?
  168. */
  169. canUndo() {
  170. // Can undo if the entity still exists
  171. switch (this.entityType) {
  172. case 'card':
  173. return !!ReactiveCache.getCard(this.entityId);
  174. case 'list':
  175. return !!ReactiveCache.getList(this.entityId);
  176. case 'swimlane':
  177. return !!ReactiveCache.getSwimlane(this.entityId);
  178. case 'checklist':
  179. return !!ReactiveCache.getChecklist(this.entityId);
  180. case 'checklistItem':
  181. return !!ChecklistItems.findOne(this.entityId);
  182. default:
  183. return false;
  184. }
  185. },
  186. /**
  187. * Undo this change
  188. */
  189. undo() {
  190. if (!this.canUndo()) {
  191. throw new Meteor.Error('cannot-undo', 'Entity no longer exists');
  192. }
  193. const userId = this.userId;
  194. switch (this.entityType) {
  195. case 'card': {
  196. const card = ReactiveCache.getCard(this.entityId);
  197. if (card) {
  198. // Restore previous position
  199. const boardId = this.previousBoardId || card.boardId;
  200. const swimlaneId = this.previousSwimlaneId || card.swimlaneId;
  201. const listId = this.previousListId || card.listId;
  202. const sort = this.previousSort !== undefined ? this.previousSort : card.sort;
  203. Cards.update(card._id, {
  204. $set: {
  205. boardId,
  206. swimlaneId,
  207. listId,
  208. sort,
  209. },
  210. });
  211. }
  212. break;
  213. }
  214. case 'list': {
  215. const list = ReactiveCache.getList(this.entityId);
  216. if (list) {
  217. const sort = this.previousSort !== undefined ? this.previousSort : list.sort;
  218. const swimlaneId = this.previousSwimlaneId || list.swimlaneId;
  219. Lists.update(list._id, {
  220. $set: {
  221. sort,
  222. swimlaneId,
  223. },
  224. });
  225. }
  226. break;
  227. }
  228. case 'swimlane': {
  229. const swimlane = ReactiveCache.getSwimlane(this.entityId);
  230. if (swimlane) {
  231. const sort = this.previousSort !== undefined ? this.previousSort : swimlane.sort;
  232. Swimlanes.update(swimlane._id, {
  233. $set: {
  234. sort,
  235. },
  236. });
  237. }
  238. break;
  239. }
  240. case 'checklist': {
  241. const checklist = ReactiveCache.getChecklist(this.entityId);
  242. if (checklist) {
  243. const sort = this.previousSort !== undefined ? this.previousSort : checklist.sort;
  244. Checklists.update(checklist._id, {
  245. $set: {
  246. sort,
  247. },
  248. });
  249. }
  250. break;
  251. }
  252. case 'checklistItem': {
  253. if (typeof ChecklistItems !== 'undefined') {
  254. const item = ChecklistItems.findOne(this.entityId);
  255. if (item) {
  256. const sort = this.previousSort !== undefined ? this.previousSort : item.sort;
  257. const checklistId = this.previousState?.checklistId || item.checklistId;
  258. ChecklistItems.update(item._id, {
  259. $set: {
  260. sort,
  261. checklistId,
  262. },
  263. });
  264. }
  265. }
  266. break;
  267. }
  268. }
  269. },
  270. });
  271. if (Meteor.isServer) {
  272. Meteor.startup(() => {
  273. UserPositionHistory._collection.createIndex({ userId: 1, boardId: 1, createdAt: -1 });
  274. UserPositionHistory._collection.createIndex({ userId: 1, entityType: 1, entityId: 1 });
  275. UserPositionHistory._collection.createIndex({ userId: 1, isCheckpoint: 1 });
  276. UserPositionHistory._collection.createIndex({ batchId: 1 });
  277. UserPositionHistory._collection.createIndex({ createdAt: 1 }); // For cleanup of old entries
  278. });
  279. /**
  280. * Helper to track a position change
  281. */
  282. UserPositionHistory.trackChange = function(options) {
  283. const {
  284. userId,
  285. boardId,
  286. entityType,
  287. entityId,
  288. actionType,
  289. previousState,
  290. newState,
  291. batchId,
  292. } = options;
  293. if (!userId || !boardId || !entityType || !entityId || !actionType) {
  294. throw new Meteor.Error('invalid-params', 'Missing required parameters');
  295. }
  296. const historyEntry = {
  297. userId,
  298. boardId,
  299. entityType,
  300. entityId,
  301. actionType,
  302. newState,
  303. };
  304. if (previousState) {
  305. historyEntry.previousState = previousState;
  306. historyEntry.previousSort = previousState.sort;
  307. historyEntry.previousSwimlaneId = previousState.swimlaneId;
  308. historyEntry.previousListId = previousState.listId;
  309. historyEntry.previousBoardId = previousState.boardId;
  310. }
  311. if (newState) {
  312. historyEntry.newSort = newState.sort;
  313. historyEntry.newSwimlaneId = newState.swimlaneId;
  314. historyEntry.newListId = newState.listId;
  315. historyEntry.newBoardId = newState.boardId;
  316. }
  317. if (batchId) {
  318. historyEntry.batchId = batchId;
  319. }
  320. return UserPositionHistory.insert(historyEntry);
  321. };
  322. /**
  323. * Cleanup old history entries (keep last 1000 per user per board)
  324. */
  325. UserPositionHistory.cleanup = function() {
  326. const users = Meteor.users.find({}).fetch();
  327. users.forEach(user => {
  328. const boards = Boards.find({ 'members.userId': user._id }).fetch();
  329. boards.forEach(board => {
  330. const history = UserPositionHistory.find(
  331. { userId: user._id, boardId: board._id, isCheckpoint: { $ne: true } },
  332. { sort: { createdAt: -1 }, limit: 1000 }
  333. ).fetch();
  334. if (history.length >= 1000) {
  335. const oldestToKeep = history[999].createdAt;
  336. // Remove entries older than the 1000th entry (except checkpoints)
  337. UserPositionHistory.remove({
  338. userId: user._id,
  339. boardId: board._id,
  340. createdAt: { $lt: oldestToKeep },
  341. isCheckpoint: { $ne: true },
  342. });
  343. }
  344. });
  345. });
  346. };
  347. // Run cleanup daily
  348. if (Meteor.settings.public?.enableHistoryCleanup !== false) {
  349. Meteor.setInterval(() => {
  350. try {
  351. UserPositionHistory.cleanup();
  352. } catch (e) {
  353. console.error('Error during history cleanup:', e);
  354. }
  355. }, 24 * 60 * 60 * 1000); // Once per day
  356. }
  357. }
  358. // Meteor Methods for client interaction
  359. Meteor.methods({
  360. 'userPositionHistory.createCheckpoint'(boardId, checkpointName) {
  361. check(boardId, String);
  362. check(checkpointName, String);
  363. if (!this.userId) {
  364. throw new Meteor.Error('not-authorized', 'Must be logged in');
  365. }
  366. // Create a checkpoint entry
  367. return UserPositionHistory.insert({
  368. userId: this.userId,
  369. boardId,
  370. entityType: 'checkpoint',
  371. entityId: 'checkpoint',
  372. actionType: 'create',
  373. isCheckpoint: true,
  374. checkpointName,
  375. newState: {
  376. timestamp: new Date(),
  377. },
  378. });
  379. },
  380. 'userPositionHistory.undo'(historyId) {
  381. check(historyId, String);
  382. if (!this.userId) {
  383. throw new Meteor.Error('not-authorized', 'Must be logged in');
  384. }
  385. const history = UserPositionHistory.findOne({ _id: historyId, userId: this.userId });
  386. if (!history) {
  387. throw new Meteor.Error('not-found', 'History entry not found');
  388. }
  389. return history.undo();
  390. },
  391. 'userPositionHistory.getRecent'(boardId, limit = 50) {
  392. check(boardId, String);
  393. check(limit, Number);
  394. if (!this.userId) {
  395. throw new Meteor.Error('not-authorized', 'Must be logged in');
  396. }
  397. return UserPositionHistory.find(
  398. { userId: this.userId, boardId },
  399. { sort: { createdAt: -1 }, limit: Math.min(limit, 100) }
  400. ).fetch();
  401. },
  402. 'userPositionHistory.getCheckpoints'(boardId) {
  403. check(boardId, String);
  404. if (!this.userId) {
  405. throw new Meteor.Error('not-authorized', 'Must be logged in');
  406. }
  407. return UserPositionHistory.find(
  408. { userId: this.userId, boardId, isCheckpoint: true },
  409. { sort: { createdAt: -1 } }
  410. ).fetch();
  411. },
  412. 'userPositionHistory.restoreToCheckpoint'(checkpointId) {
  413. check(checkpointId, String);
  414. if (!this.userId) {
  415. throw new Meteor.Error('not-authorized', 'Must be logged in');
  416. }
  417. const checkpoint = UserPositionHistory.findOne({
  418. _id: checkpointId,
  419. userId: this.userId,
  420. isCheckpoint: true,
  421. });
  422. if (!checkpoint) {
  423. throw new Meteor.Error('not-found', 'Checkpoint not found');
  424. }
  425. // Find all changes after this checkpoint and undo them in reverse order
  426. const changesToUndo = UserPositionHistory.find(
  427. {
  428. userId: this.userId,
  429. boardId: checkpoint.boardId,
  430. createdAt: { $gt: checkpoint.createdAt },
  431. isCheckpoint: { $ne: true },
  432. },
  433. { sort: { createdAt: -1 } }
  434. ).fetch();
  435. let undoneCount = 0;
  436. changesToUndo.forEach(change => {
  437. try {
  438. if (change.canUndo()) {
  439. change.undo();
  440. undoneCount++;
  441. }
  442. } catch (e) {
  443. console.warn('Failed to undo change:', change._id, e);
  444. }
  445. });
  446. return { undoneCount, totalChanges: changesToUndo.length };
  447. },
  448. });