mirror of
https://github.com/wekan/wekan.git
synced 2026-01-08 02:28:50 +01:00
Per-User and Board-level data save fixes. Per-User is collapse, width, height. Per-Board is Swimlanes, Lists, Cards etc.
Thanks to xet7 ! Fixes #5997
This commit is contained in:
parent
2e0e1e56b5
commit
414b8dbf41
11 changed files with 2273 additions and 57 deletions
498
models/userPositionHistory.js
Normal file
498
models/userPositionHistory.js
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
|
||||
/**
|
||||
* UserPositionHistory collection - Per-user history of entity movements
|
||||
* Similar to Activities but specifically for tracking position changes with undo/redo support
|
||||
*/
|
||||
UserPositionHistory = new Mongo.Collection('userPositionHistory');
|
||||
|
||||
UserPositionHistory.attachSchema(
|
||||
new SimpleSchema({
|
||||
userId: {
|
||||
/**
|
||||
* The user who made this change
|
||||
*/
|
||||
type: String,
|
||||
},
|
||||
boardId: {
|
||||
/**
|
||||
* The board where the change occurred
|
||||
*/
|
||||
type: String,
|
||||
},
|
||||
entityType: {
|
||||
/**
|
||||
* Type of entity: 'swimlane', 'list', or 'card'
|
||||
*/
|
||||
type: String,
|
||||
allowedValues: ['swimlane', 'list', 'card', 'checklist', 'checklistItem'],
|
||||
},
|
||||
entityId: {
|
||||
/**
|
||||
* The ID of the entity that was moved
|
||||
*/
|
||||
type: String,
|
||||
},
|
||||
actionType: {
|
||||
/**
|
||||
* Type of action performed
|
||||
*/
|
||||
type: String,
|
||||
allowedValues: ['move', 'create', 'delete', 'restore', 'archive'],
|
||||
},
|
||||
previousState: {
|
||||
/**
|
||||
* The state before the change
|
||||
*/
|
||||
type: Object,
|
||||
blackbox: true,
|
||||
optional: true,
|
||||
},
|
||||
newState: {
|
||||
/**
|
||||
* The state after the change
|
||||
*/
|
||||
type: Object,
|
||||
blackbox: true,
|
||||
},
|
||||
// For easier undo operations, store specific fields
|
||||
previousSort: {
|
||||
type: Number,
|
||||
decimal: true,
|
||||
optional: true,
|
||||
},
|
||||
newSort: {
|
||||
type: Number,
|
||||
decimal: true,
|
||||
optional: true,
|
||||
},
|
||||
previousSwimlaneId: {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
newSwimlaneId: {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
previousListId: {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
newListId: {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
previousBoardId: {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
newBoardId: {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
createdAt: {
|
||||
/**
|
||||
* When this history entry was created
|
||||
*/
|
||||
type: Date,
|
||||
autoValue() {
|
||||
if (this.isInsert) {
|
||||
return new Date();
|
||||
} else if (this.isUpsert) {
|
||||
return { $setOnInsert: new Date() };
|
||||
} else {
|
||||
this.unset();
|
||||
}
|
||||
},
|
||||
},
|
||||
// For savepoint/checkpoint feature
|
||||
isCheckpoint: {
|
||||
/**
|
||||
* Whether this is a user-marked checkpoint/savepoint
|
||||
*/
|
||||
type: Boolean,
|
||||
defaultValue: false,
|
||||
optional: true,
|
||||
},
|
||||
checkpointName: {
|
||||
/**
|
||||
* User-defined name for the checkpoint
|
||||
*/
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
// For grouping related changes
|
||||
batchId: {
|
||||
/**
|
||||
* ID to group related changes (e.g., moving multiple cards at once)
|
||||
*/
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
UserPositionHistory.allow({
|
||||
insert(userId, doc) {
|
||||
// Only allow users to create their own history
|
||||
return userId && doc.userId === userId;
|
||||
},
|
||||
update(userId, doc) {
|
||||
// Only allow users to update their own history (for checkpoints)
|
||||
return userId && doc.userId === userId;
|
||||
},
|
||||
remove() {
|
||||
// Don't allow removal - history is permanent
|
||||
return false;
|
||||
},
|
||||
fetch: ['userId'],
|
||||
});
|
||||
|
||||
UserPositionHistory.helpers({
|
||||
/**
|
||||
* Get a human-readable description of this change
|
||||
*/
|
||||
getDescription() {
|
||||
const entityName = this.entityType;
|
||||
const action = this.actionType;
|
||||
|
||||
let desc = `${action} ${entityName}`;
|
||||
|
||||
if (this.actionType === 'move') {
|
||||
if (this.previousListId && this.newListId && this.previousListId !== this.newListId) {
|
||||
desc += ' to different list';
|
||||
} else if (this.previousSwimlaneId && this.newSwimlaneId && this.previousSwimlaneId !== this.newSwimlaneId) {
|
||||
desc += ' to different swimlane';
|
||||
} else if (this.previousSort !== this.newSort) {
|
||||
desc += ' position';
|
||||
}
|
||||
}
|
||||
|
||||
return desc;
|
||||
},
|
||||
|
||||
/**
|
||||
* Can this change be undone?
|
||||
*/
|
||||
canUndo() {
|
||||
// Can undo if the entity still exists
|
||||
switch (this.entityType) {
|
||||
case 'card':
|
||||
return !!ReactiveCache.getCard(this.entityId);
|
||||
case 'list':
|
||||
return !!ReactiveCache.getList(this.entityId);
|
||||
case 'swimlane':
|
||||
return !!ReactiveCache.getSwimlane(this.entityId);
|
||||
case 'checklist':
|
||||
return !!ReactiveCache.getChecklist(this.entityId);
|
||||
case 'checklistItem':
|
||||
return !!ChecklistItems.findOne(this.entityId);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Undo this change
|
||||
*/
|
||||
undo() {
|
||||
if (!this.canUndo()) {
|
||||
throw new Meteor.Error('cannot-undo', 'Entity no longer exists');
|
||||
}
|
||||
|
||||
const userId = this.userId;
|
||||
|
||||
switch (this.entityType) {
|
||||
case 'card': {
|
||||
const card = ReactiveCache.getCard(this.entityId);
|
||||
if (card) {
|
||||
// Restore previous position
|
||||
const boardId = this.previousBoardId || card.boardId;
|
||||
const swimlaneId = this.previousSwimlaneId || card.swimlaneId;
|
||||
const listId = this.previousListId || card.listId;
|
||||
const sort = this.previousSort !== undefined ? this.previousSort : card.sort;
|
||||
|
||||
Cards.update(card._id, {
|
||||
$set: {
|
||||
boardId,
|
||||
swimlaneId,
|
||||
listId,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'list': {
|
||||
const list = ReactiveCache.getList(this.entityId);
|
||||
if (list) {
|
||||
const sort = this.previousSort !== undefined ? this.previousSort : list.sort;
|
||||
const swimlaneId = this.previousSwimlaneId || list.swimlaneId;
|
||||
|
||||
Lists.update(list._id, {
|
||||
$set: {
|
||||
sort,
|
||||
swimlaneId,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'swimlane': {
|
||||
const swimlane = ReactiveCache.getSwimlane(this.entityId);
|
||||
if (swimlane) {
|
||||
const sort = this.previousSort !== undefined ? this.previousSort : swimlane.sort;
|
||||
|
||||
Swimlanes.update(swimlane._id, {
|
||||
$set: {
|
||||
sort,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'checklist': {
|
||||
const checklist = ReactiveCache.getChecklist(this.entityId);
|
||||
if (checklist) {
|
||||
const sort = this.previousSort !== undefined ? this.previousSort : checklist.sort;
|
||||
|
||||
Checklists.update(checklist._id, {
|
||||
$set: {
|
||||
sort,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'checklistItem': {
|
||||
const item = ChecklistItems.findOne(this.entityId);
|
||||
if (item) {
|
||||
const sort = this.previousSort !== undefined ? this.previousSort : item.sort;
|
||||
const checklistId = this.previousState?.checklistId || item.checklistId;
|
||||
|
||||
ChecklistItems.update(item._id, {
|
||||
$set: {
|
||||
sort,
|
||||
checklistId,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(() => {
|
||||
UserPositionHistory._collection.createIndex({ userId: 1, boardId: 1, createdAt: -1 });
|
||||
UserPositionHistory._collection.createIndex({ userId: 1, entityType: 1, entityId: 1 });
|
||||
UserPositionHistory._collection.createIndex({ userId: 1, isCheckpoint: 1 });
|
||||
UserPositionHistory._collection.createIndex({ batchId: 1 });
|
||||
UserPositionHistory._collection.createIndex({ createdAt: 1 }); // For cleanup of old entries
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to track a position change
|
||||
*/
|
||||
UserPositionHistory.trackChange = function(options) {
|
||||
const {
|
||||
userId,
|
||||
boardId,
|
||||
entityType,
|
||||
entityId,
|
||||
actionType,
|
||||
previousState,
|
||||
newState,
|
||||
batchId,
|
||||
} = options;
|
||||
|
||||
if (!userId || !boardId || !entityType || !entityId || !actionType) {
|
||||
throw new Meteor.Error('invalid-params', 'Missing required parameters');
|
||||
}
|
||||
|
||||
const historyEntry = {
|
||||
userId,
|
||||
boardId,
|
||||
entityType,
|
||||
entityId,
|
||||
actionType,
|
||||
newState,
|
||||
};
|
||||
|
||||
if (previousState) {
|
||||
historyEntry.previousState = previousState;
|
||||
historyEntry.previousSort = previousState.sort;
|
||||
historyEntry.previousSwimlaneId = previousState.swimlaneId;
|
||||
historyEntry.previousListId = previousState.listId;
|
||||
historyEntry.previousBoardId = previousState.boardId;
|
||||
}
|
||||
|
||||
if (newState) {
|
||||
historyEntry.newSort = newState.sort;
|
||||
historyEntry.newSwimlaneId = newState.swimlaneId;
|
||||
historyEntry.newListId = newState.listId;
|
||||
historyEntry.newBoardId = newState.boardId;
|
||||
}
|
||||
|
||||
if (batchId) {
|
||||
historyEntry.batchId = batchId;
|
||||
}
|
||||
|
||||
return UserPositionHistory.insert(historyEntry);
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleanup old history entries (keep last 1000 per user per board)
|
||||
*/
|
||||
UserPositionHistory.cleanup = function() {
|
||||
const users = Meteor.users.find({}).fetch();
|
||||
|
||||
users.forEach(user => {
|
||||
const boards = Boards.find({ 'members.userId': user._id }).fetch();
|
||||
|
||||
boards.forEach(board => {
|
||||
const history = UserPositionHistory.find(
|
||||
{ userId: user._id, boardId: board._id, isCheckpoint: { $ne: true } },
|
||||
{ sort: { createdAt: -1 }, limit: 1000 }
|
||||
).fetch();
|
||||
|
||||
if (history.length >= 1000) {
|
||||
const oldestToKeep = history[999].createdAt;
|
||||
|
||||
// Remove entries older than the 1000th entry (except checkpoints)
|
||||
UserPositionHistory.remove({
|
||||
userId: user._id,
|
||||
boardId: board._id,
|
||||
createdAt: { $lt: oldestToKeep },
|
||||
isCheckpoint: { $ne: true },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Run cleanup daily
|
||||
if (Meteor.settings.public?.enableHistoryCleanup !== false) {
|
||||
Meteor.setInterval(() => {
|
||||
try {
|
||||
UserPositionHistory.cleanup();
|
||||
} catch (e) {
|
||||
console.error('Error during history cleanup:', e);
|
||||
}
|
||||
}, 24 * 60 * 60 * 1000); // Once per day
|
||||
}
|
||||
}
|
||||
|
||||
// Meteor Methods for client interaction
|
||||
Meteor.methods({
|
||||
'userPositionHistory.createCheckpoint'(boardId, checkpointName) {
|
||||
check(boardId, String);
|
||||
check(checkpointName, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
// Create a checkpoint entry
|
||||
return UserPositionHistory.insert({
|
||||
userId: this.userId,
|
||||
boardId,
|
||||
entityType: 'checkpoint',
|
||||
entityId: 'checkpoint',
|
||||
actionType: 'create',
|
||||
isCheckpoint: true,
|
||||
checkpointName,
|
||||
newState: {
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
'userPositionHistory.undo'(historyId) {
|
||||
check(historyId, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
const history = UserPositionHistory.findOne({ _id: historyId, userId: this.userId });
|
||||
if (!history) {
|
||||
throw new Meteor.Error('not-found', 'History entry not found');
|
||||
}
|
||||
|
||||
return history.undo();
|
||||
},
|
||||
|
||||
'userPositionHistory.getRecent'(boardId, limit = 50) {
|
||||
check(boardId, String);
|
||||
check(limit, Number);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
return UserPositionHistory.find(
|
||||
{ userId: this.userId, boardId },
|
||||
{ sort: { createdAt: -1 }, limit: Math.min(limit, 100) }
|
||||
).fetch();
|
||||
},
|
||||
|
||||
'userPositionHistory.getCheckpoints'(boardId) {
|
||||
check(boardId, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
return UserPositionHistory.find(
|
||||
{ userId: this.userId, boardId, isCheckpoint: true },
|
||||
{ sort: { createdAt: -1 } }
|
||||
).fetch();
|
||||
},
|
||||
|
||||
'userPositionHistory.restoreToCheckpoint'(checkpointId) {
|
||||
check(checkpointId, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
const checkpoint = UserPositionHistory.findOne({
|
||||
_id: checkpointId,
|
||||
userId: this.userId,
|
||||
isCheckpoint: true,
|
||||
});
|
||||
|
||||
if (!checkpoint) {
|
||||
throw new Meteor.Error('not-found', 'Checkpoint not found');
|
||||
}
|
||||
|
||||
// Find all changes after this checkpoint and undo them in reverse order
|
||||
const changesToUndo = UserPositionHistory.find(
|
||||
{
|
||||
userId: this.userId,
|
||||
boardId: checkpoint.boardId,
|
||||
createdAt: { $gt: checkpoint.createdAt },
|
||||
isCheckpoint: { $ne: true },
|
||||
},
|
||||
{ sort: { createdAt: -1 } }
|
||||
).fetch();
|
||||
|
||||
let undoneCount = 0;
|
||||
changesToUndo.forEach(change => {
|
||||
try {
|
||||
if (change.canUndo()) {
|
||||
change.undo();
|
||||
undoneCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to undo change:', change._id, e);
|
||||
}
|
||||
});
|
||||
|
||||
return { undoneCount, totalChanges: changesToUndo.length };
|
||||
},
|
||||
});
|
||||
|
||||
export default UserPositionHistory;
|
||||
Loading…
Add table
Add a link
Reference in a new issue