docs/Security/PerUserDataAudit2025-12-23/ARCHITECTURE_IMPROVEMENTS.md
This document describes the architectural improvements made to Wekan's persistence layer to ensure proper separation between board-level data and per-user UI preferences.
Changes:
collapsed field from Swimlanes schema (models/swimlanes.js)collapsed field from Lists schema (models/lists.js)collapse() mutation from SwimlanesPUT /api/boards/:boardId/lists/:listIdRationale: Collapsed state is a per-user UI preference and should never be stored at the board level. This prevents conflicts where one user collapses a swimlane/list and affects all other users.
Migration:
collapsed values will be ignoredprofile.collapsedSwimlanes and profile.collapsedListsFile: client/lib/localStorageValidator.js
Features:
Usage:
import { validateAndCleanLocalStorage, shouldRunCleanup } from '/client/lib/localStorageValidator';
// Auto-runs on startup
Meteor.startup(() => {
if (shouldRunCleanup()) {
validateAndCleanLocalStorage();
}
});
File: models/lib/userStorageHelpers.js
Functions:
getValidatedNumber(key, boardId, itemId, defaultValue, min, max) - Get with validationsetValidatedNumber(key, boardId, itemId, value, min, max) - Set with validationgetValidatedBoolean(key, boardId, itemId, defaultValue) - Get booleansetValidatedBoolean(key, boardId, itemId, value) - Set booleanValidation Applied To:
wekan-list-widths - List column widthswekan-list-constraints - List max-width constraintswekan-swimlane-heights - Swimlane row heightswekan-collapsed-lists - List collapse stateswekan-collapsed-swimlanes - Swimlane collapse statesFile: models/userPositionHistory.js
Purpose: Track all position changes (moves, reorders) per user with full undo/redo support.
Schema Fields:
userId - User who made the changeboardId - Board where change occurredentityType - Type: 'swimlane', 'list', 'card', 'checklist', 'checklistItem'entityId - ID of the moved entityactionType - Type: 'move', 'create', 'delete', 'restore', 'archive'previousState - Complete state before change (blackbox object)newState - Complete state after change (blackbox object)previousSort, newSort - Sort positionspreviousSwimlaneId, newSwimlaneId - Swimlane changespreviousListId, newListId - List changespreviousBoardId, newBoardId - Board changesisCheckpoint - User-marked savepointcheckpointName - Name for the savepointbatchId - Group related changes togethercreatedAt - TimestampKey Features:
Helpers:
getDescription() - Human-readable change descriptioncanUndo() - Check if change can be undoneundo() - Reverse the changeIndexes:
{ userId: 1, boardId: 1, createdAt: -1 }
{ userId: 1, entityType: 1, entityId: 1 }
{ userId: 1, isCheckpoint: 1 }
{ batchId: 1 }
{ createdAt: 1 }
Available Methods:
// Create a checkpoint/savepoint
Meteor.call('userPositionHistory.createCheckpoint', boardId, checkpointName);
// Undo a specific change
Meteor.call('userPositionHistory.undo', historyId);
// Get recent changes
Meteor.call('userPositionHistory.getRecent', boardId, limit);
// Get all checkpoints
Meteor.call('userPositionHistory.getCheckpoints', boardId);
// Restore to a checkpoint (undo all changes after it)
Meteor.call('userPositionHistory.restoreToCheckpoint', checkpointId);
Card Moves: models/cards.js
The card.move() method now automatically tracks changes:
// Capture previous state
const previousState = {
boardId: this.boardId,
swimlaneId: this.swimlaneId,
listId: this.listId,
sort: this.sort,
};
// After update, track in history
UserPositionHistory.trackChange({
userId: Meteor.userId(),
boardId: this.boardId,
entityType: 'card',
entityId: this._id,
actionType: 'move',
previousState,
newState: { boardId, swimlaneId, listId, sort },
});
TODO: Add similar tracking for:
File: server/migrations/ensureValidSwimlaneIds.js
Purpose: Ensure all cards and lists have valid swimlaneId references, rescuing orphaned data.
Operations:
Fix Cards Without SwimlaneId
Fix Lists Without SwimlaneId
Rescue Orphaned Cards
Add Validation Hooks
Cards.before.insert - Auto-assign default swimlaneIdCards.before.update - Prevent swimlaneId removalMigration Tracking:
Stored in migrations collection:
{
name: 'ensure-valid-swimlane-ids',
version: 1,
completedAt: Date,
results: {
cardsFixed: Number,
listsFixed: Number,
cardsRescued: Number,
}
}
Board Toolbar:
History Sidebar:
// To implement in client/lib/keyboard.js
Mousetrap.bind('ctrl+z', () => {
// Undo last change
});
Mousetrap.bind('ctrl+shift+z', () => {
// Redo last undone change
});
Mousetrap.bind('ctrl+shift+s', () => {
// Create checkpoint
});
Per the user request:
"For board-level data, for each field (like description, comments etc) at Search All Boards have translatable options to also search from history of boards where user is member of board"
New Collection: FieldHistory
{
boardId: String,
entityType: String, // 'card', 'list', 'swimlane', 'board'
entityId: String,
fieldName: String, // 'description', 'title', 'comments', etc.
previousValue: String,
newValue: String,
changedBy: String, // userId
changedAt: Date,
}
Search Enhancement:
Translatable Field Options:
const searchableFieldsI18n = {
'card-title': 'search-card-titles',
'card-description': 'search-card-descriptions',
'card-comments': 'search-card-comments',
'list-title': 'search-list-titles',
'swimlane-title': 'search-swimlane-titles',
'board-title': 'search-board-titles',
// Add i18n keys for each searchable field
};
Challenge: Field history can grow very large
Solutions:
Suggested Settings:
{
enableFieldHistory: true,
trackedFields: ['description', 'title', 'comments'],
historyRetentionDays: 90,
maxHistoryPerField: 100,
}
| Data Type | Storage | Validation | Range/Type |
|---|---|---|---|
| List Width | localStorage + profile | Number | 100-1000 px |
| List Constraint | localStorage + profile | Number | 100-1000 px |
| Swimlane Height | localStorage + profile | Number | -1 (auto) or 50-2000 px |
| Collapsed Lists | localStorage + profile | Boolean | true/false |
| Collapsed Swimlanes | localStorage + profile | Boolean | true/false |
| SwimlaneId | MongoDB (cards) | String (required) | Valid ObjectId |
| SwimlaneId | MongoDB (lists) | String (optional) | Valid ObjectId or '' |
LocalStorage:
UserPositionHistory:
Automatic Migrations:
ensureValidSwimlaneIds - Runs automatically on server startManual Actions Required:
When Adding New Per-User Preferences:
'profile.myNewPreference': {
type: Object,
optional: true,
blackbox: true,
}
function validateMyNewPreference(data) {
// Validate structure
// Return cleaned data
}
getMyNewPreferenceFromStorage(boardId, itemId) {
if (this._id) {
return this.getMyNewPreference(boardId, itemId);
}
return getValidatedData('wekan-my-preference', validators.myPreference);
}
localStorageValidator.jsUnit Tests Needed:
localStorageValidator.js - All validation functionsuserStorageHelpers.js - Get/set functionsuserPositionHistory.js - Undo logicensureValidSwimlaneIds.js - Migration logicIntegration Tests Needed:
// UserPositionHistory
{ userId: 1, boardId: 1, createdAt: -1 }
{ userId: 1, entityType: 1, entityId: 1 }
{ userId: 1, isCheckpoint: 1 }
{ batchId: 1 }
{ createdAt: 1 }
Field-Level History
Collaborative Undo
Export History
Visual Timeline
Batch Operations
Compression
Archival