Files
nextcloud-workflow-deck-aut…/lib/Service/DeckIntegrationService.php
T
Patrick Niebeling 578b235f08
Build package / unit-tests (push) Failing after 20s
Build package / xml-lint (push) Successful in 46s
Build package / php-lint (8.4) (push) Successful in 1m30s
Build package / php-lint (8.5) (push) Successful in 1m36s
Build package / package (push) Skipped
Notify board members about moved cards according to their Deck setting
Replaces the yes/no notify_email flag with notify_mode:
- off:   no mail
- owner: only the board owner, as before (migrated from notify_email=1)
- board: everyone with access to the board, filtered by their own Deck
         "Notifications" setting for it (board:<id>:notify-due: off /
         assigned / all), mirroring Deck's NotificationHelper rules

Recipients are resolved via PermissionService::findUsers() (owner, ACL,
groups and circles expanded), AssignmentMapper::isUserAssigned() and
IConfig, none of which reads Deck's frozen $userId. If they cannot be
resolved, no mail is sent rather than one to the wrong people.

Moved cards are collected over the whole run and each recipient gets a
single digest mail afterwards, in their own language.

Bumps to 35.2.0 so the migrations run.
2026-09-30 10:31:45 +02:00

549 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Service;
use OCA\Deck\Db\AssignmentMapper;
use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Db\Card;
use OCA\Deck\Db\CardMapper;
use OCA\Deck\Db\StackMapper;
use OCA\Deck\Service\BoardService;
use OCA\Deck\Service\CardService;
use OCA\Deck\Service\PermissionService;
use OCA\Deck\Service\StackService;
use OCP\App\IAppManager;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IConfig;
use OCP\IUser;
use OCP\Server;
use Psr\Log\LoggerInterface;
use ReflectionProperty;
use Throwable;
/**
* Single choke point for everything this app does with Deck.
*
* Deck's OCA\Deck\* classes are not a documented/stable public API (no
* @since markers, no <public> 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<array{ReflectionProperty, object, ?string}>
*/
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<int, array{id: int, title: string, archived: bool}>
* @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<int, array{id: int, title: string}>
*/
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:<id>: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<int, list<string>> 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
* `#<id>` 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<T> $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;
}
}