Build package / xml-lint (push) Successful in 3m32s
Build package / unit-tests (push) Successful in 4m49s
Build package / php-lint (8.5) (push) Successful in 4m52s
Build package / php-lint (8.4) (push) Successful in 4m56s
Build package / package (push) Successful in 2m28s
Each workflow now has a trigger date (due or start): the runner compares either the card's due date or its start date against now, read straight from Deck's Card entity. Mails and Nextcloud notifications say whether the card was overdue or reached its start date. Cards without the chosen date stay put. Deck only has a start date since 1.18 and Entity getters are magic, so DeckIntegrationService::supportsStartdate() checks the property; without it the form rejects start-date workflows and the runner skips them without disabling them. WorkflowRunner::isOverdue() becomes hasPassed(), as it now covers both dates. Existing workflows keep the due date (migration Version1005). Bumps to 35.4.0 so the migration runs.
558 lines
20 KiB
PHP
558 lines
20 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');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether Deck's cards carry a start date (`deck_cards.startdate`, added
|
|
* in Deck 1.18). Checked on the property, not with method_exists():
|
|
* Entity getters are magic, so getStartdate() would only fail when called.
|
|
*/
|
|
public function supportsStartdate(): bool {
|
|
return class_exists(Card::class) && property_exists(Card::class, 'startdate');
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|