# Wekan Data Persistence Architecture - 2025-12-23

**Status**: ✅ Latest Current  
**Updated**: 2025-12-23  
**Scope**: All data persistence related to swimlanes, lists, cards, checklists, checklistItems positioning and user preferences

---

## Executive Summary

Wekan's data persistence architecture distinguishes between:
- **Board-Level Data**: Shared across all users on a board (positions, widths, heights, order)
- **Per-User Data**: Private to each user, not visible to others (collapse state, label visibility)

This document defines the authoritative source of truth for all persistence decisions.

---

## Data Classification Matrix

### ✅ PER-BOARD LEVEL (Shared - Stored in MongoDB Documents)

| Entity | Property | Storage | Format | Scope |
|--------|----------|---------|--------|-------|
| **Swimlane** | Title | MongoDB | String | Board |
| **Swimlane** | Color | MongoDB | String (ALLOWED_COLORS) | Board |
| **Swimlane** | Background | MongoDB | Object {color} | Board |
| **Swimlane** | Height | MongoDB | Number (-1=auto, 50-2000) | Board |
| **Swimlane** | Position/Sort | MongoDB | Number (decimal) | Board |
| **List** | Title | MongoDB | String | Board |
| **List** | Color | MongoDB | String (ALLOWED_COLORS) | Board |
| **List** | Background | MongoDB | Object {color} | Board |
| **List** | Width | MongoDB | Number (100-1000) | Board |
| **List** | Position/Sort | MongoDB | Number (decimal) | Board |
| **List** | WIP Limit | MongoDB | Object {enabled, value, soft} | Board |
| **List** | Starred | MongoDB | Boolean | Board |
| **Card** | Title | MongoDB | String | Board |
| **Card** | Color | MongoDB | String (ALLOWED_COLORS) | Board |
| **Card** | Background | MongoDB | Object {color} | Board |
| **Card** | Description | MongoDB | String | Board |
| **Card** | Position/Sort | MongoDB | Number (decimal) | Board |
| **Card** | ListId | MongoDB | String | Board |
| **Card** | SwimlaneId | MongoDB | String | Board |
| **Checklist** | Title | MongoDB | String | Board |
| **Checklist** | Position/Sort | MongoDB | Number (decimal) | Board |
| **Checklist** | hideCheckedItems | MongoDB | Boolean | Board |
| **Checklist** | hideAllItems | MongoDB | Boolean | Board |
| **ChecklistItem** | Title | MongoDB | String | Board |
| **ChecklistItem** | isFinished | MongoDB | Boolean | Board |
| **ChecklistItem** | Position/Sort | MongoDB | Number (decimal) | Board |

### 🔒 PER-USER ONLY (Private - User Profile or localStorage)

| Entity | Property | Storage | Format | Users |
|--------|----------|---------|--------|-------|
| **User** | Collapsed Swimlanes | User Profile / Cookie | Object {boardId: {swimlaneId: boolean}} | Single |
| **User** | Collapsed Lists | User Profile / Cookie | Object {boardId: {listId: boolean}} | Single |
| **User** | Hide Minicard Label Text | User Profile / localStorage | Object {boardId: boolean} | Single |
| **User** | Collapse Card Details View | Cookie | Boolean | Single |

---

## Implementation Details

### 1. Swimlanes Schema (swimlanes.js)

```javascript
Swimlanes.attachSchema(
  new SimpleSchema({
    title: { type: String },                    // ✅ Per-board
    color: { type: String, optional: true },    // ✅ Per-board (ALLOWED_COLORS)
    // background: { ...color properties... }   // ✅ Per-board (for future use)
    height: {                                   // ✅ Per-board (NEW)
      type: Number,
      optional: true,
      defaultValue: -1,                         // -1 means auto-height
      custom() {
        const h = this.value;
        if (h !== -1 && (h < 50 || h > 2000)) {
          return 'heightOutOfRange';
        }
      },
    },
    sort: { type: Number, decimal: true, optional: true }, // ✅ Per-board
    boardId: { type: String },                  // ✅ Per-board
    archived: { type: Boolean },                // ✅ Per-board
    // NOTE: Collapse state is per-user only, stored in:
    // - User profile: profile.collapsedSwimlanes[boardId][swimlaneId] = boolean
    // - Non-logged-in: Cookie 'wekan-collapsed-swimlanes'
  })
);
```

### 2. Lists Schema (lists.js)

```javascript
Lists.attachSchema(
  new SimpleSchema({
    title: { type: String },                    // ✅ Per-board
    color: { type: String, optional: true },    // ✅ Per-board (ALLOWED_COLORS)
    // background: { ...color properties... }   // ✅ Per-board (for future use)
    width: {                                    // ✅ Per-board (NEW)
      type: Number,
      optional: true,
      defaultValue: 272,                        // default width in pixels
      custom() {
        const w = this.value;
        if (w < 100 || w > 1000) {
          return 'widthOutOfRange';
        }
      },
    },
    sort: { type: Number, decimal: true, optional: true }, // ✅ Per-board
    swimlaneId: { type: String, optional: true }, // ✅ Per-board
    boardId: { type: String },                  // ✅ Per-board
    archived: { type: Boolean },                // ✅ Per-board
    wipLimit: { type: Object, optional: true }, // ✅ Per-board
    starred: { type: Boolean, optional: true }, // ✅ Per-board
    // NOTE: Collapse state is per-user only, stored in:
    // - User profile: profile.collapsedLists[boardId][listId] = boolean
    // - Non-logged-in: Cookie 'wekan-collapsed-lists'
  })
);
```

### 3. Cards Schema (cards.js)

```javascript
Cards.attachSchema(
  new SimpleSchema({
    title: { type: String, optional: true },    // ✅ Per-board
    color: { type: String, optional: true },    // ✅ Per-board (ALLOWED_COLORS)
    // background: { ...color properties... }   // ✅ Per-board (for future use)
    description: { type: String, optional: true }, // ✅ Per-board
    sort: { type: Number, decimal: true, optional: true }, // ✅ Per-board
    swimlaneId: { type: String },              // ✅ Per-board (REQUIRED)
    listId: { type: String, optional: true },  // ✅ Per-board
    boardId: { type: String, optional: true }, // ✅ Per-board
    archived: { type: Boolean },               // ✅ Per-board
    // ... other fields are all per-board
  })
);
```

### 4. Checklists Schema (checklists.js)

```javascript
Checklists.attachSchema(
  new SimpleSchema({
    title: { type: String },                   // ✅ Per-board
    sort: { type: Number, decimal: true },     // ✅ Per-board
    hideCheckedChecklistItems: { type: Boolean, optional: true }, // ✅ Per-board
    hideAllChecklistItems: { type: Boolean, optional: true }, // ✅ Per-board
    cardId: { type: String },                  // ✅ Per-board
  })
);
```

### 5. ChecklistItems Schema (checklistItems.js)

```javascript
ChecklistItems.attachSchema(
  new SimpleSchema({
    title: { type: String },                   // ✅ Per-board
    sort: { type: Number, decimal: true },     // ✅ Per-board
    isFinished: { type: Boolean },             // ✅ Per-board
    checklistId: { type: String },             // ✅ Per-board
    cardId: { type: String },                  // ✅ Per-board
  })
);
```

### 6. User Schema - Per-User Data (users.js)

```javascript
// User.profile structure for per-user data
user.profile = {
  // Collapse states - per-user, per-board
  collapsedSwimlanes: {
    'boardId123': {
      'swimlaneId456': true,   // swimlane is collapsed for this user
      'swimlaneId789': false
    },
    'boardId999': { ... }
  },

  // Collapse states - per-user, per-board
  collapsedLists: {
    'boardId123': {
      'listId456': true,       // list is collapsed for this user
      'listId789': false
    },
    'boardId999': { ... }
  },

  // Label visibility - per-user, per-board
  hideMiniCardLabelText: {
    'boardId123': true,        // hide minicard labels on this board
    'boardId999': false
  }
}
```

---

## Client-Side Storage (Non-Logged-In Users)

For users not logged in, collapse state is persisted via cookies (localStorage alternative):

```javascript
// Cookie: wekan-collapsed-swimlanes
{
  'boardId123': {
    'swimlaneId456': true,
    'swimlaneId789': false
  }
}

// Cookie: wekan-collapsed-lists
{
  'boardId123': {
    'listId456': true,
    'listId789': false
  }
}

// Cookie: wekan-card-collapsed
{
  'state': false  // is card details view collapsed
}

// localStorage: wekan-hide-minicard-label-{boardId}
true or false
```

---

## Data Flow

### ✅ Board-Level Data Flow (Swimlane Height Example)

```
1. User resizes swimlane in UI
2. Client calls: Swimlanes.update(swimlaneId, { $set: { height: 300 } })
3. MongoDB receives update
4. Schema validation: height must be -1 or 50-2000
5. Update stored in swimlanes collection: { _id, title, height: 300, ... }
6. Update reflected in Swimlanes collection reactive
7. All users viewing board see updated height
8. Persists across page reloads
9. Persists across browser restarts
```

### ✅ Per-User Data Flow (Collapse State Example)

```
1. User collapses swimlane in UI
2. Client detects LOGGED-IN or NOT-LOGGED-IN
3. If LOGGED-IN:
   a. Client calls: Meteor.call('setCollapsedSwimlane', boardId, swimlaneId, true)
   b. Server updates user profile: { profile: { collapsedSwimlanes: { ... } } }
   c. Stored in users collection
4. If NOT-LOGGED-IN:
   a. Client writes to cookie: wekan-collapsed-swimlanes
   b. Stored in browser cookies
5. On next page load:
   a. Client reads from profile (logged-in) or cookie (not logged-in)
   b. UI restored to saved state
6. Collapse state NOT visible to other users
```

---

## Validation Rules

### Swimlane Height Validation
- **Allowed Values**: -1 (auto) or 50-2000 pixels
- **Default**: -1 (auto)
- **Trigger**: On insert/update
- **Action**: Reject if invalid

### List Width Validation
- **Allowed Values**: 100-1000 pixels
- **Default**: 272 pixels
- **Trigger**: On insert/update
- **Action**: Reject if invalid

### Collapse State Validation
- **Allowed Values**: true or false
- **Storage**: Only boolean values allowed
- **Trigger**: On read/write to profile
- **Action**: Remove if corrupted

---

## Migration Strategy

### For Existing Installations

1. **Add new fields to schemas**
   - `Swimlanes.height` (default: -1)
   - `Lists.width` (default: 272)

2. **Populate existing data**
   - For swimlanes without height: set to -1 (auto)
   - For lists without width: set to 272 (default)

3. **Remove per-user storage if present**
   - Check user.profile.swimlaneHeights → migrate to swimlane.height
   - Check user.profile.listWidths → migrate to list.width
   - Remove old fields from user profile

4. **Validation migration**
   - Ensure all swimlaneIds are valid (no orphaned data)
   - Ensure all widths/heights are in valid range
   - Clean corrupted per-user data

---

## Security Implications

### Per-User Data (🔒 Private)
- Collapse state is per-user → User A's collapse setting doesn't affect User B's view
- Hide label setting is per-user → User A's label visibility doesn't affect User B
- Stored in user profile → Only accessible to that user
- Cookies for non-logged-in → Stored locally, not transmitted

### Per-Board Data (✅ Shared)
- Heights/widths are shared → All users see same swimlane/list sizes
- Positions are shared → All users see same card order
- Colors are shared → All users see same visual styling
- Stored in MongoDB → All users can query and receive updates

### No Cross-User Leakage
- User A's preferences never stored in User B's profile
- User A's preferences never affect User B's view
- Each user has isolated per-user data space

---

## Testing Checklist

### Per-Board Data Tests
- [ ] Resize swimlane height → all users see change
- [ ] Resize list width → all users see change
- [ ] Move card between lists → all users see change
- [ ] Change card color → all users see change
- [ ] Reload page → changes persist
- [ ] Different browser → changes persist

### Per-User Data Tests
- [ ] User A collapses swimlane → User B sees it expanded
- [ ] User A hides labels → User B sees labels
- [ ] User A scrolls away → User B can collapse same swimlane
- [ ] Logout → cookies maintain collapse state
- [ ] Login as different user → previous collapse state not visible
- [ ] Reload page → collapse state restored for user

### Validation Tests
- [ ] Set swimlane height = 25 → rejected (< 50)
- [ ] Set swimlane height = 3000 → rejected (> 2000)
- [ ] Set list width = 50 → rejected (< 100)
- [ ] Set list width = 2000 → rejected (> 1000)
- [ ] Corrupt localStorage height → cleaned on startup
- [ ] Corrupt user profile height → cleaned on startup

---

## Related Files

| File | Purpose |
|------|---------|
| [models/swimlanes.js](../../../models/swimlanes.js) | Swimlane model with height field |
| [models/lists.js](../../../models/lists.js) | List model with width field |
| [models/cards.js](../../../models/cards.js) | Card model with position tracking |
| [models/checklists.js](../../../models/checklists.js) | Checklist model |
| [models/checklistItems.js](../../../models/checklistItems.js) | ChecklistItem model |
| [models/users.js](../../../models/users.js) | User model with per-user settings |

---

## Glossary

| Term | Definition |
|------|-----------|
| **Per-Board** | Stored in swimlane/list/card document, visible to all users |
| **Per-User** | Stored in user profile/cookie, visible only to that user |
| **Sort** | Decimal number determining visual order of entity |
| **Height** | Pixel measurement of swimlane vertical size |
| **Width** | Pixel measurement of list horizontal size |
| **Collapse** | Hiding swimlane/list/card from view (per-user preference) |
| **Position** | Combination of swimlaneId/listId and sort value |

---

## Change Log

| Date | Change | Impact |
|------|--------|--------|
| 2025-12-23 | Created comprehensive architecture document | Documentation |
| 2025-12-23 | Added height field to Swimlanes | Per-board storage |
| 2025-12-23 | Added width field to Lists | Per-board storage |
| 2025-12-23 | Defined per-user data as collapse + label visibility | Architecture |

---

**Status**: ✅ Complete and Current  
**Next Review**: Upon next architectural change

