Files
wekan/models/lib/ruleCardTitleFilter.js
T
Lauri Ojansivu 25d33cec36 Fix #2345 #2674 #574: rules card title filter, rules REST API, api docs;
Sandstorm username races.

- #2345: the Card Title Filter is now SAVED by every board trigger
  builder (a trigger doc missing the field could never satisfy the
  matcher's $in - those rules fired for nothing), SHOWN in the rule
  details, and ENFORCED for archive/restore triggers by resolving the
  title from the card when the activity lacks it; legacy field-less
  triggers keep matching; the filter popup prefills its current value.
- #2674: rules created via POST/PUT /api/boards/:boardId/rules never
  fired - missing matching fields are now normalized to '*' wildcards
  and trigger/action types validated; the addMember/removeMember rule
  actions no longer crash on unresolvable usernames or member-less
  cards and their writes are awaited. New docs/API/Rules.md (endpoints,
  matching fields, wildcard semantics, the exact two-rule
  add-on-move-to/remove-on-move-from workflow), linked from REST-API.md;
  api.py help now lists the rule commands with the same example.
- #574: Sandstorm unique-username generation probes case-insensitively
  (anchored, regex-escaped) and retries the next number when a
  concurrent insert wins the unique-index race, instead of aborting the
  login hook with E11000.

Tests: tests/rulesCardTitleFilter.test.cjs (12),
tests/rulesApiTriggerNormalize.test.cjs (19),
tests/sandstormUsername.test.cjs (11) - positive and negative cases.

Thanks to InfoSec812, sfahrenholz, mitar and xet7.
2026-07-17 03:36:50 +03:00

81 lines
3.6 KiB
JavaScript

'use strict';
// #2345 "basic card title filter rules issue".
//
// The board-rule triggers ("when a card is created / moved / archived ...")
// offer a magnifier popup that lets the user restrict the trigger to cards
// whose title matches a filter. Three things were broken:
//
// 1. The generic "when a card is moved" and the archive/unarchive triggers
// never stored the filter at all — worse, they stored NO `cardTitle`
// field, and because the server matches triggers with
// `cardTitle: { $in: [...] }`, a trigger document that lacks the field
// can never match an activity that carries a card title, so those rules
// never fired for anything.
// 2. A stored filter never appeared in the trigger's human-readable
// description ("When a card is moved to list X" — no trace of "Part A"),
// which is exactly what the issue reporter complained about.
// 3. Old trigger documents created before the fix (without a `cardTitle`
// field) must keep behaving as "match every card".
//
// These helpers are pure (no Meteor imports) so both the client wizard and
// server/rulesHelper.js share one tested implementation.
// Normalize the raw popup input into the value stored on the trigger document.
// An empty / whitespace-only / missing filter means "match every card", which
// the rules engine expresses with the '*' wildcard (this matches the UI note:
// "leave a field empty to match every possible value").
function cardTitleFilterOrWildcard(raw) {
if (typeof raw !== 'string') return '*';
const trimmed = raw.trim();
return trimmed === '' ? '*' : trimmed;
}
// Build the list of trigger `cardTitle` values that match an activity whose
// card title is `activityCardTitle`, used as `cardTitle: { $in: [...] }` when
// looking up triggers. A trigger matches when its stored filter is:
// - the exact card title,
// - any single word of the card title (historic behaviour: the title is
// split on non-word characters),
// - the '*' wildcard (no filter set), or
// - null / a missing field (legacy triggers saved without a cardTitle —
// in MongoDB `$in: [null, ...]` also matches documents lacking the field,
// which keeps pre-fix rules firing as "every card").
function cardTitleMatchList(activityCardTitle) {
const matches = ['*', null];
if (activityCardTitle !== undefined && activityCardTitle !== null) {
const title = String(activityCardTitle);
return title
.split(/\W/)
.filter(word => word !== '')
.concat([title])
.concat(matches);
}
return matches;
}
// Pure predicate mirroring the $in semantics above: does a trigger stored with
// `filter` fire for a card titled `cardTitle`? (A missing/null filter — legacy
// trigger — matches everything, like the wildcard.)
function cardTitleFilterMatches(filter, cardTitle) {
if (filter === undefined || filter === null) return true;
return cardTitleMatchList(cardTitle).includes(filter);
}
// Append the filter to the trigger's human-readable description so the rule
// list / rule details show it ("when a card is moved to list x (card title
// filter: "Part A")"). A wildcard/empty filter leaves the description alone.
function appendCardTitleFilterToDesc(desc, cardTitle, label) {
const base = typeof desc === 'string' ? desc : '';
if (!cardTitle || cardTitle === '*') return base;
const tag = label && typeof label === 'string' ? label : 'card title filter';
return `${base} (${tag}: "${cardTitle}")`;
}
module.exports = {
cardTitleFilterOrWildcard,
cardTitleMatchList,
cardTitleFilterMatches,
appendCardTitleFilterToDesc,
};