workflowMapper->findAllEnabled(); if ($workflows === []) { return; } $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, ]); } } } /** * 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; } // 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(); 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)) { 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; } /** * 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; } }