mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 02:52:33 -05:00
Since #1759 a card copied or moved to another board brings its labels: a label that board lacks is created there when the actor is its admin. The stored (durable) rule copyCard, move to another board and move-all onto another board still matched by name only and dropped the rest, so the durable copy or move was no longer the ordinary one - the parity three server tests check - and the card lost a label a direct copy would keep. The durable plans now use the same planCrossBoardLabels with the rule's actor. The labels to create are saved in the command (copy) or move (single move, and each move-all unit), so a replay creates nothing twice: each is pushed only when no label with its id is on that board yet, before the card that names it is written. In a move-all, a label created for one card is that board's label for the next, as when Card.move moves them one by one. Commands saved before this, without the labels, still validate. The server tests now expect the created label, once across a replay, and the ordinary copy or move finding it; the card-move test gains the negative of a mover who is not the new board's admin, where the label is dropped and its activity removed. The command tests cover the new field, its validation and an older command without it. Thanks to xet7 !
345 lines
22 KiB
JavaScript
345 lines
22 KiB
JavaScript
'use strict';
|
|
// Durable rule moves to ANOTHER board (maintainer decision of 2026-10-02: a
|
|
// rule effect on another board is durable only when that board opted into
|
|
// Sync effects too). moveCardToTop / moveCardToBottom with the action's board
|
|
// set to another board: RulesHelper.moveCardTarget resolves the place there,
|
|
// and Card.move (models/cards.js) then writes, in one update,
|
|
//
|
|
// * boardId, swimlaneId, listId, sort, and lastMoveReason '' (a rule gives no
|
|
// reason); listEnteredAt is the before-update hook's;
|
|
// * labelIds - the new board's labels with the names of the card's labels;
|
|
// * cardNumber - the new board's next number;
|
|
// * customFields - mapped to the new board (mapCustomFieldsToBoard, which
|
|
// shares a definition the new board lacks);
|
|
// * members and watchers - only the new board's active members, written only
|
|
// when someone is dropped;
|
|
// * cardDependencies - [] (dependencies only connect cards on one board);
|
|
//
|
|
// and its hooks write: one `position` History row and one row per changed
|
|
// group among labels, members, custom fields and dependencies
|
|
// (server/models/changeHistoryHooks.js); the moveCardBoard activity (cardMove);
|
|
// a set/unsetCustomField activity per custom field whose id changed
|
|
// (cardCustomFields); the card's addedLabel activities re-pointed at the new
|
|
// labels by position, or removed (updateActivities); the checklists' and items'
|
|
// boardId; the inbound dependencies of the cards left behind; the attachments'
|
|
// placement; and Card.move's legacy UserPositionHistory row.
|
|
//
|
|
// One saved command per rule invocation holds all of that, decided at capture.
|
|
// The runner applies the update conditionally with the hooks' records
|
|
// deferred, then writes each record from the command, idempotently.
|
|
//
|
|
// The plan's later actions act on the card on its new board, as the ordinary
|
|
// engine's do (2026-10-03, storedRulePlans.js ruleCardNow); eligibility
|
|
// (server/lib/listSyncSteps.js) lifts such a move only when every action that
|
|
// can follow it is one that follows the card. Pure: tested by
|
|
// tests/syncRuleMoveBoardCommand.test.cjs.
|
|
const { EJSON } = require('bson');
|
|
const { canonical, sha256 } = require('../../models/lib/changeHistoryIntegrity');
|
|
const { validateRulePlan, planId } = require('./syncRulePlan');
|
|
const { prepareCardFieldHistory, RULE_CARD_POSITION_FIELDS, RULE_CARD_MOVE_BOARD_FIELDS } = require('./syncHistoryBatch');
|
|
const copy = value => EJSON.parse(EJSON.stringify(value), { relaxed: true });
|
|
const fail = code => { throw new Error(`sync-rule-move-board-${code}`); };
|
|
const text = value => typeof value === 'string' && value.length > 0;
|
|
|
|
const RULE_MOVE_ACTIONS = ['moveCardToTop', 'moveCardToBottom'];
|
|
// The card fields a move to another board may change besides its place.
|
|
const MOVED_FIELDS = ['labelIds', 'cardNumber', 'customFields', 'members', 'watchers', 'cardDependencies', 'scrum',
|
|
'scrumRevision'];
|
|
const { movedScrumMetadata } = require('../../models/lib/scrumCopy');
|
|
|
|
const commandId = invocationId => sha256(canonical(['sync-rule-move-board', invocationId]));
|
|
const effectIdFor = (id, part) => sha256(canonical(['sync-rule-move-board-effect', id, part]));
|
|
const legacyIdFor = id => `sync-rule-move-board-${sha256(canonical([id, 'user-position']))}`;
|
|
|
|
function isOtherBoardMove(action, boardId) {
|
|
return !!action && RULE_MOVE_ACTIONS.includes(action.actionType) && text(action.boardId) && action.boardId !== boardId;
|
|
}
|
|
|
|
function identity({ plan, activity, effectId, index }) {
|
|
validateRulePlan(plan, activity, effectId);
|
|
const invocation = plan.actions[index];
|
|
const action = invocation?.action;
|
|
// Later actions of the plan follow the card (2026-10-03); eligibility lets
|
|
// only those that can follow it into a plan with such a move.
|
|
if (!Number.isSafeInteger(index) || index < 0 || !isOtherBoardMove(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, targetBoardId: action.boardId };
|
|
}
|
|
|
|
// The fields among MOVED_FIELDS the card has (absent ones stay absent).
|
|
function presentFields(card) {
|
|
return Object.fromEntries(MOVED_FIELDS.filter(field => card[field] !== undefined).map(field => [field, copy(card[field])]));
|
|
}
|
|
|
|
// Card.move's members and watchers: only the new board's active members, and
|
|
// only written when someone is dropped.
|
|
function filtered(list, allowed) {
|
|
const current = Array.isArray(list) ? list : [];
|
|
const kept = current.filter(id => allowed.includes(id));
|
|
return kept.length === current.length ? undefined : kept;
|
|
}
|
|
|
|
// updateActivities (models/cards.js), once: each addedLabel activity of the
|
|
// card is re-pointed at the new label at the same position, or removed.
|
|
function labelActivityRewrites(activities, oldLabelIds, newLabelIds, boardId) {
|
|
return activities.map(({ _id, labelId }) => {
|
|
const at = (oldLabelIds || []).indexOf(labelId);
|
|
return at !== -1 && newLabelIds.length > at ? { _id, labelId: newLabelIds[at], boardId } : { _id, remove: true };
|
|
});
|
|
}
|
|
|
|
// cardCustomFields (models/cards.js): one activity per custom field id whose
|
|
// value differs, compared by id.
|
|
function customFieldActivities(base, before, after, place, createdAt) {
|
|
const values = list => new Map((list || []).filter(field => field && typeof field._id === 'string')
|
|
.map(field => [field._id, field.value ?? null]));
|
|
const was = values(before), is = values(after), rows = [];
|
|
for (const customFieldId of new Set([...was.keys(), ...is.keys()])) {
|
|
const value = is.get(customFieldId) ?? null;
|
|
if (canonical(was.get(customFieldId) ?? null) === canonical(value)) continue;
|
|
const receiptId = sha256(canonical([base._id, 'custom-field', customFieldId]));
|
|
rows.push({ receiptId, activity: { _id: `sync-rule-move-board-${receiptId}`, userId: base.actorId, customFieldId,
|
|
...(value === null ? {} : { value }), activityType: value === null ? 'unsetCustomField' : 'setCustomField',
|
|
boardId: base.targetBoardId, cardId: base.cardId, listId: place.listId, swimlaneId: place.swimlaneId,
|
|
createdAt: new Date(createdAt), modifiedAt: new Date(createdAt) } });
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function effectsFor({ base, before, after, titles, labelActivities, createdAt, redoRows }) {
|
|
const position = prepareCardFieldHistory({
|
|
before: { _id: base.cardId, ...before.place }, after: { _id: base.cardId, ...after.place },
|
|
effectId: effectIdFor(base._id, 'position'), userId: base.actorId, createdAt, redoRows,
|
|
fields: RULE_CARD_POSITION_FIELDS });
|
|
const fields = prepareCardFieldHistory({
|
|
before: { _id: base.cardId, ...before.place, ...before.fields }, after: { _id: base.cardId, ...after.place, ...after.fields },
|
|
effectId: effectIdFor(base._id, 'fields'), userId: base.actorId, createdAt, redoRows: [],
|
|
fields: RULE_CARD_MOVE_BOARD_FIELDS });
|
|
const state = place => ({ boardId: place.boardId, swimlaneId: place.swimlaneId, listId: place.listId, sort: place.sort });
|
|
const userPosition = { _id: legacyIdFor(base._id), userId: base.actorId, boardId: base.boardId, entityType: 'card',
|
|
entityId: base.cardId, actionType: 'move', previousState: state(before.place), newState: state(after.place),
|
|
previousSort: before.place.sort, previousSwimlaneId: before.place.swimlaneId, previousListId: before.place.listId,
|
|
previousBoardId: base.boardId, newSort: after.place.sort, newSwimlaneId: after.place.swimlaneId,
|
|
newListId: after.place.listId, newBoardId: base.targetBoardId, createdAt: new Date(createdAt), isCheckpoint: false,
|
|
undone: false };
|
|
const receiptId = sha256(canonical([base._id, 'activity']));
|
|
// cardMove's moveCardBoard activity. It names no list; durable delivery takes
|
|
// such board-level activities (2026-10-03), and no rule trigger exists for it
|
|
// (server/triggersDef.js).
|
|
const move = { _id: `sync-rule-move-board-${receiptId}`, userId: base.actorId, activityType: 'moveCardBoard',
|
|
moveReason: '', boardName: titles.boardName, boardId: base.targetBoardId, oldBoardId: base.boardId,
|
|
oldBoardName: titles.oldBoardName, cardId: base.cardId, swimlaneName: titles.swimlaneName,
|
|
swimlaneId: after.place.swimlaneId, oldSwimlaneId: before.place.swimlaneId,
|
|
createdAt: new Date(createdAt), modifiedAt: new Date(createdAt) };
|
|
return { history: [position, fields], userPosition, move,
|
|
customFields: customFieldActivities(base, before.fields.customFields, after.fields.customFields, after.place, createdAt),
|
|
labelActivities: labelActivityRewrites(labelActivities, before.fields.labelIds, after.fields.labelIds,
|
|
base.targetBoardId) };
|
|
}
|
|
|
|
// One card's move, as Card.move makes it. `base` names it: { _id, actorId,
|
|
// boardId, targetBoardId, cardId } - the command's, or a move-all unit's.
|
|
// card - the card (raw document) on the board it leaves;
|
|
// target - { boardId, listId, swimlaneId, sort } (sort null: its own);
|
|
// mapped - { labelIds, cardNumber, customFields } for the target board,
|
|
// and createLabels: the labels it lacks, created there first
|
|
// (#1759; only when there are any, so an older move reads the same);
|
|
// allowedMemberIds - the target board's active members;
|
|
// titles - { boardName, oldBoardName, swimlaneName } for the activity;
|
|
// labelActivities - the card's addedLabel activities, { _id, labelId };
|
|
// redoRows - the actor's undone History rows on the target board.
|
|
function buildMove({ base, card, target, mapped, allowedMemberIds, titles, labelActivities = [], createdAt,
|
|
redoRows = [] }) {
|
|
if (!card || card._id !== base.cardId || card.boardId !== base.boardId || !text(card.listId) || !text(card.swimlaneId) ||
|
|
!target || target.boardId !== base.targetBoardId || !text(target.listId) || !text(target.swimlaneId) ||
|
|
!(target.sort === null || Number.isFinite(target.sort)) || !mapped || !Array.isArray(mapped.labelIds) ||
|
|
!Array.isArray(mapped.customFields) || !Number.isSafeInteger(mapped.cardNumber) ||
|
|
!Array.isArray(allowedMemberIds) || !titles ||
|
|
!Array.isArray(labelActivities) || labelActivities.some(row => !row || !text(row._id)) ||
|
|
!(createdAt instanceof Date) || !Number.isFinite(createdAt.getTime())) fail('invalid');
|
|
const sort = Number.isFinite(card.sort) ? card.sort : null;
|
|
const before = { place: { boardId: base.boardId, listId: card.listId, swimlaneId: card.swimlaneId, sort,
|
|
lastMoveReason: typeof card.lastMoveReason === 'string' ? card.lastMoveReason : '' },
|
|
fields: presentFields(card) };
|
|
const fields = { labelIds: copy(mapped.labelIds), cardNumber: mapped.cardNumber, customFields: copy(mapped.customFields),
|
|
cardDependencies: [], ...copy(movedScrumMetadata(card, base.targetBoardId, mapped.scrumPlanning || null)) };
|
|
for (const field of ['members', 'watchers']) {
|
|
const kept = filtered(card[field], allowedMemberIds);
|
|
if (kept !== undefined) fields[field] = kept;
|
|
else if (card[field] !== undefined) fields[field] = copy(card[field]);
|
|
}
|
|
// Card.move without a sort keeps the card's own.
|
|
const after = { place: { boardId: base.targetBoardId, listId: target.listId, swimlaneId: target.swimlaneId,
|
|
sort: target.sort === null ? sort : target.sort, lastMoveReason: '' }, fields };
|
|
const savedTitles = { boardName: String(titles.boardName ?? ''), oldBoardName: String(titles.oldBoardName ?? ''),
|
|
swimlaneName: String(titles.swimlaneName ?? '') };
|
|
const savedLabels = labelActivities.map(row => ({ _id: row._id, labelId: row.labelId ?? null }));
|
|
const createLabels = Array.isArray(mapped.createLabels)
|
|
? mapped.createLabels.map(label => ({ _id: label?._id, name: label?.name, color: label?.color })) : [];
|
|
return { before, after, titles: savedTitles, labelActivities: savedLabels,
|
|
...(createLabels.length ? { createLabels } : {}),
|
|
effects: effectsFor({ base, before, after, titles: savedTitles, labelActivities: savedLabels, createdAt, redoRows }) };
|
|
}
|
|
|
|
function validPlace(place, boardId) {
|
|
return !!place && Object.keys(place).sort().join(',') === 'boardId,lastMoveReason,listId,sort,swimlaneId' &&
|
|
place.boardId === boardId && text(place.listId) && text(place.swimlaneId) &&
|
|
(place.sort === null || Number.isFinite(place.sort)) && typeof place.lastMoveReason === 'string';
|
|
}
|
|
|
|
// A saved move: its places and fields, and the effects they make.
|
|
function validMove(move, base, createdAt) {
|
|
if (!move.before || !move.after ||
|
|
!validPlace(move.before.place, base.boardId) || !validPlace(move.after.place, base.targetBoardId) ||
|
|
move.after.place.lastMoveReason !== '' ||
|
|
[move.before.fields, move.after.fields].some(fields => !fields ||
|
|
Object.keys(fields).some(key => !MOVED_FIELDS.includes(key))) ||
|
|
!Array.isArray(move.after.fields.labelIds) || !Array.isArray(move.after.fields.customFields) ||
|
|
!Number.isSafeInteger(move.after.fields.cardNumber) || canonical(move.after.fields.cardDependencies) !== canonical([]) ||
|
|
!move.titles || Object.keys(move.titles).sort().join(',') !== 'boardName,oldBoardName,swimlaneName' ||
|
|
!Array.isArray(move.labelActivities) ||
|
|
// #1759's labels to create: named, and each one the moved card carries.
|
|
(Object.hasOwn(move, 'createLabels') && (!Array.isArray(move.createLabels) || !move.createLabels.length ||
|
|
move.createLabels.some(label => !label || !text(label._id) || typeof label.name !== 'string' || !label.name ||
|
|
!move.after.fields.labelIds.includes(label._id))))) return false;
|
|
// The saved effects are what this move writes (the redo targets are the
|
|
// capture's, checked when they are written).
|
|
const expected = effectsFor({ base, before: move.before, after: move.after, titles: move.titles,
|
|
labelActivities: move.labelActivities, createdAt, redoRows: [] });
|
|
expected.history[0].redo = move.effects?.history?.[0]?.redo ?? null;
|
|
return canonical(expected) === canonical(move.effects);
|
|
}
|
|
|
|
// The board a single move takes the card from: the plan's, or - when an
|
|
// earlier move of this same plan already took it to another board
|
|
// (2026-10-03) - that board, saved as `fromBoard`. A command from the plan's
|
|
// board has none, as before; a move onto the board the card is on is refused.
|
|
const fromBoardOf = command => command.fromBoard || command.boardId;
|
|
const moveBaseOf = (base, row) => (row && row.fromBoard ? { ...base, boardId: row.fromBoard } : base);
|
|
|
|
// Capture of a single move: `target` is RulesHelper.moveCardTarget's.
|
|
function prepareRuleMoveBoardCommand({ plan, activity, effectId, index, target, createdAt, fromBoardId = plan?.boardId, ...rest }) {
|
|
const base = identity({ plan, activity, effectId, index });
|
|
if (!target || !Number.isFinite(target.sort) || !text(fromBoardId) || fromBoardId === base.targetBoardId) fail('invalid');
|
|
const moved = fromBoardId === base.boardId ? {} : { fromBoard: fromBoardId };
|
|
const command = { ...base, ...moved, ...buildMove({ base: moveBaseOf(base, moved), target, createdAt, ...rest }),
|
|
createdAt: new Date(createdAt) };
|
|
command.checksum = sha256(canonical(command));
|
|
return validateRuleMoveBoardCommand(command, { plan, activity, effectId, index });
|
|
}
|
|
|
|
function validateRuleMoveBoardCommand(row, context) {
|
|
const base = identity(context);
|
|
const elsewhere = !!row && Object.hasOwn(row, 'fromBoard');
|
|
const keys = [...Object.keys(base), ...(elsewhere ? ['fromBoard'] : []), 'before', 'after', 'titles', 'labelActivities',
|
|
...(row && Object.hasOwn(row, 'createLabels') ? ['createLabels'] : []),
|
|
'createdAt', 'effects', 'checksum'].sort().join(',');
|
|
if (!row || Object.keys(row).sort().join(',') !== keys ||
|
|
(elsewhere && (!text(row.fromBoard) || row.fromBoard === base.boardId || row.fromBoard === base.targetBoardId)) ||
|
|
Object.entries(base).some(([key, value]) => canonical(row[key]) !== canonical(value)) ||
|
|
!(row.createdAt instanceof Date) || !Number.isFinite(row.after?.place?.sort)) fail('command-invalid');
|
|
const { checksum, ...content } = row;
|
|
if (checksum !== sha256(canonical(content)) || !validMove(row, moveBaseOf(base, row), row.createdAt)) fail('command-invalid');
|
|
return copy(row);
|
|
}
|
|
|
|
// moveAllCardsInList onto another board (server/rulesHelper.js): every card
|
|
// of the list named fromListName on the plan's board goes to the list named
|
|
// listName there, each through Card.move with its own swimlane and no sort -
|
|
// so it keeps its sort, and the update's consistency hook
|
|
// (server/lib/cardBoardConsistency.js) puts it in that board's default
|
|
// swimlane, its own being on the board it leaves. One unit per card, each a
|
|
// whole move with its own records; the rule's own card may be among them, and
|
|
// later actions follow it like a single move's.
|
|
const moveAllCommandId = invocationId => sha256(canonical(['sync-rule-move-all-board', invocationId]));
|
|
const unitIdFor = (id, cardId) => sha256(canonical([id, 'unit', cardId]));
|
|
function isOtherBoardMoveAll(action, boardId) {
|
|
return !!action && action.actionType === 'moveAllCardsInList' && text(action.boardId) && action.boardId !== boardId;
|
|
}
|
|
function moveAllIdentity({ plan, activity, effectId, index }) {
|
|
validateRulePlan(plan, activity, effectId);
|
|
const invocation = plan.actions[index];
|
|
const action = invocation?.action;
|
|
if (!Number.isSafeInteger(index) || index < 0 || !isOtherBoardMoveAll(action, plan.boardId)) fail('invalid');
|
|
return { _id: moveAllCommandId(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: 'moveAllCardsInList', targetBoardId: action.boardId };
|
|
}
|
|
// Each unit leaves the board the list is on: the plan's, or - when an
|
|
// earlier move of this same plan took the rule's card to another board
|
|
// (2026-10-03) - that board, saved as `fromBoard` as a single move saves it.
|
|
const unitBase = (base, cardId) => ({ _id: unitIdFor(base._id, cardId), actorId: base.actorId,
|
|
boardId: base.fromBoard || base.boardId, targetBoardId: base.targetBoardId, cardId });
|
|
|
|
// Capture: `moves` are one { card, target, mapped, allowedMemberIds, titles,
|
|
// labelActivities } per card of the list, in order; none when the ordinary
|
|
// action would move nothing (no list to move from or to).
|
|
function prepareRuleMoveAllBoardCommand({ plan, activity, effectId, index, moves = [], createdAt, redoRows = [],
|
|
fromBoardId = plan?.boardId }) {
|
|
const identityBase = moveAllIdentity({ plan, activity, effectId, index });
|
|
if (!Array.isArray(moves) || moves.length > 10000 || !(createdAt instanceof Date) || !text(fromBoardId) ||
|
|
fromBoardId === identityBase.targetBoardId) fail('invalid');
|
|
const base = fromBoardId === identityBase.boardId ? identityBase : { ...identityBase, fromBoard: fromBoardId };
|
|
const units = moves.map((move, i) => ({ cardId: move.card?._id,
|
|
...buildMove({ base: unitBase(base, move.card?._id), createdAt, ...move, redoRows: i === 0 ? redoRows : [] }) }));
|
|
const command = { ...base, createdAt: new Date(createdAt), units };
|
|
command.checksum = sha256(canonical(command));
|
|
return validateRuleMoveAllBoardCommand(command, { plan, activity, effectId, index });
|
|
}
|
|
|
|
function validateRuleMoveAllBoardCommand(row, context) {
|
|
const identityBase = moveAllIdentity(context);
|
|
const elsewhere = !!row && Object.hasOwn(row, 'fromBoard');
|
|
const base = elsewhere ? { ...identityBase, fromBoard: row.fromBoard } : identityBase;
|
|
const keys = [...Object.keys(base), 'createdAt', 'units', 'checksum'].sort().join(',');
|
|
if (!row || Object.keys(row).sort().join(',') !== keys ||
|
|
(elsewhere && (!text(row.fromBoard) || row.fromBoard === identityBase.boardId || row.fromBoard === identityBase.targetBoardId)) ||
|
|
Object.entries(base).some(([key, value]) => canonical(row[key]) !== canonical(value)) ||
|
|
!(row.createdAt instanceof Date) || !Array.isArray(row.units) || row.units.length > 10000) fail('command-invalid');
|
|
const { checksum, ...content } = row;
|
|
if (checksum !== sha256(canonical(content))) fail('command-invalid');
|
|
const seen = new Set();
|
|
for (const unit of row.units) {
|
|
if (!unit || Object.keys(unit).filter(key => key !== 'createLabels').sort().join(',') !==
|
|
'after,before,cardId,effects,labelActivities,titles' ||
|
|
!text(unit.cardId) || seen.has(unit.cardId) || unit.after.place.sort !== unit.before.place.sort ||
|
|
!validMove(unit, unitBase(base, unit.cardId), row.createdAt)) fail('command-invalid');
|
|
seen.add(unit.cardId);
|
|
}
|
|
return copy(row);
|
|
}
|
|
|
|
// A unit as the selectors and modifier below read a command.
|
|
const unitMove = (command, unit) => ({ ...unit, actorId: command.actorId, boardId: command.fromBoard || command.boardId });
|
|
|
|
const placeOf = (command, place) => ({ _id: command.cardId, boardId: place.boardId, listId: place.listId,
|
|
swimlaneId: place.swimlaneId, sort: place.sort === null ? null : { $eq: place.sort } });
|
|
// The card as captured: its place and the fields the move maps, exactly as
|
|
// they were read (an absent field matching only an absent one), so a card
|
|
// changed since capture is not moved with a stale mapping.
|
|
function beforeSelector(command) {
|
|
const selector = placeOf(command, command.before.place);
|
|
for (const field of MOVED_FIELDS) {
|
|
const fields = command.before.fields;
|
|
selector[field] = Object.hasOwn(fields, field) ? { $eq: fields[field] } : { $exists: false };
|
|
}
|
|
return selector;
|
|
}
|
|
// The card moved: at its new place under the number taken for it. The mapped
|
|
// arrays are not compared - the schema may normalise them on write.
|
|
function afterSelector(command) {
|
|
return { ...placeOf(command, command.after.place), cardNumber: command.after.fields.cardNumber };
|
|
}
|
|
// Card.move's update: the place, the reason and every moved field.
|
|
function moveModifier(command) {
|
|
const { place, fields } = command.after;
|
|
return { $set: { boardId: place.boardId, swimlaneId: place.swimlaneId, listId: place.listId,
|
|
...(place.sort === null ? {} : { sort: place.sort }), lastMoveReason: '', ...copy(fields) } };
|
|
}
|
|
|
|
module.exports = { RULE_MOVE_ACTIONS, MOVED_FIELDS, commandId, legacyIdFor, isOtherBoardMove, labelActivityRewrites,
|
|
prepareRuleMoveBoardCommand, validateRuleMoveBoardCommand, beforeSelector, afterSelector, moveModifier, fromBoardOf,
|
|
moveAllCommandId, unitIdFor, isOtherBoardMoveAll, prepareRuleMoveAllBoardCommand, validateRuleMoveAllBoardCommand,
|
|
unitMove };
|