Add Deck workflow automation Nextcloud app
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user