Files
Patrick Niebeling 9e1a15bab0
Build package / xml-lint (push) Successful in 1m4s
Build package / php-lint (8.5) (push) Successful in 4m0s
Build package / unit-tests (push) Successful in 4m13s
Build package / php-lint (8.4) (push) Successful in 4m34s
Build package / package (push) Successful in 1m38s
Refuse workflows that move cards in a loop
Every enabled workflow is an edge "source stack -> target stack";
LoopDetector finds circles in that graph, a workflow whose source is its
own target being the shortest. Without a check, two workflows A -> B and
B -> A move the same cards back and forth on every run, even within a
single run, with a notice for each move. The trigger date is ignored on
purpose: by start date one way and by due date the other, a card with
both dates passed still circles. Chains stay allowed.

The controller rejects saving an enabled workflow that would close a loop
and names the workflows involved. Workflows stored before this check are
skipped by the runner (logged, not disabled) and flagged in the settings
list via a new `loop` field from index().
2026-10-01 13:47:32 +02:00

279 lines
9.8 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Controller;
use OCA\WorkflowDeckAutomation\Db\Workflow;
use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
use OCA\WorkflowDeckAutomation\Service\DeckIntegrationService;
use OCA\WorkflowDeckAutomation\Service\DeckUnavailableException;
use OCA\WorkflowDeckAutomation\Service\LoopDetector;
use OCA\WorkflowDeckAutomation\Service\NotificationPolicy;
use OCA\WorkflowDeckAutomation\Service\TriggerDate;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSBadRequestException;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCS\OCSPreconditionFailedException;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserSession;
class WorkflowController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private WorkflowMapper $workflowMapper,
private DeckIntegrationService $deckService,
private IUserSession $userSession,
) {
parent::__construct($appName, $request);
}
private function currentUserId(): string {
$user = $this->userSession->getUser();
if ($user === null) {
throw new OCSPreconditionFailedException('Nicht angemeldet');
}
return $user->getUID();
}
/**
* Every workflow on a board the user owns, no matter who created it: a
* workflow belongs to its board's owner, and follows the board when
* Deck transfers it.
*/
#[NoAdminRequired]
public function index(): DataResponse {
$workflows = $this->workflowMapper->findAllForBoards($this->ownedBoardIds());
// validate() keeps new loops out, but workflows stored before that
// check can still form one; the runner skips them, the list says why.
$looping = LoopDetector::findLoopingWorkflows(LoopDetector::edgesOf(
array_filter($workflows, static fn (Workflow $w) => (bool)$w->getEnabled()),
));
return new DataResponse(array_map(
static fn (Workflow $w) => $w->jsonSerialize() + ['loop' => in_array((int)$w->getId(), $looping, true)],
$workflows,
));
}
#[NoAdminRequired]
public function create(
string $title,
int $boardId,
int $sourceStackId,
int $targetStackId,
string $triggerDate = TriggerDate::DUE,
string $notifyMode = NotificationPolicy::MODE_OFF,
string $notifyChannel = NotificationPolicy::CHANNEL_MAIL,
bool $enabled = true,
): DataResponse {
$this->validate(null, $title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel, $enabled);
$workflow = new Workflow();
$workflow->setUserId($this->currentUserId());
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel, $enabled);
$workflow = $this->workflowMapper->insert($workflow);
return new DataResponse($workflow->jsonSerialize(), Http::STATUS_CREATED);
}
#[NoAdminRequired]
public function update(
int $id,
string $title,
int $boardId,
int $sourceStackId,
int $targetStackId,
string $triggerDate = TriggerDate::DUE,
string $notifyMode = NotificationPolicy::MODE_OFF,
string $notifyChannel = NotificationPolicy::CHANNEL_MAIL,
bool $enabled = true,
): DataResponse {
$workflow = $this->findOwnWorkflow($id);
$this->validate($id, $title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel, $enabled);
// Kept in step with the board owner so the job runs as, and mails,
// the right user; validate() made sure the editor owns the board.
$workflow->setUserId($this->currentUserId());
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel, $enabled);
$workflow = $this->workflowMapper->update($workflow);
return new DataResponse($workflow->jsonSerialize());
}
#[NoAdminRequired]
public function destroy(int $id): DataResponse {
$workflow = $this->findOwnWorkflow($id);
$this->workflowMapper->delete($workflow);
return new DataResponse([]);
}
#[NoAdminRequired]
public function boards(): DataResponse {
$this->assertDeck();
return new DataResponse($this->ownedBoards());
}
#[NoAdminRequired]
public function stacks(int $boardId): DataResponse {
$this->assertDeck();
return new DataResponse($this->deckService->listStacks($boardId));
}
private function findOwnWorkflow(int $id): Workflow {
try {
$workflow = $this->workflowMapper->findById($id);
} catch (DoesNotExistException $e) {
throw new OCSNotFoundException('Workflow nicht gefunden');
}
if (!in_array($workflow->getBoardId(), $this->ownedBoardIds(), true)) {
// Same answer as for a missing id: don't reveal other users' workflows.
throw new OCSNotFoundException('Workflow nicht gefunden');
}
return $workflow;
}
/**
* @return array<int, array{id: int, title: string, archived: bool}>
*/
private function ownedBoards(): array {
$userId = $this->currentUserId();
$this->assertDeck();
try {
return $this->deckService->listOwnedBoards($userId);
} catch (DeckUnavailableException $e) {
throw new OCSPreconditionFailedException('Deine Deck-Boards konnten nicht geladen werden: ' . $e->getMessage());
}
}
/**
* @return int[]
*/
private function ownedBoardIds(): array {
return array_map(static fn (array $board) => $board['id'], $this->ownedBoards());
}
private function assertDeck(): void {
$user = $this->userSession->getUser();
if ($user === null) {
throw new OCSPreconditionFailedException('Nicht angemeldet');
}
try {
$this->deckService->assertDeckAvailable($user);
} catch (DeckUnavailableException $e) {
throw new OCSPreconditionFailedException('Deck ist nicht verfügbar: ' . $e->getMessage());
}
}
/**
* @param ?int $id the workflow being changed, `null` for a new one
*/
private function validate(?int $id, string $title, int $boardId, int $sourceStackId, int $targetStackId, string $triggerDate, string $notifyMode, string $notifyChannel, bool $enabled): void {
if (trim($title) === '') {
throw new OCSBadRequestException('Bitte einen Titel angeben.');
}
if ($sourceStackId === $targetStackId) {
throw new OCSBadRequestException('Quell- und Ziel-Stapel müssen sich unterscheiden.');
}
if (!TriggerDate::isValid($triggerDate)) {
throw new OCSBadRequestException('Ungültiges Datum für die Verschiebung, erlaubt sind: ' . implode(', ', TriggerDate::ALL));
}
if (!NotificationPolicy::isValidMode($notifyMode)) {
throw new OCSBadRequestException('Ungültige Benachrichtigungs-Einstellung, erlaubt sind: ' . implode(', ', NotificationPolicy::MODES));
}
if (!NotificationPolicy::isValidChannel($notifyChannel)) {
throw new OCSBadRequestException('Ungültiger Benachrichtigungsweg, erlaubt sind: ' . implode(', ', NotificationPolicy::CHANNELS));
}
// Reject references to boards or stacks that are gone (or were never
// the user's) instead of storing a workflow that can only fail later.
// assertDeck() ran first, so an empty list here means "not there",
// not "Deck is unreachable".
$this->assertDeck();
if ($triggerDate === TriggerDate::START && !$this->deckService->supportsStartdate()) {
throw new OCSBadRequestException('Die installierte Deck-Version kennt kein Startdatum.');
}
// Only boards the user owns are listed, so this also enforces the
// owner rule. Archived boards stay listed (their workflows must stay
// visible) but are no target for a new or changed workflow.
$board = null;
foreach ($this->ownedBoards() as $candidate) {
if ($candidate['id'] === $boardId) {
$board = $candidate;
break;
}
}
if ($board === null || $board['archived']) {
throw new OCSBadRequestException('Das gewählte Board existiert nicht, ist archiviert oder gehört dir nicht.');
}
$stackIds = array_map(
static fn (array $stack) => (int)$stack['id'],
$this->deckService->listStacks($boardId),
);
if (!in_array($sourceStackId, $stackIds, true) || !in_array($targetStackId, $stackIds, true)) {
throw new OCSBadRequestException('Der gewählte Stapel existiert auf diesem Board nicht.');
}
// A disabled workflow moves nothing and can close no loop; it is
// checked again when it gets switched on, which also goes through here.
if ($enabled) {
$this->assertNoLoop($id, $boardId, $sourceStackId, $targetStackId);
}
}
/**
* Rejects a workflow whose target stack leads back to its source through
* the board's other enabled workflows — cards would circle forever. See
* LoopDetector.
*/
private function assertNoLoop(?int $id, int $boardId, int $sourceStackId, int $targetStackId): void {
$others = [];
foreach ($this->workflowMapper->findAllForBoards([$boardId]) as $other) {
if ($other->getId() !== $id && $other->getEnabled()) {
$others[(int)$other->getId()] = $other;
}
}
$path = LoopDetector::findPath(LoopDetector::edgesOf($others), $targetStackId, $sourceStackId);
if ($path === null) {
return;
}
$titles = array_map(static fn (int $otherId) => '„' . $others[$otherId]->getTitle() . '“', $path);
throw new OCSBadRequestException(sprintf(
'Zusammen mit %s würde dieser Workflow eine Schleife bilden, in der Karten immer wieder hin- und hergeschoben werden. Wähle einen anderen Ziel-Stapel oder deaktiviere einen der Workflows.',
implode(', ', $titles),
));
}
private function applyFields(
Workflow $workflow,
string $title,
int $boardId,
int $sourceStackId,
int $targetStackId,
string $triggerDate,
string $notifyMode,
string $notifyChannel,
bool $enabled,
): void {
$workflow->setTitle($title);
$workflow->setBoardId($boardId);
$workflow->setSourceStackId($sourceStackId);
$workflow->setTargetStackId($targetStackId);
$workflow->setTriggerDate($triggerDate);
$workflow->setNotifyMode($notifyMode);
$workflow->setNotifyChannel($notifyChannel);
$workflow->setEnabled($enabled);
}
}