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'; /** * Deck's Acl::PERMISSION_READ, inlined so a moved or renamed constant * cannot fatal this class. It is the key under which * PermissionService::getPermissions() reports read access. */ private const ACL_PERMISSION_READ = 0; /** * The property through which Deck carries "the current user" — a plain * string frozen at construction rather than a session lookup. See * beginUserContext(). */ private const IDENTITY_PROPERTY = 'userId'; /** * Further Deck classes holding that same frozen uid. Unlike * PermissionService these do not decide access, only whose name ends up * on an activity entry or whose unread-comment counts are computed, so * failing to pin them is logged and shrugged off. */ private const OPTIONAL_IDENTITY_CLASSES = [ CardService::class, BoardService::class, 'OCA\Deck\Activity\ActivityManager', ]; /** * @var list */ private array $identityRestore = []; public function __construct( private IAppManager $appManager, private IConfig $config, private LoggerInterface $logger, ) { } /** * Pins Deck to $user until endUserContext() — mandatory before any Deck * call made on behalf of someone who is not the logged-in web user. * * IUserSession::setUser() alone is not enough, and that is not obvious: * Deck's PermissionService — the class behind *every* permission check, * including the ones inside CardService::reorder() — receives the current * user as `private ?string $userId`. The app container fills that from * `ISession::get('user_id')` through a **shared** service, so Pimple * computes it once and caches it for the rest of the process, and * ServerContainer keeps the app container just as long. Whoever was * active when Deck's container first built PermissionService is therefore * the user every later check is evaluated against. * * In the background job that is never the user we are impersonating: it * is `null` whenever a Deck-owned job ran earlier in the same cron pass * (cron.php works through many jobs in one process), and otherwise the * first workflow owner we happened to touch. `null` fails every check, * which is how intact boards started reporting NoPermissionException and * got their workflows switched off. * * Deck exposes no API for this, so the frozen value is swapped directly * and put back in endUserContext(). PermissionService is required: if it * cannot be pinned this throws, and the caller has to skip the user * rather than run them under someone else's permissions. * * @throws DeckUnavailableException */ public function beginUserContext(IUser $user): void { if ($this->identityRestore !== []) { throw new DeckUnavailableException('A Deck user context is already active'); } $uid = $user->getUID(); try { $this->pinDeckIdentity($this->resolve(PermissionService::class), $uid); } catch (Throwable $e) { $this->endUserContext(); throw new DeckUnavailableException( 'Could not run Deck as ' . $uid . ': ' . $e->getMessage(), 0, $e, ); } foreach (self::OPTIONAL_IDENTITY_CLASSES as $class) { try { $this->pinDeckIdentity($this->resolve($class), $uid); } catch (Throwable $e) { $this->logger->debug('Could not pin ' . $class . ' to ' . $uid . ': ' . $e->getMessage(), [ 'app' => 'workflow_deck_automation', ]); } } } /** * Restores what beginUserContext() replaced. Idempotent, so it can be * called unconditionally from a finally block. */ public function endUserContext(): void { foreach (array_reverse($this->identityRestore) as [$property, $service, $previous]) { try { $property->setValue($service, $previous); } catch (Throwable $e) { $this->logger->warning('Could not restore Deck identity on ' . $service::class . ': ' . $e->getMessage(), [ 'app' => 'workflow_deck_automation', ]); } } $this->identityRestore = []; } private function pinDeckIdentity(object $service, string $uid): void { $property = new ReflectionProperty($service, self::IDENTITY_PROPERTY); $previous = $property->isInitialized($service) ? $property->getValue($service) : null; $property->setValue($service, $uid); $this->identityRestore[] = [$property, $service, is_string($previous) ? $previous : null]; } 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'); } } /** * The boards `$userId` owns — the only boards workflows may live on, and * the boards whose workflows that user sees and manages. * * 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. * * Throws instead of degrading to `[]`: an empty list would read as "you * own no boards" and hide every workflow the user has. * * @return array * @throws DeckUnavailableException */ public function listOwnedBoards(string $userId): array { $boards = $this->call( fn () => $this->resolve(BoardMapper::class)->findAllByOwner($userId), [], true, ); $result = []; foreach ($boards as $board) { if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) { continue; } $id = (int)$board->getId(); $title = $board->getTitle(); $result[] = [ 'id' => $id, 'title' => (is_string($title) && $title !== '') ? $title : ('#' . $id), 'archived' => method_exists($board, 'getArchived') && (bool)$board->getArchived(), ]; } return $result; } /** * @return array */ 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' => (int)$stack->getId(), 'title' => $stack->getTitle()], $stacks, ); } /** * Ids of the board's stacks, for deciding whether a stored workflow still * points at anything real. * * Returns `null` only on positive knowledge that the board is gone, in the * trash, or no longer readable for `$userId`. Every other failure throws, * because "Deck is broken right now" must never be mistaken for "the user * deleted this board" — the caller reacts by switching the workflow off * and mailing its owner. * * Deliberately assembled from pieces that cannot answer for the wrong * user. BoardMapper and StackMapper are plain QBMappers with no user * state (StackMapper::findAll() filters `deleted_at = 0` itself), and * getPermissions() is handed the uid explicitly instead of falling back * to PermissionService's construction-time `$userId`. Leaning on that * frozen value — which is exactly what `StackService::findAll()` does * internally — is what disabled workflows whose board was perfectly * intact, so this must not be "simplified" back to one service call even * once beginUserContext() has pinned the identity. * * @return int[]|null * @throws DeckUnavailableException */ 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) { return null; } $permissions = $this->resolve(PermissionService::class)->getPermissions($boardId, $userId); if (!array_key_exists(self::ACL_PERMISSION_READ, $permissions)) { // Unknown shape: say nothing rather than something wrong. throw new DeckUnavailableException('Deck reported no read permission for board ' . $boardId); } if ($permissions[self::ACL_PERMISSION_READ] !== true) { return null; } $stacks = $this->resolve(StackMapper::class)->findAll($boardId); } catch (DeckUnavailableException $e) { throw $e; } catch (Throwable $e) { if ($this->isMissing($e)) { return null; } $this->logger->error('Could not list stacks of board ' . $boardId . ': ' . $e->getMessage(), [ 'app' => 'workflow_deck_automation', 'exception' => $e, ]); throw new DeckUnavailableException('Deck call failed: ' . $e->getMessage(), 0, $e); } 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; } /** * Who should hear about each of the given (moved) cards, according to * every board member's own Deck notification setting for this board — * the "Notifications: none / assigned cards / all cards" menu, stored as * the user preference `deck` / `board::notify-due`. The decision * itself is NotificationPolicy::shouldNotify(), a copy of Deck's rules. * * Like findStackIds(), assembled only from pieces that take the users * explicitly and never consult Deck's frozen `$userId`: * PermissionService::findUsers() reads the owner and ACL through mappers * and expands groups and circles itself; AssignmentMapper is a plain * mapper that also resolves group/circle assignments; the preference is * read straight from IConfig. * * Throws on any failure, so the caller can log and skip the mail instead * of mailing a half-resolved or wrong set of people. * * @param int[] $cardIds * @return array> card id => uids to notify * @throws DeckUnavailableException */ public function findNotificationRecipients(int $boardId, array $cardIds): array { try { $board = $this->resolve(BoardMapper::class)->find($boardId, false, true); $owner = $this->unwrapUid($board->getOwner()); // Deck notifies the owner of a board nobody else can see about // every card: "assigned" means nothing there. $unshared = count($board->getAcl() ?? []) === 0; // `true` bypasses PermissionService's per-process cache, which // would otherwise outlive ACL changes for the whole cron pass. $participants = $this->resolve(PermissionService::class)->findUsers($boardId, true); $assignments = $this->resolve(AssignmentMapper::class); $settings = []; foreach ($participants as $uid => $participant) { // Users of other instances (FederatedUser) have no local // account and no preference to read. if (!is_a($participant, 'OCA\Deck\Db\User')) { continue; } // Numeric uids come back as integer array keys. $uid = (string)$uid; $settings[$uid] = (string)$this->config->getUserValue( $uid, self::DECK_APP_ID, 'board:' . $boardId . ':notify-due', NotificationPolicy::DECK_DEFAULT, ); } $recipients = []; foreach ($cardIds as $cardId) { $recipients[$cardId] = []; foreach ($settings as $uid => $setting) { $uid = (string)$uid; $notify = NotificationPolicy::shouldNotify( $setting, $unshared && $uid === $owner, static fn (): bool => (bool)$assignments->isUserAssigned($cardId, $uid), ); if ($notify) { $recipients[$cardId][] = $uid; } } } } catch (DeckUnavailableException $e) { throw $e; } catch (Throwable $e) { throw new DeckUnavailableException('Could not resolve notification recipients for board ' . $boardId . ': ' . $e->getMessage(), 0, $e); } return $recipients; } /** * 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 * or renamed Deck degrades to "unknown error" instead of fataling. * * `OCA\Deck\NoPermissionException` is deliberately *not* treated as "the * board is gone" here. It used to be, and that is what switched intact * workflows off: Deck raises it for any check that fails, including every * check evaluated against the wrong frozen uid — which says nothing at * all about whether the board still exists. */ private function isMissing(Throwable $e): bool { if ($e instanceof DoesNotExistException) { return true; } return is_a($e, 'OCA\Deck\NotFoundException'); } /** * Titles of a workflow's board and stacks, for the notification mail. * * Best effort by design: this only decorates a mail that is being sent * because a card *was* already moved, so anything unreadable degrades to * `#` rather than costing the user their notification. * * @return array{board: string, sourceStack: string, targetStack: string} */ public function describeTargets(int $boardId, int $sourceStackId, int $targetStackId): array { $titles = $this->call(function () use ($boardId) { $stackTitles = []; foreach ($this->resolve(StackMapper::class)->findAll($boardId) as $stack) { $stackTitles[(int)$stack->getId()] = (string)$stack->getTitle(); } return [ 'board' => (string)$this->resolve(BoardMapper::class)->find($boardId)->getTitle(), 'stacks' => $stackTitles, ]; }, ['board' => '', 'stacks' => []]); return [ 'board' => self::titleOr($titles['board'], $boardId), 'sourceStack' => self::titleOr($titles['stacks'][$sourceStackId] ?? '', $sourceStackId), 'targetStack' => self::titleOr($titles['stacks'][$targetStackId] ?? '', $targetStackId), ]; } private static function titleOr(string $title, int $id): string { return $title !== '' ? $title : ('#' . $id); } /** * Cards in the given stack that are neither archived, deleted nor marked * done. * * The `deletedAt` check is not redundant: Deck's * `CardMapper::findAllByStack()` filters on `stack_id` and * `archived = false` only, so cards sitting in the trash come back too — * they are kept until Deck's own DeleteCron purges them. Without this we * would move cards the user already deleted. * * @return Card[] */ public function getActiveCardsInStack(int $stackId): array { return $this->call(function () use ($stackId) { // Deliberately not enriched: CardService::enrichCards() only adds // labels, assignees and comment counts, and runs // BoardService::find(), a permission check against Deck's frozen // uid (see beginUserContext()). Assignments, where needed for // mails, come from findNotificationRecipients() instead. $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) { return false; } return !$card->getArchived() && $card->getDone() === null; })); }, []); } /** * 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 $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); } /** * Turns whatever Deck hands out for a "participant"/"owner" field into * a plain uid. * * Enriched Deck entities do *not* return the raw uid string: their * RelationalEntity base swaps resolved relations for a * OCA\Deck\Db\RelationalObject wrapping a User/Group object, and its * primary key is the uid we want. Unenriched entities still return the * bare string, so both shapes are handled. */ private function unwrapUid(mixed $value): ?string { if (is_string($value)) { return $value !== '' ? $value : null; } if (!is_object($value)) { return null; } foreach (['getPrimaryKey', 'getUID', 'getUid', 'getId'] as $method) { if (method_exists($value, $method)) { $inner = $value->$method(); if (is_string($inner) && $inner !== '') { return $inner; } } } if (method_exists($value, 'getObject')) { $inner = $value->getObject(); foreach (['getUID', 'getUid', 'getId'] as $method) { if (is_object($inner) && method_exists($inner, $method)) { $uid = $inner->$method(); if (is_string($uid) && $uid !== '') { return $uid; } } } } return null; } }