12 KiB
Implementation Guide - Per-Board vs Per-User Data Storage
Status: ✅ Complete
Updated: 2025-12-23
Scope: Changes to implement per-board height/width storage and per-user-only collapse/label visibility
Overview of Changes
This document details all changes required to properly separate per-board data from per-user data.
1. Schema Changes ✅ COMPLETED
Swimlanes (swimlanes.js) ✅
Change: Add height field to schema
// ADDED:
height: {
/**
* The height of the swimlane in pixels.
* -1 = auto-height (default)
* 50-2000 = fixed height in pixels
*/
type: Number,
optional: true,
defaultValue: -1,
custom() {
const h = this.value;
if (h !== -1 && (h < 50 || h > 2000)) {
return 'heightOutOfRange';
}
},
},
Status: ✅ Implemented
Lists (lists.js) ✅
Change: Add width field to schema
// ADDED:
width: {
/**
* The width of the list in pixels (100-1000).
* Default width is 272 pixels.
*/
type: Number,
optional: true,
defaultValue: 272,
custom() {
const w = this.value;
if (w < 100 || w > 1000) {
return 'widthOutOfRange';
}
},
},
Status: ✅ Implemented
Cards (cards.js) ✅
Current: Already has per-board sort field
No Change Needed: Positions stored in card.sort (per-board)
Status: ✅ Already Correct
Checklists (checklists.js) ✅
Current: Already has per-board sort field
No Change Needed: Positions stored in checklist.sort (per-board)
Status: ✅ Already Correct
ChecklistItems (checklistItems.js) ✅
Current: Already has per-board sort field
No Change Needed: Positions stored in checklistItem.sort (per-board)
Status: ✅ Already Correct
2. User Model Changes
Users (users.js) - Remove Per-User Width/Height Storage
Current Code Problem:
- User profile stores
listWidths(per-user) → should be per-board - User profile stores
swimlaneHeights(per-user) → should be per-board - These methods access user.profile.listWidths and user.profile.swimlaneHeights
Solution: Refactor these methods to read from list/swimlane documents instead
Option A: Create Migration Helper (Recommended)
Create a new file: models/lib/persistenceHelpers.js
// Get swimlane height from swimlane document (per-board storage)
export const getSwimlaneHeight = (swimlaneId) => {
const swimlane = Swimlanes.findOne(swimlaneId);
return swimlane && swimlane.height !== undefined ? swimlane.height : -1;
};
// Get list width from list document (per-board storage)
export const getListWidth = (listId) => {
const list = Lists.findOne(listId);
return list && list.width !== undefined ? list.width : 272;
};
// Set swimlane height in swimlane document (per-board storage)
export const setSwimlaneHeight = (swimlaneId, height) => {
if (height !== -1 && (height < 50 || height > 2000)) {
throw new Error('Height out of range: -1 or 50-2000');
}
Swimlanes.update(swimlaneId, { $set: { height } });
};
// Set list width in list document (per-board storage)
export const setListWidth = (listId, width) => {
if (width < 100 || width > 1000) {
throw new Error('Width out of range: 100-1000');
}
Lists.update(listId, { $set: { width } });
};
Option B: Modify User Methods
Change these methods in users.js:
-
getListWidth(boardId, listId) - Remove per-user lookup
// OLD (removes this): // const listWidths = this.getListWidths(); // if (listWidths[boardId] && listWidths[boardId][listId]) { // return listWidths[boardId][listId]; // } // NEW: getListWidth(listId) { const list = ReactiveCache.getList({ _id: listId }); return list && list.width ? list.width : 272; }, -
getSwimlaneHeight(boardId, swimlaneId) - Remove per-user lookup
// OLD (removes this): // const swimlaneHeights = this.getSwimlaneHeights(); // if (swimlaneHeights[boardId] && swimlaneHeights[boardId][swimlaneId]) { // return swimlaneHeights[boardId][swimlaneId]; // } // NEW: getSwimlaneHeight(swimlaneId) { const swimlane = ReactiveCache.getSwimlane(swimlaneId); return swimlane && swimlane.height ? swimlane.height : -1; }, -
setListWidth(boardId, listId, width) - Update list document
// OLD (removes this): // let currentWidths = this.getListWidths(); // if (!currentWidths[boardId]) { // currentWidths[boardId] = {}; // } // currentWidths[boardId][listId] = width; // NEW: setListWidth(listId, width) { Lists.update(listId, { $set: { width } }); }, -
setSwimlaneHeight(boardId, swimlaneId, height) - Update swimlane document
// OLD (removes this): // let currentHeights = this.getSwimlaneHeights(); // if (!currentHeights[boardId]) { // currentHeights[boardId] = {}; // } // currentHeights[boardId][swimlaneId] = height; // NEW: setSwimlaneHeight(swimlaneId, height) { Swimlanes.update(swimlaneId, { $set: { height } }); },
Keep These Per-User Storage Methods
These should remain in user profile (per-user only):
-
Collapse Swimlanes (per-user)
getCollapsedSwimlanes() { const { collapsedSwimlanes = {} } = this.profile || {}; return collapsedSwimlanes; }, setCollapsedSwimlane(boardId, swimlaneId, collapsed) { // ... update user.profile.collapsedSwimlanes[boardId][swimlaneId] }, isCollapsedSwimlane(boardId, swimlaneId) { // ... check user.profile.collapsedSwimlanes }, -
Collapse Lists (per-user)
getCollapsedLists() { const { collapsedLists = {} } = this.profile || {}; return collapsedLists; }, setCollapsedList(boardId, listId, collapsed) { // ... update user.profile.collapsedLists[boardId][listId] }, isCollapsedList(boardId, listId) { // ... check user.profile.collapsedLists }, -
Hide Minicard Label Text (per-user)
getHideMiniCardLabelText(boardId) { const { hideMiniCardLabelText = {} } = this.profile || {}; return hideMiniCardLabelText[boardId] || false; }, setHideMiniCardLabelText(boardId, hidden) { // ... update user.profile.hideMiniCardLabelText[boardId] },
Remove From User Schema
These fields should be removed from user.profile schema in users.js:
// REMOVE from schema:
'profile.listWidths': { ... }, // Now stored in list.width
'profile.swimlaneHeights': { ... }, // Now stored in swimlane.height
3. Client-Side Changes
Storage Access Layer
When UI needs to get/set widths and heights:
OLD APPROACH (removes this):
// Getting from user profile
const width = Meteor.user().getListWidth(boardId, listId);
// Setting to user profile
Meteor.call('setListWidth', boardId, listId, 300);
NEW APPROACH:
// Getting from list document
const width = Lists.findOne(listId)?.width || 272;
// Setting to list document
Lists.update(listId, { $set: { width: 300 } });
Meteor Methods to Remove
Remove these Meteor methods that updated user profile:
// Remove:
Meteor.methods({
'setListWidth': function(boardId, listId, width) { ... },
'setSwimlaneHeight': function(boardId, swimlaneId, height) { ... },
});
4. Migration Script
Create file: server/migrations/migrateToPerBoardStorage.js
const MIGRATION_NAME = 'migrate-to-per-board-height-width-storage';
Migrations = new Mongo.Collection('migrations');
Meteor.startup(() => {
const existingMigration = Migrations.findOne({ name: MIGRATION_NAME });
if (!existingMigration) {
try {
// Migrate swimlane heights from user.profile to swimlane.height
Meteor.users.find().forEach(user => {
const swimlaneHeights = user.profile?.swimlaneHeights || {};
Object.keys(swimlaneHeights).forEach(boardId => {
Object.keys(swimlaneHeights[boardId]).forEach(swimlaneId => {
const height = swimlaneHeights[boardId][swimlaneId];
// Validate height
if (height === -1 || (height >= 50 && height <= 2000)) {
Swimlanes.update(
{ _id: swimlaneId, boardId },
{ $set: { height } },
{ multi: false }
);
}
});
});
});
// Migrate list widths from user.profile to list.width
Meteor.users.find().forEach(user => {
const listWidths = user.profile?.listWidths || {};
Object.keys(listWidths).forEach(boardId => {
Object.keys(listWidths[boardId]).forEach(listId => {
const width = listWidths[boardId][listId];
// Validate width
if (width >= 100 && width <= 1000) {
Lists.update(
{ _id: listId, boardId },
{ $set: { width } },
{ multi: false }
);
}
});
});
});
// Record successful migration
Migrations.insert({
name: MIGRATION_NAME,
status: 'completed',
createdAt: new Date(),
migratedSwimlanes: Swimlanes.find({ height: { $exists: true, $ne: -1 } }).count(),
migratedLists: Lists.find({ width: { $exists: true, $ne: 272 } }).count(),
});
console.log('✅ Migration to per-board height/width storage completed');
} catch (error) {
console.error('❌ Migration failed:', error);
Migrations.insert({
name: MIGRATION_NAME,
status: 'failed',
error: error.message,
createdAt: new Date(),
});
}
}
});
5. Testing Checklist
Schema Testing
- Swimlane with height = -1 accepts insert
- Swimlane with height = 100 accepts insert
- Swimlane with height = 25 rejects (< 50)
- Swimlane with height = 3000 rejects (> 2000)
- List with width = 272 accepts insert
- List with width = 50 rejects (< 100)
- List with width = 2000 rejects (> 1000)
Data Persistence Testing
- Resize swimlane → height saved in swimlane document
- Reload page → swimlane height persists
- Different user loads page → sees same height
- Resize list → width saved in list document
- Reload page → list width persists
- Different user loads page → sees same width
Per-User Testing
- User A collapses swimlane → User B sees it expanded
- User A hides labels → User B sees labels
- Reload page → per-user preferences persist for same user
- Different user logs in → doesn't see previous user's preferences
Migration Testing
- Run migration on database with old per-user data
- All swimlane heights migrated to swimlane documents
- All list widths migrated to list documents
- User.profile.swimlaneHeights can be safely removed
- User.profile.listWidths can be safely removed
6. Rollback Plan
If issues occur:
-
Before Migration: Backup MongoDB
mongodump -d wekan -o backup-wekan-before-migration -
If Needed: Restore from backup
mongorestore -d wekan backup-wekan-before-migration/wekan -
Revert Code: Restore previous swimlanes.js, lists.js, users.js
7. Files Modified
| File | Change | Status |
|---|---|---|
| models/swimlanes.js | Add height field | ✅ Done |
| models/lists.js | Add width field | ✅ Done |
| models/users.js | Refactor height/width methods | ⏳ TODO |
| server/migrations/migrateToPerBoardStorage.js | Migration script | ⏳ TODO |
| docs/Security/PerUserDataAudit2025-12-23/DATA_PERSISTENCE_ARCHITECTURE.md | Architecture docs | ✅ Done |
8. Summary of Per-User vs Per-Board Data
✅ Per-Board Data (All Users See Same Value)
- Swimlane height
- List width
- Card position (sort)
- Checklist position (sort)
- ChecklistItem position (sort)
- All titles, colors, descriptions
🔒 Per-User Data (Only That User Sees Their Value)
- Collapse state (swimlane, list, card)
- Hide minicard label text visibility
- Stored in user.profile or cookie
Status: ✅ Architecture and schema changes complete
Next: Refactor user methods and run migration