Build package / xml-lint (push) Successful in 3m32s
Build package / unit-tests (push) Successful in 4m49s
Build package / php-lint (8.5) (push) Successful in 4m52s
Build package / php-lint (8.4) (push) Successful in 4m56s
Build package / package (push) Successful in 2m28s
Each workflow now has a trigger date (due or start): the runner compares either the card's due date or its start date against now, read straight from Deck's Card entity. Mails and Nextcloud notifications say whether the card was overdue or reached its start date. Cards without the chosen date stay put. Deck only has a start date since 1.18 and Entity getters are magic, so DeckIntegrationService::supportsStartdate() checks the property; without it the form rejects start-date workflows and the runner skips them without disabling them. WorkflowRunner::isOverdue() becomes hasPassed(), as it now covers both dates. Existing workflows keep the due date (migration Version1005). Bumps to 35.4.0 so the migration runs.
512 lines
17 KiB
PHP
512 lines
17 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;
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
$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;
|
|
}
|
|
}
|