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 */ 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); } }