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>
186 lines
5.3 KiB
PHP
186 lines
5.3 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.
|
|
*
|
|
* 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;
|
|
}
|
|
}
|