Files
nextcloud-workflow-deck-aut…/lib/Service/WorkflowRunner.php
T
Patrick Niebeling 9e1a15bab0
Build package / xml-lint (push) Successful in 1m4s
Build package / php-lint (8.5) (push) Successful in 4m0s
Build package / unit-tests (push) Successful in 4m13s
Build package / php-lint (8.4) (push) Successful in 4m34s
Build package / package (push) Successful in 1m38s
Refuse workflows that move cards in a loop
Every enabled workflow is an edge "source stack -> target stack";
LoopDetector finds circles in that graph, a workflow whose source is its
own target being the shortest. Without a check, two workflows A -> B and
B -> A move the same cards back and forth on every run, even within a
single run, with a notice for each move. The trigger date is ignored on
purpose: by start date one way and by due date the other, a card with
both dates passed still circles. Chains stay allowed.

The controller rejects saving an enabled workflow that would close a loop
and names the workflows involved. Workflows stored before this check are
skipped by the runner (logged, not disabled) and flagged in the settings
list via a new `loop` field from index().
2026-10-01 13:47:32 +02:00

526 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Service;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use OCA\Deck\Db\Card;
use OCA\WorkflowDeckAutomation\Db\Workflow;
use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\IRootFolder;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* Evaluates all enabled workflows and moves every card whose due date or
* start date — whichever the workflow uses, see TriggerDate — has passed.
*
* This runner has to evaluate rules for many different users within a
* single background-job process, so workflows are grouped by owner and
* each owner is impersonated in turn. Impersonation here means two
* things, and the second one is the load-bearing half: pushing the IUser
* onto the session (the standard Nextcloud cron pattern), *and* pinning
* Deck itself to that user via
* DeckIntegrationService::beginUserContext(), because Deck's permission
* checks never consult the session — they read a uid frozen once per
* process. Both are undone in a finally.
*/
class WorkflowRunner {
public const TARGET_BOARD = 'board';
public const TARGET_SOURCE_STACK = 'sourceStack';
public const TARGET_TARGET_STACK = 'targetStack';
private ?IUser $impersonationRestore = null;
/**
* Moved cards per recipient uid, collected over the whole run. Each
* entry's workflow says whether it goes out by mail, as a Nextcloud
* notification, or both.
*
* @var array<string, list<array{workflow: Workflow, card: Card, targets: array{board: string, sourceStack: string, targetStack: string}}>>
*/
private array $pendingNotices = [];
public function __construct(
private WorkflowMapper $workflowMapper,
private DeckIntegrationService $deckService,
private NotificationMailer $mailer,
private NotificationPusher $pusher,
private IUserManager $userManager,
private IUserSession $userSession,
private IRootFolder $rootFolder,
private ITimeFactory $timeFactory,
private LoggerInterface $logger,
) {
}
public function run(): void {
$workflows = $this->workflowMapper->findAllEnabled();
if ($workflows === []) {
return;
}
// The controller refuses to store a loop, but workflows saved before
// that check — including ones whose source is their own target — can
// still form one. They would move the same cards on every run, with a
// notice each time, so they are left out until the user fixes them;
// the settings page marks them.
$looping = LoopDetector::findLoopingWorkflows(LoopDetector::edgesOf($workflows));
$this->pendingNotices = [];
$byUser = [];
$owners = [];
foreach ($workflows as $workflow) {
$this->followBoardOwner($workflow, $owners);
if (in_array((int)$workflow->getId(), $looping, true)) {
$this->logger->warning('Skipping workflow {id}: it moves cards in a loop together with other workflows', [
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
]);
continue;
}
$byUser[$workflow->getUserId()][] = $workflow;
}
foreach ($byUser as $userId => $userWorkflows) {
// Nothing may escape this loop. Job::start() calls setLastRun()
// *before* run() and only clears `reserved_at` afterwards via
// setExecutionTime(), which is not in a finally — so a single
// uncaught throwable leaves the job reserved, and JobList::getNext()
// then skips it until the reservation is 12 hours stale. One bad
// workflow would silently take the whole app offline for half a day.
try {
$this->runForUser((string)$userId, $userWorkflows);
} catch (Throwable $e) {
$this->logger->error('Workflow run failed for {user}: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'user' => $userId,
'exception' => $e,
]);
}
}
$this->sendPendingNotices();
}
/**
* A workflow belongs to its board's owner, not to whoever created it: if
* Deck transferred the board, the workflow moves along to the new owner —
* it then runs as them, and they get its mails.
*
* Only a known owner rewrites `user_id`. A board that is gone keeps its
* last owner, so findBrokenTarget() can disable the workflow and mail
* them; a Deck error keeps it too, and the run just goes ahead as before.
*
* @param array<int, ?string> $owners per-run cache, board id => owner
*/
private function followBoardOwner(Workflow $workflow, array &$owners): void {
$boardId = $workflow->getBoardId();
if (!array_key_exists($boardId, $owners)) {
try {
$owners[$boardId] = $this->deckService->findBoardOwner($boardId);
} catch (Throwable $e) {
$this->logger->warning('Could not determine the owner of board {board}: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'board' => $boardId,
]);
$owners[$boardId] = null;
}
}
$owner = $owners[$boardId];
if ($owner === null || $owner === $workflow->getUserId()) {
return;
}
$this->logger->info('Workflow {id} follows board {board} to its new owner {owner}.', [
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
'board' => $boardId,
'owner' => $owner,
]);
$workflow->setUserId($owner);
try {
$this->workflowMapper->update($workflow);
} catch (Throwable $e) {
// Not persisted, but this run still goes to the right user; the
// next one tries again.
$this->logger->warning('Could not store the new owner of workflow {id}: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
]);
}
}
/**
* @param Workflow[] $workflows
*/
private function runForUser(string $userId, array $workflows): void {
$user = $this->userManager->get($userId);
if ($user === null || !$user->isEnabled()) {
return;
}
try {
$this->deckService->assertDeckAvailable($user);
} catch (DeckUnavailableException $e) {
$this->logger->info('Skipping workflows for {user}: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'user' => $userId,
]);
return;
}
try {
$this->impersonate($user);
} catch (DeckUnavailableException $e) {
// Deck could not be pinned to this user, so every permission
// check would be answered for somebody else. Skipping costs one
// run; continuing would move the wrong cards or, worse, read a
// denial as "the board is gone" and disable a working workflow.
$this->logger->error('Could not impersonate {user} towards Deck: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'user' => $userId,
]);
return;
}
try {
foreach ($workflows as $workflow) {
// Same reasoning as in run(): one broken workflow must not
// stop the user's remaining ones.
try {
$this->runWorkflow($user, $workflow);
} catch (Throwable $e) {
$this->logger->error('Workflow {id} failed: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
'exception' => $e,
]);
}
}
} finally {
$this->clearImpersonation();
}
}
private function runWorkflow(IUser $user, Workflow $workflow): void {
try {
$brokenTarget = $this->findBrokenTarget($user, $workflow);
} catch (DeckUnavailableException $e) {
// Could not determine it either way — leave the workflow alone
// rather than disabling it over a temporary Deck problem.
$this->logger->warning('Could not verify targets of workflow {id}: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
]);
return;
}
if ($brokenTarget !== null) {
$this->disableBrokenWorkflow($user, $workflow, $brokenTarget);
return;
}
try {
$cards = $this->deckService->getActiveCardsInStack($workflow->getSourceStackId());
} catch (DeckUnavailableException $e) {
$this->logger->warning('Could not read stack for workflow {id}: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
]);
return;
}
$trigger = (string)$workflow->getTriggerDate();
if ($trigger === TriggerDate::START && !$this->deckService->supportsStartdate()) {
// Not disabled: the workflow is intact, Deck is just too old for
// it, and comes back to life with a Deck update.
$this->logger->warning('Skipping workflow {id}: this Deck version has no start date', [
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
]);
return;
}
$moved = [];
$now = $this->timeFactory->getDateTime();
foreach ($cards as $card) {
// Anything but `start` counts as `due`, the column default.
$date = $trigger === TriggerDate::START ? $card->getStartdate() : $card->getDuedate();
if (!self::hasPassed($date, $now)) {
continue;
}
$this->logger->info('Card {card} ("{title}") matches workflow {id}; moving to stack {target}.', [
'app' => 'workflow_deck_automation',
'card' => $card->getId(),
'title' => $card->getTitle(),
'id' => $workflow->getId(),
'target' => $workflow->getTargetStackId(),
]);
if ($this->moveCard($workflow, $card)) {
$moved[] = $card;
}
}
$workflow->setLastRun($this->timeFactory->getDateTime());
$this->workflowMapper->update($workflow);
if ($moved !== []) {
$this->queueNotices($user, $workflow, $moved);
}
}
/**
* Works out who hears about the moved cards and parks them in
* $pendingNotices; sendPendingNotices() delivers them at the end of the
* run. The recipients are the same for mail and notification.
*
* Notices are decoration, not part of the move: nothing in here may fail
* the workflow, and if Deck cannot say who wants one, nobody gets one
* rather than people who asked Deck for silence.
*
* @param Card[] $cards
*/
private function queueNotices(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 or notifications 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->pendingNotices[$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. Nextcloud notifications
* go out per card instead: they are grouped in the bell anyway, and each
* one links its card.
*
* Runs after the per-user loop, so no impersonation is active any more.
*/
private function sendPendingNotices(): void {
$pending = $this->pendingNotices;
$this->pendingNotices = [];
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;
}
$mailEntries = array_values(array_filter(
$entries,
static fn (array $entry): bool => NotificationPolicy::sendsMail((string)$entry['workflow']->getNotifyChannel()),
));
$notificationEntries = array_values(array_filter(
$entries,
static fn (array $entry): bool => NotificationPolicy::sendsNotification((string)$entry['workflow']->getNotifyChannel()),
));
if ($mailEntries !== []) {
$this->mailer->sendCardsMovedNotification($recipient, $mailEntries);
}
if ($notificationEntries !== []) {
$this->pusher->sendCardsMovedNotification($recipient, $notificationEntries);
}
} catch (Throwable $e) {
$this->logger->error('Could not notify {user} about moved cards: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'user' => $uid,
'exception' => $e,
]);
}
}
}
/**
* Which of the workflow's Deck references no longer exists, if any.
*
* Deleting a board or stack leaves the workflow row untouched — there is
* no foreign key — and Deck keeps the orphaned cards readable until its
* own DeleteCron purges them, so the workflow would otherwise keep
* failing on every single run.
*
* @return self::TARGET_*|null
* @throws DeckUnavailableException if Deck could not be asked
*/
private function findBrokenTarget(IUser $user, Workflow $workflow): ?string {
$stackIds = $this->deckService->findStackIds($workflow->getBoardId(), $user->getUID());
if ($stackIds === null) {
return self::TARGET_BOARD;
}
if (!in_array($workflow->getSourceStackId(), $stackIds, true)) {
return self::TARGET_SOURCE_STACK;
}
if (!in_array($workflow->getTargetStackId(), $stackIds, true)) {
return self::TARGET_TARGET_STACK;
}
return null;
}
/**
* @param self::TARGET_* $brokenTarget
*/
private function disableBrokenWorkflow(IUser $user, Workflow $workflow, string $brokenTarget): void {
$workflow->setEnabled(false);
$workflow->setLastRun($this->timeFactory->getDateTime());
$this->workflowMapper->update($workflow);
$this->logger->warning('Disabled workflow {id}: its {target} no longer exists', [
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
'target' => $brokenTarget,
]);
// Sent to the owner regardless of the workflow's notifyMode and
// notifyChannel: those are about moved cards, this is a one-off notice
// that the automation the user configured has been switched off — by
// mail as always, and in the bell too if the notifications app is
// there. It stays one-off because a disabled workflow is not picked
// up again.
$this->mailer->sendWorkflowDisabledNotification($user, $workflow, $brokenTarget);
$this->pusher->sendWorkflowDisabledNotification($user, $workflow, $brokenTarget);
}
private function moveCard(Workflow $workflow, Card $card): bool {
try {
$this->deckService->moveCard($card->getId(), $workflow->getTargetStackId());
} catch (Throwable $e) {
$this->logger->error('Failed to move card {card} for workflow {id}: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'card' => $card->getId(),
'id' => $workflow->getId(),
]);
return false;
}
$this->logger->info('Moved card {card} ("{title}") to stack {target} for workflow {id}.', [
'app' => 'workflow_deck_automation',
'card' => $card->getId(),
'title' => $card->getTitle(),
'target' => $workflow->getTargetStackId(),
'id' => $workflow->getId(),
]);
return true;
}
/**
* Whether the card's trigger date (due or start date, see TriggerDate)
* lies before $now.
*
* Compares the card's actual timestamp against $now instead of Deck's own
* Card::getDaysUntilDue(), which truncates both sides to midnight before diffing
* (DateInterval's %a is a whole-day count) and therefore reports 0 - not overdue -
* for any card whose due time has already passed today. That silently held back
* every card for up to 24h after it actually fell due; comparing the raw datetime
* instead matches what Deck's own UI shows (e.g. "vor 2 Stunden") and has no
* day-rounding step to get wrong.
*
* Both sides are compared as absolute instants, and the card's date is
* first pinned to UTC via asStoredUtc() — see there for why.
*/
public static function hasPassed(?DateTimeInterface $date, DateTimeInterface $now): bool {
return $date !== null && self::asStoredUtc($date) < $now;
}
/**
* Deck's `duedate` and `startdate` are DATETIME columns without a zone
* that hold UTC wall-clock time: Deck's frontend sends `toISOString()`,
* and the value is written as plain `Y-m-d H:i:s`. Entity hydrates it
* with `new \DateTime($string)`, i.e. in PHP's *default* timezone —
* correct only because Nextcloud sets that default to UTC during
* bootstrap. Reading the wall clock and pinning it to UTC explicitly
* makes the comparison independent of that default, so a server or CLI
* running with a local default zone cannot shift every date by its UTC
* offset.
*
* Deck itself has two timezone-sensitive paths this deliberately avoids:
* Card::getDaysUntilDue() (day-truncated, see hasPassed()) and
* CardMapper::findOverdue(), which compares the UTC column against the
* database's `NOW()` — the DB session's local time.
*/
private static function asStoredUtc(DateTimeInterface $date): DateTimeImmutable {
return new DateTimeImmutable($date->format('Y-m-d H:i:s.u'), new DateTimeZone('UTC'));
}
/**
* @throws DeckUnavailableException if Deck cannot be pinned to $user
*/
private function impersonate(IUser $user): void {
$this->impersonationRestore = $this->userSession->getUser();
$this->userSession->setUser($user);
try {
// Best-effort filesystem setup; some Deck-internal helpers
// (attachments, activity) expect an initialised user FS.
$this->rootFolder->getUserFolder($user->getUID());
} catch (Throwable $e) {
$this->logger->debug('Filesystem setup failed for ' . $user->getUID() . ': ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
]);
}
try {
// The session switch above is necessary but nowhere near
// sufficient — Deck's permission checks never look at it. See
// DeckIntegrationService::beginUserContext().
$this->deckService->beginUserContext($user);
} catch (DeckUnavailableException $e) {
$this->clearImpersonation();
throw $e;
}
}
private function clearImpersonation(): void {
$this->deckService->endUserContext();
$this->userSession->setUser($this->impersonationRestore);
$this->impersonationRestore = null;
}
}