Tie workflows to the board owner instead of their creator
Build package / php-lint (8.4) (push) Successful in 2m22s
Build package / php-lint (8.5) (push) Successful in 57s
Build package / xml-lint (push) Successful in 18s
Build package / unit-tests (push) Successful in 1m5s
Build package / package (push) Successful in 1m21s
Build package / php-lint (8.4) (push) Successful in 2m22s
Build package / php-lint (8.5) (push) Successful in 57s
Build package / xml-lint (push) Successful in 18s
Build package / unit-tests (push) Successful in 1m5s
Build package / package (push) Successful in 1m21s
A workflow now belongs to whoever owns its board. The settings page lists, edits and deletes workflows by board ownership alone, using Deck's BoardMapper::findAllByOwner(), a single owner query that never returns shared boards and carries no frozen uid. A transferred board's workflows therefore show up for the new owner immediately. The background job still needs one user to run as and mail, so it looks up the board owner before every run and stores it in user_id when it changed. Archived boards stay listed so their workflows keep a name, but are no longer offered or accepted as a target.
This commit is contained in:
1 parent
ada16cb101
commit
e84123654e
6 files changed
+185
-72
No files matched your search
@@ -172,59 +172,44 @@ class DeckIntegrationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: int, title: string}>
|
||||
* The boards `$userId` owns — the only boards workflows may live on, and
|
||||
* the boards whose workflows that user sees and manages.
|
||||
*
|
||||
* Only boards owned by `$userId` — workflows may only be created on those,
|
||||
* so boards merely shared with the user are left out entirely.
|
||||
* One query: BoardMapper::findAllByOwner() is `WHERE owner = :uid`, so
|
||||
* shared boards never come back, and as a plain QBMapper it has none of
|
||||
* 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.
|
||||
*
|
||||
* Only ever call this from a *request*: getUserBoards() reads the uid
|
||||
* BoardService froze at construction time, and beginUserContext() cannot
|
||||
* help here — it repoints that field, but Deck may already have cached
|
||||
* board lists behind it, and the job has no use for a board *list*
|
||||
* anyway. It asks findStackIds() about one known board id instead.
|
||||
* Throws instead of degrading to `[]`: an empty list would read as "you
|
||||
* own no boards" and hide every workflow the user has.
|
||||
*
|
||||
* @return array<int, array{id: int, title: string, archived: bool}>
|
||||
* @throws DeckUnavailableException
|
||||
*/
|
||||
public function listBoardsForCurrentUser(string $userId): array {
|
||||
$boards = $this->call(function () {
|
||||
$boardService = $this->resolve(BoardService::class);
|
||||
try {
|
||||
// getUserBoards(?int $since, bool $includeArchived, …): with
|
||||
// the default $includeArchived = true, Deck skips the
|
||||
// `archived = false AND deleted_at = 0` conditions entirely,
|
||||
// so archived *and* trashed boards come back. Ask for the
|
||||
// filtered query instead of sorting them out afterwards.
|
||||
return $boardService->getUserBoards(null, false);
|
||||
} catch (Throwable $e) {
|
||||
// Older/newer Deck with a different signature: fall back to
|
||||
// the unfiltered call, isBoardHidden() below still filters.
|
||||
return $boardService->getUserBoards();
|
||||
}
|
||||
}, []);
|
||||
public function listOwnedBoards(string $userId): array {
|
||||
$boards = $this->call(
|
||||
fn () => $this->resolve(BoardMapper::class)->findAllByOwner($userId),
|
||||
[],
|
||||
true,
|
||||
);
|
||||
|
||||
// Deck merges own/group/circle boards, so the same board can come
|
||||
// back more than once; boards in the trash or archived ones are no
|
||||
// useful automation target, and shared ones are not allowed. Keyed
|
||||
// by id => deduplicated.
|
||||
$result = [];
|
||||
foreach ($boards as $board) {
|
||||
$id = (int)$board->getId();
|
||||
if (isset($result[$id]) || $this->isBoardHidden($board) || $this->unwrapUid($board->getOwner()) !== $userId) {
|
||||
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
|
||||
continue;
|
||||
}
|
||||
$id = (int)$board->getId();
|
||||
$title = $board->getTitle();
|
||||
$result[$id] = [
|
||||
$result[] = [
|
||||
'id' => $id,
|
||||
'title' => (is_string($title) && $title !== '') ? $title : ('#' . $id),
|
||||
'archived' => method_exists($board, 'getArchived') && (bool)$board->getArchived(),
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($result);
|
||||
}
|
||||
|
||||
private function isBoardHidden(mixed $board): bool {
|
||||
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
|
||||
return true;
|
||||
}
|
||||
return method_exists($board, 'getArchived') && $board->getArchived() === true;
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,6 +282,37 @@ class DeckIntegrationService {
|
||||
return array_map(static fn ($stack) => (int)$stack->getId(), $stacks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* ownership, they move to the new owner along with it.
|
||||
*
|
||||
* Read through BoardMapper, a plain QBMapper without user state, so it
|
||||
* answers the same in a request and in cron. Trashed and archived boards
|
||||
* still report their owner. `null` only if the board row is gone; every
|
||||
* other failure throws, like findStackIds().
|
||||
*
|
||||
* @throws DeckUnavailableException
|
||||
*/
|
||||
public function findBoardOwner(int $boardId): ?string {
|
||||
try {
|
||||
$board = $this->resolve(BoardMapper::class)->find($boardId);
|
||||
} catch (DeckUnavailableException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
if ($this->isMissing($e)) {
|
||||
return null;
|
||||
}
|
||||
throw new DeckUnavailableException('Could not read owner of board ' . $boardId . ': ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
$owner = $this->unwrapUid($board->getOwner());
|
||||
if ($owner === null) {
|
||||
throw new DeckUnavailableException('Board ' . $boardId . ' has no readable owner');
|
||||
}
|
||||
return $owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deck's own exception classes are referenced by name: `is_a()` with a
|
||||
* string simply returns false when the class does not exist, so a missing
|
||||
|
||||
@@ -55,7 +55,9 @@ class WorkflowRunner {
|
||||
}
|
||||
|
||||
$byUser = [];
|
||||
$owners = [];
|
||||
foreach ($workflows as $workflow) {
|
||||
$this->followBoardOwner($workflow, $owners);
|
||||
$byUser[$workflow->getUserId()][] = $workflow;
|
||||
}
|
||||
|
||||
@@ -78,6 +80,55 @@ class WorkflowRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A workflow belongs to its board's owner, not to whoever created it: if
|
||||
* Deck transferred the board, the workflow moves along to the new owner —
|
||||
* it then runs as them, and they get its mails.
|
||||
*
|
||||
* Only a known owner rewrites `user_id`. A board that is gone keeps its
|
||||
* last owner, so findBrokenTarget() can disable the workflow and mail
|
||||
* them; a Deck error keeps it too, and the run just goes ahead as before.
|
||||
*
|
||||
* @param array<int, ?string> $owners per-run cache, board id => owner
|
||||
*/
|
||||
private function followBoardOwner(Workflow $workflow, array &$owners): void {
|
||||
$boardId = $workflow->getBoardId();
|
||||
if (!array_key_exists($boardId, $owners)) {
|
||||
try {
|
||||
$owners[$boardId] = $this->deckService->findBoardOwner($boardId);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning('Could not determine the owner of board {board}: ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'board' => $boardId,
|
||||
]);
|
||||
$owners[$boardId] = null;
|
||||
}
|
||||
}
|
||||
|
||||
$owner = $owners[$boardId];
|
||||
if ($owner === null || $owner === $workflow->getUserId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->info('Workflow {id} follows board {board} to its new owner {owner}.', [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'id' => $workflow->getId(),
|
||||
'board' => $boardId,
|
||||
'owner' => $owner,
|
||||
]);
|
||||
$workflow->setUserId($owner);
|
||||
try {
|
||||
$this->workflowMapper->update($workflow);
|
||||
} catch (Throwable $e) {
|
||||
// Not persisted, but this run still goes to the right user; the
|
||||
// next one tries again.
|
||||
$this->logger->warning('Could not store the new owner of workflow {id}: ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'id' => $workflow->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Workflow[] $workflows
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user