wekan/docs/Security/PerUserDataAudit2025-12-23/IMPLEMENTATION_GUIDE.md
Lauri Ojansivu a039bb1066
Some checks are pending
Docker / build (push) Waiting to run
Docker Image CI / build (push) Waiting to run
Release Charts / release (push) Waiting to run
Test suite / Meteor tests (push) Waiting to run
Test suite / Coverage report (push) Blocked by required conditions
Per-User and Board-level data save fixes. Part 3.
Thanks to xet7 !
2025-12-23 09:03:41 +02:00

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

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:

  1. 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;
    },
    
  2. 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;
    },
    
  3. 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 } });
    },
    
  4. 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):

  1. 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
    },
    
  2. 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
    },
    
  3. 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:

  1. Before Migration: Backup MongoDB

    mongodump -d wekan -o backup-wekan-before-migration
    
  2. If Needed: Restore from backup

    mongorestore -d wekan backup-wekan-before-migration/wekan
    
  3. 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