Files
nextcloud-workflow-deck-aut…/lib/Service/WorkflowRunner.php
T
Patrick Niebeling 4464e5ad3c
Build package / xml-lint (push) Successful in 1m55s
Build package / unit-tests (push) Successful in 3m39s
Build package / php-lint (8.4) (push) Successful in 3m45s
Build package / php-lint (8.5) (push) Successful in 3m55s
Build package / package (push) Successful in 3m10s
Detect trashed and archived Deck entities, skip archived boards
Deck's Entity getters are magic (__call), so method_exists() never saw
getDeletedAt() or getArchived(): trashed cards were moved, trashed boards
were neither dropped from "my boards" nor detected by findStackIds(), and
every board counted as not archived. DeckIntegrationService now checks the
properties with property_exists() via isTrashed()/isArchived().

Workflows on archived boards stay enabled but are skipped by the runner
(isBoardArchived(), read through BoardMapper) until the board is
unarchived, and the settings list marks them. Archived boards are no
longer offered in the board dropdown at all; editing a workflow on one
clears board and stacks and says why.
2026-10-01 13:54:03 +02:00

545 lines
19 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;
}
// An archived board is intact, just shelved: the workflow is skipped,
// not disabled, and resumes once the board is unarchived.
try {
$archived = $this->deckService->isBoardArchived($workflow->getBoardId());
} catch (DeckUnavailableException $e) {
$this->logger->warning('Could not check whether the board of workflow {id} is archived: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
]);
return;
}
if ($archived) {
$this->logger->debug('Skipping workflow {id}: its board is archived', [
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
]);
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;
}
}