> */ 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; } $this->pendingNotices = []; $byUser = []; $owners = []; foreach ($workflows as $workflow) { $this->followBoardOwner($workflow, $owners); $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 $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; } $moved = []; $now = $this->timeFactory->getDateTime(); foreach ($cards as $card) { if (!self::isOverdue($card->getDuedate(), $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; } /** * Compares the card's actual due 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 due date is first * pinned to UTC via asStoredUtc() — see there for why. */ public static function isOverdue(?DateTimeInterface $duedate, DateTimeInterface $now): bool { return $duedate !== null && self::asStoredUtc($duedate) < $now; } /** * Deck's `duedate` is a DATETIME column without a zone that holds 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 due date by its UTC offset. * * Deck itself has two timezone-sensitive paths this deliberately avoids: * Card::getDaysUntilDue() (day-truncated, see isOverdue()) and * CardMapper::findOverdue(), which compares the UTC column against the * database's `NOW()` — the DB session's local time. */ private static function asStoredUtc(DateTimeInterface $duedate): DateTimeImmutable { return new DateTimeImmutable($duedate->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; } }