Files
nextcloud-workflow-deck-aut…/lib/Service/WorkflowRunner.php
T
Patrick Niebeling 7612344726
Build package / xml-lint (push) Successful in 33s
Build package / php-lint (8.5) (push) Successful in 1m48s
Build package / php-lint (8.4) (push) Successful in 1m50s
Build package / unit-tests (push) Successful in 1m52s
Build package / package (push) Successful in 1m38s
Send moved-card notices as Nextcloud notifications too
Each workflow now has a notify channel (mail, notification or both) next
to its recipient mode. Notifications go out per card through
OCP\Notification\IManager, so they show up in the bell and as push on the
mobile and desktop clients. Without the notifications app nothing is sent.
The one-off "workflow disabled" notice is sent as a notification as well.

Existing workflows keep mail (migration Version1004). Bumps to 35.3.0 so
the migration runs.
2026-09-30 12:31:01 +02:00

494 lines
16 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 overdue cards.
*
* 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;
}
$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<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;
}
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;
}
}