Build package / php-lint (8.2) (push) Successful in 49s
Build package / php-lint (8.3) (push) Successful in 42s
Build package / php-lint (8.4) (push) Successful in 37s
Build package / xml-lint (push) Successful in 13s
Build package / unit-tests (push) Successful in 44s
Build package / package (push) Successful in 59s
Workflows were being switched off with "its board no longer exists or is
no longer available to you" for boards that were perfectly intact.
Deck does not read the session for permissions. PermissionService -- the
class behind every check, including the ones inside CardService::reorder()
-- takes the current user as a plain `private ?string $userId`, filled
from the app container's `userId` service (ISession::get('user_id'),
registered shared). Pimple resolves that once per process and caches it,
and ServerContainer caches Deck's app container just as long. In cron the
value is null whenever a Deck-owned job ran earlier in the same pass, and
otherwise the first workflow owner touched -- never the user being
impersonated. null fails every check, so Deck answered NoPermissionException
for an untouched board and findStackIds() read that as "the board is gone".
IUserSession::setUser() never had any effect on this path.
- DeckIntegrationService::beginUserContext()/endUserContext() pin
PermissionService (mandatory), CardService, BoardService and
ActivityManager (best effort) to the workflow owner, and restore them
afterwards. If PermissionService cannot be pinned, the runner skips that
user instead of acting under someone else's permissions.
- findStackIds() now takes the uid as an argument and is assembled from
pieces that cannot answer for the wrong user: BoardMapper and StackMapper
carry no user state, and getPermissions() is handed the uid explicitly.
It no longer goes through StackService::findAll().
- NoPermissionException is no longer treated as "board missing". Unknown
failures throw, which leaves the workflow enabled.
This also fixes the second half of the same defect: card moves silently
failed for every user except the first one processed in a cron pass.
Separately, RunWorkflowsJob is now a plain Job instead of a TimedJob and
runs on every cron pass. The workflow_deck_automation.interval config key
is gone. Time sensitivity is no longer merely declared but structurally
unreachable: JobList::add() leaves the column at its TIME_SENSITIVE
default, and the ratchet in setLastRun() only fires for TimedJob.
309 lines
9.7 KiB
PHP
309 lines
9.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\WorkflowDeckAutomation\Service;
|
|
|
|
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();
|
|
|
|
foreach ($cards as $card) {
|
|
if (!self::isOverdue($card->getDaysUntilDue())) {
|
|
continue;
|
|
}
|
|
|
|
$assignedUserIds = $this->deckService->getCardAssignedUserIds($card);
|
|
$labelIds = $this->deckService->getCardLabelIds($card);
|
|
if (!self::cardMatchesFilters($assignedUserIds, $labelIds, $filterUserIds, $filterLabelIds)) {
|
|
continue;
|
|
}
|
|
|
|
$this->moveAndNotify($user, $workflow, $card);
|
|
}
|
|
|
|
$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 moveAndNotify(IUser $user, Workflow $workflow, Card $card): void {
|
|
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;
|
|
}
|
|
|
|
if ($workflow->getNotifyEmail()) {
|
|
$this->mailer->sendCardMovedNotification($user, $workflow, $card);
|
|
}
|
|
}
|
|
|
|
public static function isOverdue(?int $daysUntilDue): bool {
|
|
return $daysUntilDue !== null && $daysUntilDue < 0;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|