docs/Features/Reports/History/History.md
Status: Draft for approval · Owner: xet7 · Related: card details view, Member settings,
Activities, userPositionHistory, docs/Features/Undo/Undo.md
This document specifies one unified change-history subsystem that records every change a user
makes, keeps it append-only, and lets changes be restored. It is surfaced from many menus, but every one is the same table + restore over the same
store, differing only by the scope filter it passes to changeHistory.page. A History
option is added to each of these menus (and the pattern generalises to any future entity menu — the
UI and method don't change, only the scope):
| Menu / location | View shows the history of… | Scope filter | Section |
|---|---|---|---|
| Card group menu (open card) | that group on that card, with a per-contributor avatar list | { cardId, group } | 7 |
| Card menu (open card) | the whole card (all its groups) | { cardId } | 7a |
| Member settings menu | that user's own changes | { userId } | 7a |
| Board Settings | the whole board (all users/entities) | { boardId } | 7a |
| Swimlane menu | that swimlane and its contents | { scope:'swimlane', scopeId } | 7a |
| List menu | that list and its cards | { scope:'list', scopeId } | 7a |
| (future) Checklist menu, Attachment menu, … | that entity | { entityType, entityId } | 7a |
Scopes nest: a card's history ⊂ its list's ⊂ its swimlane's ⊂ the board's. Container scopes
(board/swimlane/list/card) therefore mean "this entity and its descendants" — implemented as an
OR over the relevant id columns (boardId / swimlaneId / listId / cardId / entityId), which
is why the write side (section 5) stores all the applicable id columns on every row.
Plus one keyboard front-end:
Ctrl+Z / Ctrl+Y = "restore the current user's most recent change from this
history" / "re-apply it". Undo/Redo is therefore not a separate feature; it is the keyboard
restore of the newest own change. (v1 undo/redo of position moves already shipped in #6478 via
userPositionHistory; this design generalises that to every change and merges the two.)It is a design doc only — no code is implied as final until this is approved.
Scope change (this revision): the earlier draft covered only card groups. Per request, the model now records every change (all card fields/groups and board/list/swimlane/etc. structural changes), per user, and adds the Member-settings per-user view.
userPositionHistorybecomes a special case that this unified store supersedes.
On the card open-details view, each group menu gains an option History. Clicking it opens a big popup:
WeKan already logs actions in the Activities collection, but that collection records what
happened (an activityType plus references: cardId, listId, memberId, …) — not the
before/after values. Example: editing a description logs activityType: 'changedDescription'
with the card id, but not the previous text.
Consequences:
Activities.Activities, because the previous content is not
stored. Only userPositionHistory stores before/after (for positions).Therefore the feature splits into two efforts:
"Group" maps to the sections of the card details view. Proposed coverage (phase order later):
| Group | Entity / field | Change types |
|---|---|---|
| Description | cards.description | edited |
| Title | cards.title | edited |
| Labels | cards.labelIds[] | added, removed |
| Members / Assignees | cards.members[], cards.assignees[] | added, removed |
| Dates | received/start/due/end | added, edited, removed |
| Checklists | checklist + items | added, removed, edited, checked/unchecked, moved |
| Subtasks | linked subtask cards | added, removed, moved |
| Attachments | files/avatars | added, removed, renamed |
| Comments | card_comments | added, edited, removed |
| Custom fields | cards.customFields[] | edited |
Change types are a small closed set with i18n keys, e.g. history-change-added,
history-change-removed, history-change-edited, history-change-moved,
history-change-restored. Added to imports/i18n/data/en.i18n.json only (translations follow via
Transifex; see the translation-pull auto-heal note in the changelog).
One new append-only Mongo collection changeHistory (working name) covering every change,
whatever the entity. One document per change:
{
_id,
boardId, // for permission scoping + publications (null for non-board changes, if any)
// What was changed — general, not card-only:
entityType, // 'card' | 'list' | 'swimlane' | 'board' | 'checklist' | 'checklistItem'
// | 'comment' | 'attachment' | 'customField' | ...
entityId, // the changed entity's _id
cardId, // set when the change belongs to a card (drives the card-group view); optional
group, // logical group for the card view: 'description' | 'labels' | 'members'
// | 'dates' | 'checklists' | 'title' | ... (optional for non-card changes)
changeType, // 'added' | 'removed' | 'edited' | 'moved' | 'restored'
// Content for display + restore — structured, not just strings:
previousContent, // blackbox; null for 'added'
newContent, // blackbox; null for 'removed'
userId, // WHO made the change — the axis the Member-settings view filters on
createdAt, // Date; formatted client-side with the viewer's/card's date format
// Undo/redo stack (folds in #6478's userPositionHistory fields):
undone, // Boolean — restored/undone, redoable until superseded
undoneAt, // Date — orders the redo stack
batchId, // groups a multi-entity change (e.g. multi-select move / multi-restore)
// Restore provenance (set only when changeType === 'restored'):
restoredFromId, // the changeHistory _id whose content was restored
restoredByUserId, // who performed the restore
}
Notes:
undone/undoneAt flip for
the undo/redo stack). Retention cap via a server cron (à la userPositionHistory.cleanup).previousContent/newContent are blackbox so each entity/group stores what it needs
({ text } for description, { labelId } for a label, { millis } for a date, { sort, swimlaneId, listId, boardId } for a move, …).userPositionHistory. That collection's move rows map 1:1 onto this schema
(entityType card/list/swimlane, changeType: 'moved', previous/new = the position). Migration:
keep userPositionHistory writing during transition, or one-time copy its rows in; the undo/redo
methods move to read changeHistory.Activities with before/after content. Rejected for v1 —
Activities is deliberately schemaless/high-volume and drives notifications/webhooks; overloading
it risks those paths. A dedicated collection keeps concerns separate and independently cappable.Record on the server, in every mutation path (not only card groups), capturing the value before and after. A single helper:
ChangeHistory.record({ boardId, entityType, entityId, cardId?, group?, changeType, previousContent, newContent, userId, batchId? });
called from the existing setters/methods — card fields (Cards.setDescription, title, label
add/remove, member/assignee add/remove, date setters, custom fields), card sub-entities
(checklist/checklist-item, comment, attachment mutations), and structural changes (list/swimlane
create/rename/move/archive, board-level changes). Position moves come in via the same helper
(replacing userPositionHistory.trackChange).
typeof UserPositionHistory !== 'undefined' was false
without an import (fixed in #6478). This is the single most important implementation lesson.Activity; recording history
next to Activities.insert (with the extra before/after content) avoids sprinkling calls
everywhere. Evaluate during phase 1.Only the current page is loaded. One method (not a naive reactive publication of the whole log) serves the card-group view, the per-user Member-settings view, and any filter combination:
Meteor.call('changeHistory.page', {
// scope (any subset; container scopes match the entity AND its descendants):
scope, // 'board' | 'swimlane' | 'list' | 'card' — the container kind
scopeId, // that container's _id (boardId / swimlaneId / listId / cardId)
group, // narrow a card scope to one group (card-group view)
userId, // one contributor — Member view, or an avatar click within another scope
// list controls:
search, // matches changeType label + content text
page, pageSize, // 1-based page, server clamps pageSize
}) -> { rows, total, page, pageSize, contributors: [{ userId, count }] }
The server turns {scope, scopeId} into the id-column filter (board→boardId; swimlane→
swimlaneId OR its lists'/cards' rows; list→listId OR its cards'; card→cardId), then applies
userId/group/search on top. Member-settings view passes just { userId } (optionally
+ scope:'board' to limit to the current board).
requireBoardVisible). The Member-settings view is scoped to boards the caller can see; it
never leaks a user's changes on boards the caller can't access.newContent/previousContent (same cross-environment numeric/text caveat as card search).userId + counts);
unused when the view is already pinned to one userId.models/lib/undoRedoSelection.js): paginate(rows, page, size), matchesSearch(row, term), selectionToIds(selected) — in models/lib/… with tests.Templates (Blaze/jade), all inside one popup opened from the group menu's History item:
cardGroupHistoryPopup — 2-pane layout.
historyNav — a History button (default view = newest, all users) + a list of
+userAvatar (fallback initials) from contributors. Selecting one sets the userId filter.historyTable —
.js-history-search (left) · pagination .js-history-prev/next + "page X / N"
(middle) · .js-history-restore (right).rows[]: input[type=checkbox].js-history-select,
change-type label ({{_ changeTypeKey}}), content, {{ formatWithCardDateFormat createdAt }}.group, userId filter, search, page, and a Set of selected row ids kept in
a ReactiveDict on the template instance (not the data context — see #6479: don't stash state on
Blaze data contexts).dir=rtl + logical CSS (margin-inline, text-align: start, flex order) so the
top bar and columns mirror without duplicated markup. Verify live.Every non-card-group surface in the table above is the same historyTable with a different
changeHistory.page scope; there is one implementation, parametrised by scope. They share the
columns, search, pagination, RTL, and restore (+ dual re-logging) of section 7/8.
{ userId } to the current scope (that user's changes within this scope).Concretely, adding "History" to a new menu = (1) a menu item that opens historyPopup with a scope,
(2) — nothing else. No new method, table, or restore code.
Ctrl+Z / Ctrl+Y are the keyboard front-end to this history for the current user on the current
board:
changeHistory row (mark it
undone), for any entityType/changeType — not just moves.undone rows).This generalises the shipped #6478 methods: userPositionHistory.undoLast/redoLast become
changeHistory.undoLast/redoLast reading the unified store; the selection rule stays the pure,
tested pickUndo/pickRedo; the key bindings in client/lib/keyboard.js are unchanged. "Restore
selected row" (History UI) and "undo last" (keyboard) are the same operation on the same data.
Undo restores content via the same setters as a normal edit (so validation/Activities run), and the restore is itself appended to history (see section 8) — so undo is auditable and itself undoable/redoable.
Meteor.call('cardGroupHistory.restore', historyId):
previousContent (or newContent, per the row's semantics) back to the live group via the
same setters used for normal edits (so validation/activities still run).cardGroupHistory rows with changeType: 'restored' and restoredFromId:
userId of the restored row) — "their" data was
restored,Meteor.userId()) — who restored what to become current.batchId).Per-change-type restore functions live next to each group's setter (description/labels/dates/…), so
each knows how to re-apply its own previousContent.
requireBoardVisible / write access), like
updateListSort and userPositionHistory.*.cardGroupHistory (append-only invariant).userPositionHistory.cleanup pattern).changeHistory model + write helper + pure helpers (paging/search/selection/pick undo-redo) +
unit tests. Migrate the shipped position undo/redo onto it: point
changeHistory.undoLast/redoLast at the new store and record card/list/swimlane moves there
(this both proves the model and keeps #6478 working). Ctrl+Z/Ctrl+Y now read changeHistory.changeHistory.page) + the viewer UI (table, search, pagination, avatars); LTR then RTL.
Ctrl+Z now also undoes a description edit.changeHistory.page method + historyTable UI with a different scope
({ userId } vs { boardId }), so they land together once step 3's table/restore exist.page method + UI.userPositionHistory recording was dead because
trackChange was guarded by typeof … !== 'undefined' without importing the collection (fixed in
#6478). The new history helper must be a real import.ReactiveDict.models/lib/undoRedoSelection.js) so the
logic is unit-testable without the Meteor/Blaze runtime.