Files
gnilebein 28b4edc811
Build package / php-lint (8.4) (push) Successful in 43s
Build package / php-lint (8.5) (push) Successful in 40s
Build package / xml-lint (push) Successful in 12s
Build package / unit-tests (push) Successful in 38s
Build package / package (push) Successful in 52s
revert 0e66319a06
revert Let each workflow watch the due date or the start date

Deck 1.18 added a start date to cards (Card::$startdate), so a workflow
no longer has to mean "overdue". Each rule now carries a date_field
('due' | 'start'), chosen in the settings form next to board and stacks.

Existing rules keep the due date: the new column's default supplies it
for every stored row, so there is no backfill step, and
Workflow::getDateFieldOrDue() covers entities that were never near the
database as well as hand-edited values. The controller is the one place
that rejects an unknown value instead of normalising it - a client
asking for a rule it would not get should hear about it.

The probe for the field is property_exists(), not method_exists():
Deck's entities declare no getter as real code - getStartdate(),
getDuedate(), even getId() all go through Entity::__call(), which
method_exists() ignores by definition. A method_exists() guard would
have been false on every Deck version and would have turned every
start-date rule into a silent no-op.

assertDeckAvailable() now also refuses Deck older than 1.18.0. That
floor cannot live in appinfo/info.xml - neither the server's nor the app
store's schema has an app-to-app dependency element - but
<nextcloud min-version="34"> already implies it, since 1.18.x is the
only Deck release line published for Nextcloud 34.

isOverdue() becomes hasPassed(): one function for both dates, since the
comparison and the argument against Deck's day-truncating
getDaysUntilDue() are identical for either.

Also carries a pending NcSelect icon-alignment fix that was already in
the working tree.

Bump to 34.1.0.
2026-08-26 07:10:55 +02:00

429 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Service;
use DateTimeInterface;
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 = [];
foreach ($workflows as $workflow) {
$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,
]);
}
}
}
/**
* @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;
}
$filterUserIds = $workflow->getFilterUserIdsArray();
$filterLabelIds = $workflow->getFilterLabelIdsArray();
$this->warnAboutDeadFilters($workflow, $filterUserIds, $filterLabelIds);
// 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;
}
$assignedUserIds = $this->deckService->getCardAssignedUserIds($card);
$labelIds = $this->deckService->getCardLabelIds($card);
if (!self::cardMatchesFilters($assignedUserIds, $labelIds, $filterUserIds, $filterLabelIds)) {
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;
}
/**
* A filter whose every entry has been deleted from the board is not an
* error — the workflow still runs, it just cannot match a single card any
* more, forever, without anything in the UI or the log saying so. That is
* the one failure mode of this app that is completely invisible, so it
* gets a log line on every run.
*
* Explicitly *not* a reason to disable the workflow or mail the user: a
* filter with three labels of which one was deleted still works as
* intended, and even a fully dead filter may be one board edit away from
* being live again. The settings page shows the same condition in red.
*
* @param string[] $filterUserIds
* @param int[] $filterLabelIds
*/
private function warnAboutDeadFilters(Workflow $workflow, array $filterUserIds, array $filterLabelIds): void {
if ($filterUserIds === [] && $filterLabelIds === []) {
return;
}
try {
$options = $this->deckService->findFilterOptions($workflow->getBoardId());
} catch (DeckUnavailableException $e) {
// Same rule as everywhere else: no answer is not a bad answer.
$this->logger->debug('Could not check the filters of workflow {id}: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
]);
return;
}
$dead = self::findDeadFilters($filterUserIds, $filterLabelIds, $options['participantUids'], $options['labelIds']);
if ($dead !== []) {
$this->logger->warning(
'Workflow {id} can no longer match any card: none of its ' . implode('/', $dead)
. ' filter entries exists on board {board} any more',
[
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
'board' => $workflow->getBoardId(),
],
);
}
}
/**
* 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.
*/
public static function isOverdue(?DateTimeInterface $duedate, DateTimeInterface $now): bool {
return $duedate !== null && $duedate < $now;
}
/**
* Which filters cannot match anything any more, because not one of their
* entries is still offered by the board. An empty filter is not dead, it
* is "no restriction on this dimension" — the same reading as in
* cardMatchesFilters(), and getting it backwards would report every
* unfiltered workflow as broken.
*
* Static and side-effect free for the same reason as the matching logic:
* testable without a Deck installation.
*
* @param string[] $filterUserIds
* @param int[] $filterLabelIds
* @param string[] $boardUserIds everyone who can hold a card on the board
* @param int[] $boardLabelIds the board's remaining labels
* @return string[] any of 'label', 'user', in that order
*/
public static function findDeadFilters(
array $filterUserIds,
array $filterLabelIds,
array $boardUserIds,
array $boardLabelIds,
): array {
$dead = [];
if ($filterLabelIds !== [] && array_intersect($filterLabelIds, $boardLabelIds) === []) {
$dead[] = 'label';
}
if ($filterUserIds !== [] && array_intersect($filterUserIds, $boardUserIds) === []) {
$dead[] = 'user';
}
return $dead;
}
/**
* Pure filter-matching logic (kept static/side-effect free so it can
* be unit tested without real Deck objects). Both filters are OR
* inside themselves and AND between each other: an empty filter list
* means "no restriction on this dimension".
*
* @param string[] $cardAssignedUserIds
* @param int[] $cardLabelIds
* @param string[] $filterUserIds
* @param int[] $filterLabelIds
*/
public static function cardMatchesFilters(
array $cardAssignedUserIds,
array $cardLabelIds,
array $filterUserIds,
array $filterLabelIds,
): bool {
if ($filterUserIds !== [] && array_intersect($filterUserIds, $cardAssignedUserIds) === []) {
return false;
}
if ($filterLabelIds !== [] && array_intersect($filterLabelIds, $cardLabelIds) === []) {
return false;
}
return true;
}
/**
* @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;
}
}