Files
nextcloud-workflow-deck-aut…/lib/Service/WorkflowRunner.php
T
Patrick Niebeling 27717205cd Read Deck due dates as UTC regardless of PHP's default timezone
Deck stores duedate as a zoneless DATETIME holding UTC wall-clock time,
and Entity hydrates it in PHP's default zone. That is only correct
because Nextcloud forces the default to UTC; pin the wall clock to UTC
explicitly so the overdue check compares real instants either way.
2026-09-30 10:27:13 +02:00

390 lines
13 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;
public function __construct(
private WorkflowMapper $workflowMapper,
private DeckIntegrationService $deckService,
private NotificationMailer $mailer,
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;
}
$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<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;
}
// 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;
}
}