workflowMapper->findAllEnabled(); if ($workflows === []) { return; } $byUser = []; foreach ($workflows as $workflow) { $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, ]); } } } /** * @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; } $filterUserIds = $workflow->getFilterUserIdsArray(); $filterLabelIds = $workflow->getFilterLabelIdsArray(); $this->warnAboutDeadFilters($workflow, $filterUserIds, $filterLabelIds); // 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; $now = $this->timeFactory->getDateTime(); $dateField = $workflow->getDateFieldOrDue(); foreach ($cards as $card) { if (!self::hasPassed($this->deckService->getCardDate($card, $dateField), $now)) { continue; } $assignedUserIds = $this->deckService->getCardAssignedUserIds($card); $labelIds = $this->deckService->getCardLabelIds($card); if (!self::cardMatchesFilters($assignedUserIds, $labelIds, $filterUserIds, $filterLabelIds)) { continue; } $this->logger->info('Card {card} ("{title}") matches workflow {id} on its {dateField} date; moving to stack {target}.', [ 'app' => 'workflow_deck_automation', 'card' => $card->getId(), 'title' => $card->getTitle(), 'id' => $workflow->getId(), 'dateField' => $dateField, '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); } } $workflow->setLastRun($this->timeFactory->getDateTime()); $this->workflowMapper->update($workflow); } /** * 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 regardless of the workflow's notifyEmail setting: that flag 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. $this->mailer->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; } /** * A filter whose every entry has been deleted from the board is not an * error — the workflow still runs, it just cannot match a single card any * more, forever, without anything in the UI or the log saying so. That is * the one failure mode of this app that is completely invisible, so it * gets a log line on every run. * * Explicitly *not* a reason to disable the workflow or mail the user: a * filter with three labels of which one was deleted still works as * intended, and even a fully dead filter may be one board edit away from * being live again. The settings page shows the same condition in red. * * @param string[] $filterUserIds * @param int[] $filterLabelIds */ private function warnAboutDeadFilters(Workflow $workflow, array $filterUserIds, array $filterLabelIds): void { if ($filterUserIds === [] && $filterLabelIds === []) { return; } try { $options = $this->deckService->findFilterOptions($workflow->getBoardId()); } catch (DeckUnavailableException $e) { // Same rule as everywhere else: no answer is not a bad answer. $this->logger->debug('Could not check the filters of workflow {id}: ' . $e->getMessage(), [ 'app' => 'workflow_deck_automation', 'id' => $workflow->getId(), ]); return; } $dead = self::findDeadFilters($filterUserIds, $filterLabelIds, $options['participantUids'], $options['labelIds']); if ($dead !== []) { $this->logger->warning( 'Workflow {id} can no longer match any card: none of its ' . implode('/', $dead) . ' filter entries exists on board {board} any more', [ 'app' => 'workflow_deck_automation', 'id' => $workflow->getId(), 'board' => $workflow->getBoardId(), ], ); } } /** * Whether the card date a workflow watches - its due date or its start date, * see Workflow::DATE_FIELD_* - lies in the past. A card without that date set * is never picked up. * * 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. The same reasoning applies unchanged to the * start date, which is why this is one function and not two. */ public static function hasPassed(?DateTimeInterface $cardDate, DateTimeInterface $now): bool { return $cardDate !== null && $cardDate < $now; } /** * Which filters cannot match anything any more, because not one of their * entries is still offered by the board. An empty filter is not dead, it * is "no restriction on this dimension" — the same reading as in * cardMatchesFilters(), and getting it backwards would report every * unfiltered workflow as broken. * * Static and side-effect free for the same reason as the matching logic: * testable without a Deck installation. * * @param string[] $filterUserIds * @param int[] $filterLabelIds * @param string[] $boardUserIds everyone who can hold a card on the board * @param int[] $boardLabelIds the board's remaining labels * @return string[] any of 'label', 'user', in that order */ public static function findDeadFilters( array $filterUserIds, array $filterLabelIds, array $boardUserIds, array $boardLabelIds, ): array { $dead = []; if ($filterLabelIds !== [] && array_intersect($filterLabelIds, $boardLabelIds) === []) { $dead[] = 'label'; } if ($filterUserIds !== [] && array_intersect($filterUserIds, $boardUserIds) === []) { $dead[] = 'user'; } return $dead; } /** * Pure filter-matching logic (kept static/side-effect free so it can * be unit tested without real Deck objects). Both filters are OR * inside themselves and AND between each other: an empty filter list * means "no restriction on this dimension". * * @param string[] $cardAssignedUserIds * @param int[] $cardLabelIds * @param string[] $filterUserIds * @param int[] $filterLabelIds */ public static function cardMatchesFilters( array $cardAssignedUserIds, array $cardLabelIds, array $filterUserIds, array $filterLabelIds, ): bool { if ($filterUserIds !== [] && array_intersect($filterUserIds, $cardAssignedUserIds) === []) { return false; } if ($filterLabelIds !== [] && array_intersect($filterLabelIds, $cardLabelIds) === []) { return false; } return true; } /** * @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; } }