Add Deck workflow automation Nextcloud app
Lint info.xml / xml-lint (push) Successful in 30s
Lint PHP / php-lint (8.2) (push) Successful in 55s
Lint PHP / php-lint (8.3) (push) Successful in 44s
Lint PHP / php-lint (8.4) (push) Successful in 41s
PHPUnit / unit-tests (push) Failing after 41s

Ports the standalone due-date cron script into a proper Nextcloud 34
app with a personal-settings UI, per-user workflow configuration
(source/target stack, assigned-user and label filters, email
notification), a TimedJob background runner, and Gitea CI pipelines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Patrick Niebeling
2026-08-13 10:13:05 +02:00
co-authored by Claude Sonnet 5
commit 300acde27e
34 changed files with 1918 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
<?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.
*
* Deck's permission checks read the *live* user session, not a value
* frozen at construction, and this runner has to evaluate rules for many
* different users within a single background-job process. So workflows
* are grouped by owner and, for each owner, we temporarily impersonate
* that user (push their IUser onto the session, restore afterwards) —
* the standard Nextcloud pattern for multi-user cron jobs.
*/
class WorkflowRunner {
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) {
$this->runForUser((string)$userId, $userWorkflows);
}
}
/**
* @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;
}
$this->impersonate($user);
try {
foreach ($workflows as $workflow) {
$this->runWorkflow($user, $workflow);
}
} finally {
$this->clearImpersonation();
}
}
private function runWorkflow(IUser $user, Workflow $workflow): void {
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);
}
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;
}
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',
]);
}
}
private function clearImpersonation(): void {
$this->userSession->setUser($this->impersonationRestore);
$this->impersonationRestore = null;
}
}