mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 02:52:33 -05:00
The ordinary rule engine reads the card by id for every action, so the actions after a move to another board act on the card there. The durable commands that can do the same now find the card where a saved move of the same plan put it, and record their writes, History rows and activities on that board: card fields, checklist toggles, adding and removing checklists, linking, and the title variables of a new card or swimlane. A copy refuses, as the ordinary copy does. A move to another board is no longer required to be the plan's last action. It is lifted when every action that can follow it in a plan, in its own rule or in a rule of the same trigger type, is one of these. Moves, sorting and moving all cards are resolved against the board the card left, and archiving cascades to child cards there, so with any of those the board keeps direct Sync. Thanks to xet7 !
126 lines
8.2 KiB
JavaScript
126 lines
8.2 KiB
JavaScript
'use strict';
|
|
// Durable rule checklist actions (maintainer decision of 2026-09-30: durable
|
|
// adapters for rule actions): checkAll, uncheckAll, checkItem, uncheckItem.
|
|
// One saved command per rule invocation, captured with the same lookups the
|
|
// ordinary action uses (server/rulesHelper.js performAction), so a replay acts
|
|
// on the same items with the same outcome.
|
|
//
|
|
// The ordinary action writes each item in turn, and the checklist item hooks
|
|
// record, for EVERY write, even one that does not change the value:
|
|
// before it uncompleteChecklist, when the whole checklist was finished
|
|
// after it checkedItem / uncheckedItem
|
|
// completeChecklist, when the whole checklist is finished
|
|
// plus a History row on the item when isFinished really changed. The command
|
|
// simulates that sequence on the captured items and saves one unit per item:
|
|
// its value before and after, its History plan and its activities, split into
|
|
// those written before the item and those after, as the hooks write them.
|
|
// Pure: tested by tests/syncRuleChecklistCommand.test.cjs.
|
|
const { EJSON } = require('bson');
|
|
const { canonical, sha256 } = require('../../models/lib/changeHistoryIntegrity');
|
|
const { validateRulePlan, planId } = require('./syncRulePlan');
|
|
const { prepareCardFieldHistory, RULE_CHECKLIST_ITEM_FIELDS } = require('./syncHistoryBatch');
|
|
const copy = value => EJSON.parse(EJSON.stringify(value), { relaxed: true });
|
|
const fail = code => { throw new Error(`sync-rule-checklist-${code}`); };
|
|
const text = value => typeof value === 'string' && value.length > 0;
|
|
|
|
const RULE_CHECKLIST_ACTIONS = { checkAll: true, uncheckAll: false, checkItem: true, uncheckItem: false };
|
|
const commandId = invocationId => sha256(canonical(['sync-rule-checklist', invocationId]));
|
|
|
|
function identity({ plan, activity, effectId, index }) {
|
|
validateRulePlan(plan, activity, effectId);
|
|
const invocation = plan.actions[index];
|
|
const actionType = invocation?.action?.actionType;
|
|
if (!Number.isSafeInteger(index) || index < 0 || !Object.hasOwn(RULE_CHECKLIST_ACTIONS, actionType)) 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 };
|
|
}
|
|
|
|
function validSnapshot({ checklist, items, targetIds }) {
|
|
if (checklist !== null && (!checklist || Object.keys(checklist).sort().join(',') !== '_id,hideAllChecklistItems,title' ||
|
|
!text(checklist._id) || typeof checklist.title !== 'string' || typeof checklist.hideAllChecklistItems !== 'boolean')) return false;
|
|
if (!Array.isArray(items) || items.length > 1000 || !Array.isArray(targetIds) || targetIds.length > 1000) return false;
|
|
const ids = new Set();
|
|
for (const item of items) {
|
|
if (!item || Object.keys(item).filter(key => key !== 'isFinished').sort().join(',') !== '_id,title' ||
|
|
!text(item._id) || typeof item.title !== 'string' || ids.has(item._id) ||
|
|
(Object.hasOwn(item, 'isFinished') && typeof item.isFinished !== 'boolean')) return false;
|
|
ids.add(item._id);
|
|
}
|
|
return (checklist !== null || (!items.length && !targetIds.length)) && targetIds.every(id => ids.has(id));
|
|
}
|
|
|
|
// The units the ordinary action's writes and hooks produce, in order.
|
|
function simulate({ base, checklist, items, targetIds, createdAt, redoRows }) {
|
|
const target = RULE_CHECKLIST_ACTIONS[base.actionType];
|
|
const state = new Map(items.map(item => [item._id, !!item.isFinished]));
|
|
const finished = () => checklist.hideAllChecklistItems || (items.length > 0 && items.every(item => state.get(item._id)));
|
|
// The card's board: the plan's, or one a cross-board move of this plan put
|
|
// it on (2026-10-03), where its hooks record.
|
|
const place = { boardId: base.cardBoardId, listId: base.listId, swimlaneId: base.swimlaneId };
|
|
let redoLeft = redoRows;
|
|
return targetIds.map((itemId, k) => {
|
|
const item = items.find(row => row._id === itemId);
|
|
const unitId = sha256(canonical(['sync-rule-checklist-unit', base._id, k]));
|
|
const activity = (kind, fields) => {
|
|
const receiptId = sha256(canonical([unitId, kind]));
|
|
return { receiptId, activity: { _id: `sync-rule-checklist-${receiptId}`, userId: base.actorId, ...fields,
|
|
cardId: base.cardId, boardId: base.cardBoardId, checklistId: checklist._id, listId: base.listId,
|
|
swimlaneId: base.swimlaneId, createdAt, modifiedAt: createdAt } };
|
|
};
|
|
const beforeActivities = finished()
|
|
? [activity('uncomplete', { activityType: 'uncompleteChecklist', checklistName: checklist.title })] : [];
|
|
const before = Object.hasOwn(item, 'isFinished') ? { isFinished: item.isFinished } : {};
|
|
state.set(itemId, target);
|
|
const afterActivities = [activity('check', { activityType: target ? 'checkedItem' : 'uncheckedItem',
|
|
checklistItemId: itemId, checklistItemName: item.title })];
|
|
if (finished()) afterActivities.push(activity('complete', { activityType: 'completeChecklist', checklistName: checklist.title }));
|
|
const history = prepareCardFieldHistory({ before: { ...place, ...before }, after: { ...place, isFinished: target },
|
|
effectId: unitId, userId: base.actorId, createdAt, redoRows: redoLeft, fields: RULE_CHECKLIST_ITEM_FIELDS,
|
|
entityId: itemId, cardId: base.cardId });
|
|
if (history.rows.length) redoLeft = [];
|
|
return { itemId, before, after: { isFinished: target }, history, beforeActivities, afterActivities };
|
|
});
|
|
}
|
|
|
|
// `cardBoardId` is the board the card is on: the plan's, unless the runner
|
|
// verified a cross-board move of this same plan put it elsewhere.
|
|
function prepareRuleChecklistCommand({ plan, activity, effectId, index, card, checklist, items, targetIds, createdAt,
|
|
redoRows = [], cardBoardId = plan?.boardId }) {
|
|
const base = identity({ plan, activity, effectId, index });
|
|
if (!card || card._id !== base.cardId || !text(cardBoardId) || card.boardId !== cardBoardId ||
|
|
!text(card.listId) || !text(card.swimlaneId) ||
|
|
!(createdAt instanceof Date) || !Number.isFinite(createdAt.getTime())) fail('card-invalid');
|
|
const snapshot = copy({ checklist: checklist ? { _id: checklist._id, title: checklist.title,
|
|
hideAllChecklistItems: !!checklist.hideAllChecklistItems } : null,
|
|
items: (items || []).map(item => ({ _id: item._id, title: item.title,
|
|
...(typeof item.isFinished === 'boolean' ? { isFinished: item.isFinished } : {}) })), targetIds: targetIds || [] });
|
|
if (!validSnapshot(snapshot)) fail('snapshot-invalid');
|
|
const located = { ...base, cardBoardId, listId: card.listId, swimlaneId: card.swimlaneId };
|
|
const command = { ...located, ...snapshot, createdAt: new Date(createdAt),
|
|
units: simulate({ base: located, ...snapshot, createdAt, redoRows }) };
|
|
command.checksum = sha256(canonical(command));
|
|
return validateRuleChecklistCommand(command, { plan, activity, effectId, index });
|
|
}
|
|
|
|
const withoutRedo = units => units.map(unit => ({ ...unit, history: { ...unit.history, redo: [] } }));
|
|
function validateRuleChecklistCommand(row, context) {
|
|
const base = identity(context);
|
|
const keys = [...Object.keys(base), 'cardBoardId', 'listId', 'swimlaneId', 'checklist', 'items', 'targetIds', 'createdAt', 'units',
|
|
'checksum'].sort().join(',');
|
|
if (!row || Object.keys(row).sort().join(',') !== keys ||
|
|
Object.entries(base).some(([key, value]) => canonical(row[key]) !== canonical(value)) ||
|
|
!text(row.cardBoardId) || !text(row.listId) || !text(row.swimlaneId) || !(row.createdAt instanceof Date) ||
|
|
!validSnapshot(row) || !Array.isArray(row.units)) fail('command-invalid');
|
|
const { checksum, ...content } = row;
|
|
if (checksum !== sha256(canonical(content))) fail('command-invalid');
|
|
// Everything but the captured redo rows follows from the saved snapshot.
|
|
const expected = simulate({ base: { ...base, cardBoardId: row.cardBoardId, listId: row.listId, swimlaneId: row.swimlaneId },
|
|
checklist: row.checklist,
|
|
items: row.items, targetIds: row.targetIds, createdAt: row.createdAt, redoRows: [] });
|
|
if (canonical(withoutRedo(expected)) !== canonical(withoutRedo(row.units)) ||
|
|
row.units.filter(unit => unit.history.redo.length).length > 1) fail('command-invalid');
|
|
return copy(row);
|
|
}
|
|
|
|
module.exports = { RULE_CHECKLIST_ACTIONS, commandId, prepareRuleChecklistCommand, validateRuleChecklistCommand };
|