Notify board members about moved cards according to their Deck setting
Build package / unit-tests (push) Failing after 20s
Build package / xml-lint (push) Successful in 46s
Build package / php-lint (8.4) (push) Successful in 1m30s
Build package / php-lint (8.5) (push) Successful in 1m36s
Build package / package (push) Skipped

Replaces the yes/no notify_email flag with notify_mode:
- off:   no mail
- owner: only the board owner, as before (migrated from notify_email=1)
- board: everyone with access to the board, filtered by their own Deck
         "Notifications" setting for it (board:<id>:notify-due: off /
         assigned / all), mirroring Deck's NotificationHelper rules

Recipients are resolved via PermissionService::findUsers() (owner, ACL,
groups and circles expanded), AssignmentMapper::isUserAssigned() and
IConfig, none of which reads Deck's frozen $userId. If they cannot be
resolved, no mail is sent rather than one to the wrong people.

Moved cards are collected over the whole run and each recipient gets a
single digest mail afterwards, in their own language.

Bumps to 35.2.0 so the migrations run.
This commit is contained in:
Patrick Niebeling committed 2026-09-30 10:31:45 +02:00
1 parent 27717205cd
commit 578b235f08
14 files changed
+579 -100

No files matched your search

+97 -16
View File
@@ -38,6 +38,13 @@ class WorkflowRunner {
private ?IUser $impersonationRestore = null;
/**
* Moved cards per recipient uid, collected over the whole run.
*
* @var array<string, list<array{workflow: Workflow, card: Card, targets: array{board: string, sourceStack: string, targetStack: string}}>>
*/
private array $pendingMails = [];
public function __construct(
private WorkflowMapper $workflowMapper,
private DeckIntegrationService $deckService,
@@ -56,6 +63,7 @@ class WorkflowRunner {
return;
}
$this->pendingMails = [];
$byUser = [];
$owners = [];
foreach ($workflows as $workflow) {
@@ -80,6 +88,8 @@ class WorkflowRunner {
]);
}
}
$this->sendPendingMails();
}
/**
@@ -211,10 +221,7 @@ class WorkflowRunner {
return;
}
// Resolved at most once per run, and only if a card actually moves
// and the workflow wants a mail — this is decoration, not a check.
$targets = null;
$moved = [];
$now = $this->timeFactory->getDateTime();
foreach ($cards as $card) {
@@ -230,22 +237,96 @@ class WorkflowRunner {
'target' => $workflow->getTargetStackId(),
]);
if (!$this->moveCard($workflow, $card)) {
continue;
}
if ($workflow->getNotifyEmail()) {
$targets ??= $this->deckService->describeTargets(
$workflow->getBoardId(),
$workflow->getSourceStackId(),
$workflow->getTargetStackId(),
);
$this->mailer->sendCardMovedNotification($user, $workflow, $card, $targets);
if ($this->moveCard($workflow, $card)) {
$moved[] = $card;
}
}
$workflow->setLastRun($this->timeFactory->getDateTime());
$this->workflowMapper->update($workflow);
if ($moved !== []) {
$this->queueMails($user, $workflow, $moved);
}
}
/**
* Works out who hears about the moved cards and parks them in
* $pendingMails; sendPendingMails() delivers them once per recipient at
* the end of the run.
*
* Mails are decoration, not part of the move: nothing in here may fail
* the workflow, and if Deck cannot say who wants a mail, nobody gets one
* rather than people who asked Deck for silence.
*
* @param Card[] $cards
*/
private function queueMails(IUser $owner, Workflow $workflow, array $cards): void {
$cardIds = array_map(static fn (Card $card): int => (int)$card->getId(), $cards);
switch ($workflow->getNotifyMode()) {
case NotificationPolicy::MODE_OWNER:
$recipients = array_fill_keys($cardIds, [$owner->getUID()]);
break;
case NotificationPolicy::MODE_BOARD:
try {
$recipients = $this->deckService->findNotificationRecipients($workflow->getBoardId(), $cardIds);
} catch (DeckUnavailableException $e) {
$this->logger->warning('No mails for workflow {id}: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
]);
return;
}
break;
default:
return;
}
$targets = $this->deckService->describeTargets(
$workflow->getBoardId(),
$workflow->getSourceStackId(),
$workflow->getTargetStackId(),
);
foreach ($cards as $card) {
foreach ($recipients[(int)$card->getId()] ?? [] as $uid) {
$this->pendingMails[$uid][] = [
'workflow' => $workflow,
'card' => $card,
'targets' => $targets,
];
}
}
}
/**
* One mail per recipient and run, however many cards and workflows it
* covers — a board with twenty overdue cards and fifteen members would
* otherwise send three hundred mails at once.
*
* Runs after the per-user loop, so no impersonation is active any more.
*/
private function sendPendingMails(): void {
$pending = $this->pendingMails;
$this->pendingMails = [];
foreach ($pending as $uid => $entries) {
// Same reasoning as in run(): nothing may escape.
try {
$recipient = $this->userManager->get((string)$uid);
if ($recipient === null || !$recipient->isEnabled()) {
continue;
}
$this->mailer->sendCardsMovedNotification($recipient, $entries);
} catch (Throwable $e) {
$this->logger->error('Could not send the moved-cards mail to {user}: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'user' => $uid,
'exception' => $e,
]);
}
}
}
/**
@@ -289,7 +370,7 @@ class WorkflowRunner {
'target' => $brokenTarget,
]);
// Sent regardless of the workflow's notifyEmail setting: that flag is
// Sent to the owner regardless of the workflow's notifyMode: that is
// about moved cards, this is a one-off notice that the automation the
// user configured has been switched off. It stays one-off because a
// disabled workflow is not picked up again.