Detect trashed and archived Deck entities, skip archived boards
Build package / xml-lint (push) Successful in 1m55s
Build package / unit-tests (push) Successful in 3m39s
Build package / php-lint (8.4) (push) Successful in 3m45s
Build package / php-lint (8.5) (push) Successful in 3m55s
Build package / package (push) Successful in 3m10s

Deck's Entity getters are magic (__call), so method_exists() never saw
getDeletedAt() or getArchived(): trashed cards were moved, trashed boards
were neither dropped from "my boards" nor detected by findStackIds(), and
every board counted as not archived. DeckIntegrationService now checks the
properties with property_exists() via isTrashed()/isArchived().

Workflows on archived boards stay enabled but are skipped by the runner
(isBoardArchived(), read through BoardMapper) until the board is
unarchived, and the settings list marks them. Archived boards are no
longer offered in the board dropdown at all; editing a workflow on one
clears board and stacks and says why.
This commit is contained in:
Patrick Niebeling committed 2026-10-01 13:54:03 +02:00
1 parent 9e1a15bab0
commit 4464e5ad3c
5 files changed
+109 -17

No files matched your search

+51 -5
View File
@@ -172,6 +172,26 @@ class DeckIntegrationService {
return class_exists(Card::class) && property_exists(Card::class, 'startdate');
}
/**
* Whether a Deck board or card sits in Deck's trash (`deleted_at > 0`).
*
* Read through property_exists(), never method_exists(): Entity getters
* are magic (`__call`), so method_exists() is always false for them —
* which is how trashed cards used to be moved and trashed boards went
* unnoticed.
*/
private static function isTrashed(object $entity): bool {
return property_exists($entity, 'deletedAt') && (int)$entity->getDeletedAt() > 0;
}
/**
* Whether a Deck board is archived. Same magic-getter caveat as
* isTrashed().
*/
private static function isArchived(object $entity): bool {
return property_exists($entity, 'archived') && (bool)$entity->getArchived();
}
/**
* The boards `$userId` owns — the only boards workflows may live on, and
* the boards whose workflows that user sees and manages.
@@ -181,7 +201,8 @@ class DeckIntegrationService {
* the frozen-uid state of BoardService::getUserBoards(). It does return
* archived and trashed boards; trashed ones are dropped here, archived
* ones are kept but flagged — their workflows still exist and must stay
* visible, they just aren't offered as a new target.
* visible, but the board is no target for a new or changed workflow, and
* the runner skips it.
*
* Throws instead of degrading to `[]`: an empty list would read as "you
* own no boards" and hide every workflow the user has.
@@ -198,7 +219,7 @@ class DeckIntegrationService {
$result = [];
foreach ($boards as $board) {
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
if (self::isTrashed($board)) {
continue;
}
$id = (int)$board->getId();
@@ -206,7 +227,7 @@ class DeckIntegrationService {
$result[] = [
'id' => $id,
'title' => (is_string($title) && $title !== '') ? $title : ('#' . $id),
'archived' => method_exists($board, 'getArchived') && (bool)$board->getArchived(),
'archived' => self::isArchived($board),
];
}
@@ -253,7 +274,7 @@ class DeckIntegrationService {
public function findStackIds(int $boardId, string $userId): ?array {
try {
$board = $this->resolve(BoardMapper::class)->find($boardId);
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
if (self::isTrashed($board)) {
return null;
}
@@ -283,6 +304,31 @@ class DeckIntegrationService {
return array_map(static fn ($stack) => (int)$stack->getId(), $stacks);
}
/**
* Whether the board is archived — workflows on archived boards stay
* stored and enabled but move nothing until the board is unarchived.
*
* Read through BoardMapper like findBoardOwner(). `false` if the board
* row is gone, so findBrokenTarget() still gets to disable the workflow;
* every other failure throws.
*
* @throws DeckUnavailableException
*/
public function isBoardArchived(int $boardId): bool {
try {
$board = $this->resolve(BoardMapper::class)->find($boardId);
} catch (DeckUnavailableException $e) {
throw $e;
} catch (Throwable $e) {
if ($this->isMissing($e)) {
return false;
}
throw new DeckUnavailableException('Could not read board ' . $boardId . ': ' . $e->getMessage(), 0, $e);
}
return self::isArchived($board);
}
/**
* The uid of the board's current owner — the user a workflow on that
* board belongs to. Workflows follow the board: when Deck transfers its
@@ -462,7 +508,7 @@ class DeckIntegrationService {
$cards = $this->resolve(CardMapper::class)->findAllByStack($stackId);
return array_values(array_filter($cards, static function (Card $card) {
if (method_exists($card, 'getDeletedAt') && (int)$card->getDeletedAt() > 0) {
if (self::isTrashed($card)) {
return false;
}
return !$card->getArchived() && $card->getDone() === null;