Files
wekan/server/lib/syncRuleAddSwimlaneCommand.js
Lauri Ojansivu 2df3e9a8b7 Deliver board-level activities durably: a rule's new swimlane and cross-board moves.
Durable delivery identified every activity by a card and a list, so a
rule's createSwimlane activity, which names neither, and a cross-board
move's moveCardBoard activity, which names no list, were written once
but delivered the ordinary way: their notifications and webhooks were
not replayed after a crash. Durable delivery now takes activities
without a list, and without a card. Recipients and webhook senders are
checked against the board, plus the card when one is named. A card or
list the activity does not name is refused. An assigned-only member
never receives a board-level event, since its scope cannot be checked
against their assignments. The rule stage has nothing to run for these
types, because no trigger exists for them, and it refuses a list-less
activity whose type has one.

Thanks to xet7 !
2026-10-03 00:08:13 +03:00

62 lines
3.5 KiB
JavaScript

'use strict';
// Durable rule addSwimlane (the structural rule actions of TODO Later). The
// ordinary action (server/rulesHelper.js) inserts { title: <swimlaneName with
// rule variables>, boardId, sort: 0 }, and the insert hook
// (server/models/swimlanes.js) writes the createSwimlane activity.
//
// One saved command per rule invocation: the swimlane's id is derived from the
// invocation so a replay inserts it once, and the activity - fully known before
// the insert - has a derived id too, so it is written once, and delivered
// durably: a board-level activity, with no card or list (2026-10-03). Pure: tested by
// tests/syncRuleAddSwimlaneCommand.test.cjs.
const { EJSON } = require('bson');
const { canonical, sha256 } = require('../../models/lib/changeHistoryIntegrity');
const { validateRulePlan, planId } = require('./syncRulePlan');
const copy = value => EJSON.parse(EJSON.stringify(value), { relaxed: true });
const fail = code => { throw new Error(`sync-rule-add-swimlane-${code}`); };
const commandId = invocationId => sha256(canonical(['sync-rule-add-swimlane', invocationId]));
const swimlaneIdFor = id => sha256(canonical([id, 'swimlane'])).slice(0, 24);
function identity({ plan, activity, effectId, index }) {
validateRulePlan(plan, activity, effectId);
const invocation = plan.actions[index];
if (!Number.isSafeInteger(index) || index < 0 || invocation?.action?.actionType !== 'addSwimlane') 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: 'addSwimlane' };
}
function effects(base, title, createdAt) {
const swimlane = { _id: swimlaneIdFor(base._id), title, boardId: base.boardId, sort: 0 };
// The hook's activity, exactly, with a derived id.
const activity = { _id: `sync-rule-add-swimlane-${sha256(canonical([base._id, 'activity']))}`, userId: base.actorId,
type: 'swimlane', activityType: 'createSwimlane', boardId: base.boardId, swimlaneId: swimlane._id,
createdAt: new Date(createdAt), modifiedAt: new Date(createdAt) };
return { swimlane, activity };
}
function prepareRuleAddSwimlaneCommand({ plan, activity, effectId, index, title, createdAt }) {
const base = identity({ plan, activity, effectId, index });
if (typeof title !== 'string' || !(createdAt instanceof Date) || !Number.isFinite(createdAt.getTime())) fail('invalid');
const command = { ...base, createdAt: new Date(createdAt), ...effects(base, title, createdAt) };
command.checksum = sha256(canonical(command));
return validateRuleAddSwimlaneCommand(command, { plan, activity, effectId, index });
}
function validateRuleAddSwimlaneCommand(row, context) {
const base = identity(context);
const keys = [...Object.keys(base), 'createdAt', 'swimlane', 'activity', 'checksum'].sort().join(',');
if (!row || Object.keys(row).sort().join(',') !== keys ||
Object.entries(base).some(([key, value]) => canonical(row[key]) !== canonical(value)) ||
!(row.createdAt instanceof Date) || typeof row.swimlane?.title !== 'string') fail('command-invalid');
const { checksum, ...content } = row;
if (checksum !== sha256(canonical(content)) ||
canonical(effects(base, row.swimlane.title, row.createdAt)) !== canonical({ swimlane: row.swimlane, activity: row.activity })) {
fail('command-invalid');
}
return copy(row);
}
module.exports = { commandId, swimlaneIdFor, prepareRuleAddSwimlaneCommand, validateRuleAddSwimlaneCommand };