Files
wekan/server/lib/syncRuleMoveCommand.js
Lauri Ojansivu acb2c0786a Resolve a rule's later move, sort, move-all and archive on the board its card went to.
As decided on 2026-10-03, after a rule moved its card to another board, a
later action that names the rule's own board works where the card is now,
in the ordinary engine and in durable Sync alike:

- RulesHelper.ruleBoards gives the board the card is on and the board an
  action works on. A move to the top or bottom, a sort and a move-all
  resolve their list and swimlane names there; the write-access check
  covers that board. Before this, a move to "*" after a move to another
  board took the card's new list and swimlane but the old board, leaving
  the card split across two boards.
- The durable move, sort, move-all and archive commands find the card
  through ruleCardNow and save the board it is on as `onBoard`, written
  only when it is not the plan's, so every command saved before still
  validates; the archive's cascade takes the children on that board.
- These actions may now follow a move to another board, so such a plan
  stays durable. An email still may not, nor a second move to yet another
  board.

The archive reservation and follower guards follow the decision; the
cross-board move test now expects sorting and archiving to follow and
refuses an email instead, and a new server test runs the whole chain
durably and with the ordinary engine on a twin card, with the same result.

Thanks to xet7 !
2026-10-03 09:44:45 +03:00

194 lines
12 KiB
JavaScript

'use strict';
// Durable rule moves on the card's own board (maintainer decisions of
// 2026-10-02: "durable moves next", and the rule stage's guard follows a move
// the same rule plan saved). moveCardToTop / moveCardToBottom put the card at
// the top or bottom of the list and swimlane RulesHelper.moveCardTarget
// resolves - its own place, or another list or swimlane of its board. Card.move
// (models/cards.js) then writes:
//
// * the fields that change - listId, swimlaneId, sort - and, when the list
// changes, lastMoveReason '' (a rule gives no reason); listEnteredAt is the
// before-update hook's (models/lib/cardListEntry.js);
// * one `position` History row, from the collection hook
// (server/models/changeHistoryHooks.js, models/lib/timeHistory.js);
// * one legacy UserPositionHistory row, which Ctrl+Z still reads, after
// clearing the actor's redo stack on that board (trackChange);
// * when the list or the swimlane changes, the moveCard activity of the
// update hook (models/cards.js cardMove) - which runs the board's rules for
// the moved card - and the attachments' meta.listId / meta.swimlaneId.
// A move that changes nothing writes nothing, as Card.move returns early.
//
// One saved command per rule invocation carries the place before and after -
// computed once, at capture - and those effects. Replays apply the saved
// command, never the rule's current configuration. A move to ANOTHER board
// also relabels, renumbers and maps custom fields; it is not this command's.
// Pure: tested by tests/syncRuleMoveCommand.test.cjs.
const { EJSON } = require('bson');
const { canonical, sha256 } = require('../../models/lib/changeHistoryIntegrity');
const { validateRulePlan, planId } = require('./syncRulePlan');
const { prepareCardFieldHistory, RULE_CARD_POSITION_FIELDS } = require('./syncHistoryBatch');
const copy = value => EJSON.parse(EJSON.stringify(value), { relaxed: true });
const fail = code => { throw new Error(`sync-rule-move-${code}`); };
const text = value => typeof value === 'string' && value.length > 0;
const RULE_MOVE_ACTIONS = ['moveCardToTop', 'moveCardToBottom'];
// A move that stays on the card's own board (its list may be another one).
function isSameBoardMove(action, boardId) {
return !!action && RULE_MOVE_ACTIONS.includes(action.actionType) && (action.boardId || boardId) === boardId;
}
// The narrower question: a move to the top or bottom of the card's own list
// and swimlane.
function isInPlaceMove(action, boardId) {
return isSameBoardMove(action, boardId) && (!action.listName || action.listName === '*') && action.swimlaneName === '*';
}
// The type durable Sync eligibility checks: a move, copy or link elsewhere is
// not durable, so it is reported under a name no durable set contains.
function durableRuleActionType(action, boardId) {
if (!action) return null;
// A copy and a link are durable on the card's own board only
// (syncRuleCopyCardCommand.js, syncRuleLinkCardCommand.js).
if (['copyCard', 'linkCard'].includes(action.actionType)) {
return (action.boardId || boardId) === boardId ? action.actionType : `${action.actionType}:elsewhere`;
}
// A move-all is durable on the card's own board (syncRuleMoveAllCommand.js).
if (action.actionType === 'moveAllCardsInList') {
return (action.boardId || boardId) === boardId ? action.actionType : `${action.actionType}:elsewhere`;
}
if (!RULE_MOVE_ACTIONS.includes(action.actionType)) return action.actionType;
return isSameBoardMove(action, boardId) ? action.actionType : `${action.actionType}:elsewhere`;
}
const commandId = invocationId => sha256(canonical(['sync-rule-move', invocationId]));
const effectIdFor = id => sha256(canonical(['sync-rule-move-effect', id]));
const legacyIdFor = id => `sync-rule-move-${sha256(canonical([id, 'user-position']))}`;
function identity({ plan, activity, effectId, index }) {
validateRulePlan(plan, activity, effectId);
const invocation = plan.actions[index];
const action = invocation?.action;
if (!Number.isSafeInteger(index) || index < 0 || !isSameBoardMove(action, plan.boardId)) fail('invalid');
return { _id: commandId(invocation.id), version: 1, invocationId: invocation.id, planId: planId(effectId, activity._id),
planHash: sha256(canonical(plan)), actorId: plan.actorId, boardId: plan.boardId, cardId: plan.cardId,
actionType: action.actionType };
}
const placementOf = (base, place) => ({ boardId: base.boardId, swimlaneId: place.swimlaneId, listId: place.listId,
sort: place.sort, lastMoveReason: place.lastMoveReason });
const moved = (before, after) => before.listId !== after.listId || before.swimlaneId !== after.swimlaneId;
const unchanged = (before, after) => !moved(before, after) && before.sort === after.sort;
function legacyRow(base, before, after, createdAt) {
const state = place => ({ boardId: base.boardId, swimlaneId: place.swimlaneId, listId: place.listId, sort: place.sort });
return { _id: legacyIdFor(base._id), userId: base.actorId, boardId: base.boardId, entityType: 'card', entityId: base.cardId,
actionType: 'move', previousState: state(before), newState: state(after),
previousSort: before.sort, previousSwimlaneId: before.swimlaneId, previousListId: before.listId,
previousBoardId: base.boardId, newSort: after.sort, newSwimlaneId: after.swimlaneId, newListId: after.listId,
newBoardId: base.boardId, createdAt: new Date(createdAt), isCheckpoint: false, undone: false };
}
// The update hook's moveCard activity (models/cards.js cardMove).
function moveActivity(base, before, after, titles, createdAt) {
const receiptId = sha256(canonical([base._id, 'activity']));
return { receiptId, activity: { _id: `sync-rule-move-${receiptId}`, userId: base.actorId, oldListId: before.listId,
activityType: 'moveCard', moveReason: after.lastMoveReason || '', listName: titles.listName, listId: after.listId,
boardId: base.boardId, cardId: base.cardId, cardTitle: titles.cardTitle, swimlaneName: titles.swimlaneName,
swimlaneId: after.swimlaneId, oldSwimlaneId: before.swimlaneId,
createdAt: new Date(createdAt), modifiedAt: new Date(createdAt) } };
}
function effectsFor({ base, before, after, titles, createdAt, redoRows }) {
if (unchanged(before, after)) return { history: null, userPosition: null, move: null };
const history = prepareCardFieldHistory({ before: { _id: base.cardId, ...placementOf(base, before) },
after: { _id: base.cardId, ...placementOf(base, after) }, effectId: effectIdFor(base._id), userId: base.actorId,
createdAt, redoRows, fields: RULE_CARD_POSITION_FIELDS });
return { history, userPosition: legacyRow(base, before, after, createdAt),
move: moved(before, after) ? moveActivity(base, before, after, titles, createdAt) : null };
}
// The board the command's card is on: the plan's, or - when an earlier move
// of this same plan took the card to another board (2026-10-03) - that
// board, saved as `onBoard`. A command made on the plan's board has no
// `onBoard`, as every command did before.
const boardOf = command => command.onBoard || command.boardId;
// Capture from the card as it is now. `target` is RulesHelper.moveCardTarget's
// ({ boardId, listId, swimlaneId, sort }); `titles` the destination list's and
// swimlane's titles and the card's (for the moveCard activity); `redoRows` the
// actor's undone rows the move supersedes.
function prepareRuleMoveCommand({ plan, activity, effectId, index, card, target, titles, createdAt, redoRows = [],
cardBoardId = plan?.boardId }) {
const identityBase = identity({ plan, activity, effectId, index });
if (!text(cardBoardId)) fail('card-invalid');
const base = cardBoardId === identityBase.boardId ? identityBase : { ...identityBase, onBoard: cardBoardId };
if (!card || card._id !== base.cardId || card.boardId !== boardOf(base) || !text(card.listId) || !text(card.swimlaneId) ||
!target || !text(target.listId) || !text(target.swimlaneId) || !Number.isFinite(target.sort) ||
!(createdAt instanceof Date) || !Number.isFinite(createdAt.getTime())) fail('card-invalid');
if (target.boardId !== boardOf(base)) fail('elsewhere');
const reason = typeof card.lastMoveReason === 'string' ? card.lastMoveReason : '';
const before = { listId: card.listId, swimlaneId: card.swimlaneId, sort: Number.isFinite(card.sort) ? card.sort : null,
lastMoveReason: reason };
// Card.move sets lastMoveReason only when the list (or board) changes.
const after = { listId: target.listId, swimlaneId: target.swimlaneId, sort: target.sort,
lastMoveReason: target.listId !== card.listId ? '' : reason };
const savedTitles = { listName: String(titles?.listName ?? ''), swimlaneName: String(titles?.swimlaneName ?? ''),
cardTitle: String(titles?.cardTitle ?? card.title ?? '') };
const command = { ...base, before, after, titles: savedTitles, createdAt: new Date(createdAt),
effects: effectsFor({ base: { ...base, boardId: boardOf(base) }, before, after, titles: savedTitles, createdAt, redoRows }) };
command.checksum = sha256(canonical(command));
return validateRuleMoveCommand(command, { plan, activity, effectId, index });
}
function validPlace(place) {
return !!place && Object.keys(place).sort().join(',') === 'lastMoveReason,listId,sort,swimlaneId' &&
text(place.listId) && text(place.swimlaneId) && (place.sort === null || Number.isFinite(place.sort)) &&
typeof place.lastMoveReason === 'string';
}
function validateRuleMoveCommand(row, context) {
const base = identity(context);
const elsewhere = !!row && Object.hasOwn(row, 'onBoard');
const keys = [...Object.keys(base), ...(elsewhere ? ['onBoard'] : []), 'before', 'after', 'titles', 'createdAt', 'effects', 'checksum']
.sort().join(',');
if (!row || Object.keys(row).sort().join(',') !== keys || (elsewhere && (!text(row.onBoard) || row.onBoard === base.boardId)) ||
Object.entries(base).some(([key, value]) => canonical(row[key]) !== canonical(value)) ||
!(row.createdAt instanceof Date) || !validPlace(row.before) || !validPlace(row.after) ||
!Number.isFinite(row.after.sort) || !row.titles || Object.keys(row.titles).sort().join(',') !== 'cardTitle,listName,swimlaneName' ||
Object.values(row.titles).some(value => typeof value !== 'string') ||
!row.effects || Object.keys(row.effects).sort().join(',') !== 'history,move,userPosition') fail('command-invalid');
const { checksum, ...content } = row;
if (checksum !== sha256(canonical(content))) fail('command-invalid');
// The saved reason is Card.move's, and the saved effects are what that move
// writes (the redo targets are the capture's, checked when they are written).
const reasonOk = row.after.listId !== row.before.listId ? row.after.lastMoveReason === ''
: row.after.lastMoveReason === row.before.lastMoveReason;
const expected = effectsFor({ base: { ...base, boardId: boardOf(row) }, before: row.before, after: row.after, titles: row.titles,
createdAt: row.createdAt, redoRows: [] });
if (expected.history) expected.history.redo = row.effects.history ? row.effects.history.redo : null;
if (!reasonOk || canonical(expected) !== canonical(row.effects)) fail('command-invalid');
return copy(row);
}
// The card at a saved place: a card with no usable sort was captured as null,
// which matches a missing or null sort.
function placeSelector(command, place) {
return { _id: command.cardId, boardId: boardOf(command), listId: place.listId, swimlaneId: place.swimlaneId,
sort: place.sort === null ? null : { $eq: place.sort } };
}
// The update Card.move makes: only what changes.
function moveModifier(command) {
const { before, after } = command;
const set = {};
if (after.listId !== before.listId) { set.listId = after.listId; set.lastMoveReason = after.lastMoveReason; }
if (after.swimlaneId !== before.swimlaneId) set.swimlaneId = after.swimlaneId;
if (after.sort !== before.sort) set.sort = after.sort;
return { $set: set };
}
// For a move-all (syncRuleMoveAllCommand.js): the same effects and update for
// each card it moves, `base` naming that card's unit ({ _id, actorId, boardId,
// cardId }).
module.exports = { RULE_MOVE_ACTIONS, isSameBoardMove, isInPlaceMove, durableRuleActionType, commandId, effectIdFor,
legacyIdFor, prepareRuleMoveCommand, validateRuleMoveCommand, placeSelector, moveModifier, effectsFor, validPlace, boardOf };