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; } $this->impersonate($user); 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($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(); foreach ($cards as $card) { if (!self::isOverdue($card->getDaysUntilDue())) { continue; } $assignedUserIds = $this->deckService->getCardAssignedUserIds($card); $labelIds = $this->deckService->getCardLabelIds($card); if (!self::cardMatchesFilters($assignedUserIds, $labelIds, $filterUserIds, $filterLabelIds)) { continue; } $this->moveAndNotify($user, $workflow, $card); } $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(Workflow $workflow): ?string { $stackIds = $this->deckService->findStackIds($workflow->getBoardId()); 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 moveAndNotify(IUser $user, Workflow $workflow, Card $card): void { 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; } if ($workflow->getNotifyEmail()) { $this->mailer->sendCardMovedNotification($user, $workflow, $card); } } public static function isOverdue(?int $daysUntilDue): bool { return $daysUntilDue !== null && $daysUntilDue < 0; } /** * 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; } 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', ]); } } private function clearImpersonation(): void { $this->userSession->setUser($this->impersonationRestore); $this->impersonationRestore = null; } }