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
+230
View File
@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Service;
use OCA\Deck\Db\Card;
use OCA\Deck\Db\CardMapper;
use OCA\Deck\Service\BoardService;
use OCA\Deck\Service\CardService;
use OCA\Deck\Service\StackService;
use OCP\App\IAppManager;
use OCP\IUser;
use OCP\Server;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* Single choke point for everything this app does with Deck.
*
* Deck's OCA\Deck\* classes are not a documented/stable public API (no
* @since markers, no <public> declaration in its info.xml). We use them
* directly anyway, per explicit product requirement (no HTTP/OCS calls
* against Deck are allowed), but every resolution and call is guarded so a
* Deck-side change degrades a single workflow instead of crashing the
* whole background run.
*/
class DeckIntegrationService {
private const DECK_APP_ID = 'deck';
public function __construct(
private IAppManager $appManager,
private LoggerInterface $logger,
) {
}
public function assertDeckAvailable(IUser $user): void {
if (!$this->appManager->isEnabledForUser(self::DECK_APP_ID, $user)) {
throw new DeckUnavailableException('Deck is not enabled for user ' . $user->getUID());
}
if (!class_exists(CardService::class) || !class_exists(BoardService::class) || !class_exists(StackService::class)) {
throw new DeckUnavailableException('Deck internal classes are not available');
}
}
/**
* @return array<int, array{id: int, title: string}>
*/
public function listBoardsForCurrentUser(): array {
$boards = $this->call(function () {
return $this->resolve(BoardService::class)->getUserBoards();
}, []);
return array_map(
static fn ($board) => ['id' => $board->getId(), 'title' => $board->getTitle()],
$boards,
);
}
/**
* @return array<int, array{id: int, title: string}>
*/
public function listStacks(int $boardId): array {
$stacks = $this->call(function () use ($boardId) {
return $this->resolve(StackService::class)->findAll($boardId);
}, []);
return array_map(
static fn ($stack) => ['id' => $stack->getId(), 'title' => $stack->getTitle()],
$stacks,
);
}
/**
* @return array<int, array{id: int, title: string, color: string|null}>
*/
public function listLabels(int $boardId): array {
$labels = $this->call(function () use ($boardId) {
$board = $this->resolve(BoardService::class)->find($boardId, true);
return $board->getLabels() ?? [];
}, []);
return array_map(
static fn ($label) => ['id' => $label->getId(), 'title' => $label->getTitle(), 'color' => $label->getColor()],
$labels,
);
}
/**
* @return array<int, array{uid: string, displayName: string}>
*/
public function listParticipants(int $boardId): array {
$acl = $this->call(function () use ($boardId) {
$board = $this->resolve(BoardService::class)->find($boardId, true);
return $board->getAcl() ?? [];
}, []);
$participants = [];
foreach ($acl as $entry) {
$uid = $this->extractParticipantUid($entry);
if ($uid !== null && !isset($participants[$uid])) {
$participants[$uid] = ['uid' => $uid, 'displayName' => $uid];
}
}
return array_values($participants);
}
/**
* Cards in the given stack that are neither archived nor marked done,
* enriched so getAssignedUsers()/getLabels() are populated.
*
* @return Card[]
*/
public function getActiveCardsInStack(int $stackId): array {
return $this->call(function () use ($stackId) {
$cardMapper = $this->resolve(CardMapper::class);
$cardService = $this->resolve(CardService::class);
$cards = $cardMapper->findAllByStack($stackId);
$cards = $cardService->enrichCards($cards);
return array_values(array_filter($cards, static function (Card $card) {
return !$card->getArchived() && $card->getDone() === null;
}));
}, []);
}
/**
* @return string[]
*/
public function getCardAssignedUserIds(Card $card): array {
$assigned = $card->getAssignedUsers() ?? [];
$uids = [];
foreach ($assigned as $entry) {
$uid = $this->extractParticipantUid($entry);
if ($uid !== null) {
$uids[] = $uid;
}
}
return $uids;
}
/**
* @return int[]
*/
public function getCardLabelIds(Card $card): array {
$labels = $card->getLabels() ?? [];
$ids = [];
foreach ($labels as $label) {
if (method_exists($label, 'getId')) {
$ids[] = (int)$label->getId();
}
}
return $ids;
}
/**
* Moves a card to another stack using Deck's own reorder logic
* (the exact same code path behind Deck's "move card" action).
*/
public function moveCard(int $cardId, int $targetStackId): void {
$this->call(function () use ($cardId, $targetStackId) {
$this->resolve(CardService::class)->reorder($cardId, $targetStackId, 0);
return null;
}, null, true);
}
/**
* @template T
* @param callable(): T $callback
* @param T $fallback
* @return T
*/
private function call(callable $callback, mixed $fallback, bool $rethrow = false) {
try {
return $callback();
} catch (DeckUnavailableException $e) {
throw $e;
} catch (Throwable $e) {
$this->logger->error('Deck integration call failed: ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'exception' => $e,
]);
if ($rethrow) {
throw new DeckUnavailableException('Deck call failed: ' . $e->getMessage(), 0, $e);
}
return $fallback;
}
}
/**
* @template T of object
* @param class-string<T> $class
* @return T
*/
private function resolve(string $class) {
if (!class_exists($class)) {
throw new DeckUnavailableException("Deck class {$class} does not exist");
}
return Server::get($class);
}
/**
* Deck's assignment/ACL entries are internal, undocumented value
* objects whose exact accessor shape has changed across versions, so
* we probe the common accessor names defensively instead of relying
* on one fixed method signature.
*/
private function extractParticipantUid(mixed $entry): ?string {
if (is_string($entry)) {
return $entry;
}
if (!is_object($entry)) {
return null;
}
foreach (['getParticipant', 'getUid', 'getParticipantUid', 'getUserId'] as $method) {
if (method_exists($entry, $method)) {
$value = $entry->$method();
if (is_string($value) && $value !== '') {
return $value;
}
if (is_object($value) && method_exists($value, 'getUID')) {
return $value->getUID();
}
}
}
return null;
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Service;
/**
* Thrown whenever the Deck app is disabled for a user, or its internal
* classes could not be resolved. Deck's OCA\Deck\* classes are not a
* documented public API, so every access point is wrapped and normalised
* into this exception instead of leaking Deck-internal exception types.
*/
class DeckUnavailableException extends \RuntimeException {
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Service;
use OCA\Deck\Db\Card;
use OCA\WorkflowDeckAutomation\Db\Workflow;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\Mail\IMailer;
use OCP\Util;
use Psr\Log\LoggerInterface;
use Throwable;
class NotificationMailer {
public function __construct(
private IMailer $mailer,
private IURLGenerator $urlGenerator,
private IL10N $l10n,
private LoggerInterface $logger,
) {
}
public function sendCardMovedNotification(IUser $user, Workflow $workflow, Card $card): void {
$email = $user->getEMailAddress();
if ($email === null || $email === '') {
$this->logger->info('Skipping notification for workflow ' . $workflow->getId() . ': user ' . $user->getUID() . ' has no email address', [
'app' => 'workflow_deck_automation',
]);
return;
}
try {
$cardLink = $this->urlGenerator->getAbsoluteURL(
'/apps/deck/board/' . $workflow->getBoardId() . '/card/' . $card->getId(),
);
$template = $this->mailer->createEMailTemplate('workflow_deck_automation.CardMoved', [
'cardTitle' => $card->getTitle(),
'workflowTitle' => $workflow->getTitle(),
]);
$template->setSubject($this->l10n->t('Deck card moved: %s', [$card->getTitle()]));
$template->addHeader();
$template->addHeading($this->l10n->t('A card was moved automatically'), false);
$template->addBodyText($this->l10n->t(
'The card "%1$s" was moved because it is overdue (workflow "%2$s").',
[$card->getTitle(), $workflow->getTitle()],
));
$template->addBodyButton($this->l10n->t('Open card'), $cardLink);
$template->addFooter();
$message = $this->mailer->createMessage();
$message->setTo([$email => $user->getDisplayName()]);
$message->setFrom([Util::getDefaultEmailAddress('noreply') => $this->l10n->t('Deck Workflow Automation')]);
$message->useTemplate($template);
$failedRecipients = $this->mailer->send($message);
if (!empty($failedRecipients)) {
$this->logger->error('Notification mail for card ' . $card->getId() . ' failed for: ' . implode(', ', $failedRecipients), [
'app' => 'workflow_deck_automation',
]);
}
} catch (Throwable $e) {
$this->logger->error('Could not send notification mail for card ' . $card->getId() . ': ' . $e->getMessage(), [
'app' => 'workflow_deck_automation',
'exception' => $e,
]);
}
}
}
+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;
}
}