Deck 1.18 added a start date to cards (Card::$startdate), so a workflow
no longer has to mean "overdue". Each rule now carries a date_field
('due' | 'start'), chosen in the settings form next to board and stacks.
Existing rules keep the due date: the new column's default supplies it
for every stored row, so there is no backfill step, and
Workflow::getDateFieldOrDue() covers entities that were never near the
database as well as hand-edited values. The controller is the one place
that rejects an unknown value instead of normalising it - a client
asking for a rule it would not get should hear about it.
The probe for the field is property_exists(), not method_exists():
Deck's entities declare no getter as real code - getStartdate(),
getDuedate(), even getId() all go through Entity::__call(), which
method_exists() ignores by definition. A method_exists() guard would
have been false on every Deck version and would have turned every
start-date rule into a silent no-op.
assertDeckAvailable() now also refuses Deck older than 1.18.0. That
floor cannot live in appinfo/info.xml - neither the server's nor the app
store's schema has an app-to-app dependency element - but
<nextcloud min-version="34"> already implies it, since 1.18.x is the
only Deck release line published for Nextcloud 34.
isOverdue() becomes hasPassed(): one function for both dates, since the
comparison and the argument against Deck's day-truncating
getDaysUntilDue() are identical for either.
Also carries a pending NcSelect icon-alignment fix that was already in
the working tree.
Bump to 34.1.0.
731 lines
25 KiB
PHP
731 lines
25 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\WorkflowDeckAutomation\Service;
|
|
|
|
use DateTimeInterface;
|
|
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\IGroupManager;
|
|
use OCP\IUser;
|
|
use OCP\IUserManager;
|
|
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';
|
|
|
|
/**
|
|
* Oldest Deck this app will talk to.
|
|
*
|
|
* Deck 1.18.0 is the release that added the card start date
|
|
* (`Card::$startdate`, PR #7749), which the start-date workflows read.
|
|
* The floor cannot live in appinfo/info.xml: neither the server's
|
|
* app-info.xsd nor the app store's info.xsd has an app-to-app dependency
|
|
* element, only <nextcloud>.
|
|
*
|
|
* That pin does most of the work anyway — Deck 1.18.x is the only release
|
|
* line declaring `>=34.0.0,<35.0.0`, so on the Nextcloud 34 this app
|
|
* requires, every installable Deck already carries the field. What is left
|
|
* for this check is a Deck put in place by hand or from git.
|
|
*
|
|
* version_compare() sorts `1.18.0-beta.x` *below* `1.18.0`, and that is
|
|
* intended: the betas of that line may predate the field.
|
|
*/
|
|
private const MIN_DECK_VERSION = '1.18.0';
|
|
|
|
/**
|
|
* Deck's Acl::PERMISSION_TYPE_* values, inlined so this class keeps
|
|
* working (degrading to "treat it as a user") if Deck ever moves or
|
|
* renames the constants.
|
|
*/
|
|
private const ACL_TYPE_USER = 0;
|
|
private const ACL_TYPE_GROUP = 1;
|
|
|
|
/**
|
|
* Deck's Acl::PERMISSION_READ, inlined for the same reason. 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',
|
|
];
|
|
|
|
/**
|
|
* `Workflow::DATE_FIELD_*` mapped to the property on Deck's Card entity.
|
|
* Kept here rather than on the Workflow so this class stays the only one
|
|
* that has to know what Deck calls its columns.
|
|
*/
|
|
private const CARD_DATE_PROPERTIES = [
|
|
'due' => 'duedate',
|
|
'start' => 'startdate',
|
|
];
|
|
|
|
private const DEFAULT_CARD_DATE = 'due';
|
|
|
|
/**
|
|
* @var list<array{ReflectionProperty, object, ?string}>
|
|
*/
|
|
private array $identityRestore = [];
|
|
|
|
/**
|
|
* Card properties already reported as missing, so the warning in
|
|
* getCardDate() is one line per process instead of one per card.
|
|
*
|
|
* @var array<string, true>
|
|
*/
|
|
private array $reportedMissingCardProperties = [];
|
|
|
|
public function __construct(
|
|
private IAppManager $appManager,
|
|
private IUserManager $userManager,
|
|
private IGroupManager $groupManager,
|
|
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());
|
|
}
|
|
|
|
$version = $this->appManager->getAppVersion(self::DECK_APP_ID);
|
|
if ($version === '' || version_compare($version, self::MIN_DECK_VERSION, '<')) {
|
|
throw new DeckUnavailableException(sprintf(
|
|
'Deck %s is installed, but %s or newer is required',
|
|
$version !== '' ? $version : '(unknown version)',
|
|
self::MIN_DECK_VERSION,
|
|
));
|
|
}
|
|
|
|
if (!class_exists(CardService::class) || !class_exists(BoardService::class) || !class_exists(StackService::class)) {
|
|
throw new DeckUnavailableException('Deck internal classes are not available');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id: int, title: string}>
|
|
*
|
|
* 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.
|
|
*/
|
|
public function listBoardsForCurrentUser(): 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();
|
|
}
|
|
}, []);
|
|
|
|
// Deck merges own/group/circle boards, so the same board can come
|
|
// back more than once; and boards in the trash or archived ones are
|
|
// no useful automation target. Keyed by id => deduplicated.
|
|
$result = [];
|
|
foreach ($boards as $board) {
|
|
$id = (int)$board->getId();
|
|
if (isset($result[$id]) || $this->isBoardHidden($board)) {
|
|
continue;
|
|
}
|
|
$title = $board->getTitle();
|
|
$result[$id] = [
|
|
'id' => $id,
|
|
'title' => (is_string($title) && $title !== '') ? $title : ('#' . $id),
|
|
];
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id: int, title: string, color: string|null}>
|
|
*/
|
|
public function listLabels(int $boardId): array {
|
|
$labels = $this->call(fn () => $this->readLabels($boardId), []);
|
|
|
|
return array_map(
|
|
static fn ($label) => ['id' => (int)$label->getId(), 'title' => $label->getTitle(), 'color' => $label->getColor()],
|
|
$labels,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* What a workflow's filters *could* legitimately refer to on this board.
|
|
*
|
|
* Unlike listLabels()/listParticipants() this throws instead of degrading
|
|
* to an empty list, because its only caller draws a conclusion from
|
|
* emptiness ("this filter cannot match anything any more"). Same rule as
|
|
* findStackIds(): a Deck hiccup must never be read as positive knowledge.
|
|
*
|
|
* @return array{labelIds: int[], participantUids: string[]}
|
|
* @throws DeckUnavailableException
|
|
*/
|
|
public function findFilterOptions(int $boardId): array {
|
|
try {
|
|
$board = $this->readBoard($boardId);
|
|
|
|
return [
|
|
'labelIds' => array_map(
|
|
static fn ($label) => (int)$label->getId(),
|
|
$board->getLabels() ?? [],
|
|
),
|
|
'participantUids' => array_values(array_unique($this->collectParticipantUids($board))),
|
|
];
|
|
} catch (DeckUnavailableException $e) {
|
|
throw $e;
|
|
} catch (Throwable $e) {
|
|
$this->logger->error('Could not read filter options of board ' . $boardId . ': ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'exception' => $e,
|
|
]);
|
|
throw new DeckUnavailableException('Deck call failed: ' . $e->getMessage(), 0, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* Everyone who can hold a card on this board: the board owner (who is
|
|
* *not* part of the ACL — a private board has an empty ACL) plus every
|
|
* ACL entry, with group shares expanded to their members.
|
|
*
|
|
* @return array<int, array{uid: string, displayName: string}>
|
|
*/
|
|
public function listParticipants(int $boardId): array {
|
|
$uids = $this->call(fn () => $this->collectParticipantUids($this->readBoard($boardId)), []);
|
|
|
|
$participants = [];
|
|
foreach ($uids as $uid) {
|
|
if (isset($participants[$uid])) {
|
|
continue;
|
|
}
|
|
$participants[$uid] = [
|
|
'uid' => $uid,
|
|
'displayName' => $this->userManager->get($uid)?->getDisplayName() ?? $uid,
|
|
];
|
|
}
|
|
|
|
return array_values($participants);
|
|
}
|
|
|
|
/**
|
|
* The enriched board — `$fullDetails = true` is what populates labels and
|
|
* the ACL. Shared by every caller that needs one so a single Deck read
|
|
* serves them all.
|
|
*/
|
|
private function readBoard(int $boardId): mixed {
|
|
return $this->resolve(BoardService::class)->find($boardId, true);
|
|
}
|
|
|
|
private function readLabels(int $boardId): array {
|
|
return $this->readBoard($boardId)->getLabels() ?? [];
|
|
}
|
|
|
|
/**
|
|
* @return string[] may contain duplicates
|
|
*/
|
|
private function collectParticipantUids(mixed $board): array {
|
|
$uids = [];
|
|
$owner = $this->unwrapUid($board->getOwner());
|
|
if ($owner !== null) {
|
|
$uids[] = $owner;
|
|
}
|
|
|
|
foreach ($board->getAcl() ?? [] as $entry) {
|
|
$principal = $this->extractParticipantUid($entry);
|
|
if ($principal === null) {
|
|
continue;
|
|
}
|
|
$type = method_exists($entry, 'getType') ? (int)$entry->getType() : self::ACL_TYPE_USER;
|
|
if ($type === self::ACL_TYPE_GROUP) {
|
|
array_push($uids, ...$this->groupMemberUids($principal));
|
|
} elseif ($type === self::ACL_TYPE_USER) {
|
|
$uids[] = $principal;
|
|
}
|
|
// Circles and federated shares are skipped: their members
|
|
// cannot be resolved to plain uids here.
|
|
}
|
|
|
|
return $uids;
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function groupMemberUids(string $groupId): array {
|
|
$group = $this->groupManager->get($groupId);
|
|
if ($group === null) {
|
|
return [];
|
|
}
|
|
return array_map(static fn (IUser $user) => $user->getUID(), $group->getUsers());
|
|
}
|
|
|
|
/**
|
|
* Cards in the given stack that are neither archived, deleted nor marked
|
|
* done, enriched so getAssignedUsers()/getLabels() are populated.
|
|
*
|
|
* 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) {
|
|
$cardMapper = $this->resolve(CardMapper::class);
|
|
$cardService = $this->resolve(CardService::class);
|
|
|
|
$cards = $cardMapper->findAllByStack($stackId);
|
|
// enrichCards() mutates each $cards entry in place (labels, assigned users, ...) but
|
|
// *returns* CardDetails wrappers whose own fields (duedate, stackId, ...) were never
|
|
// copied from the wrapped card - only its overridden jsonSerialize() reads through to
|
|
// them. Capturing that return value here made every card's getDuedate()/getDaysUntilDue()
|
|
// resolve to null, so hasPassed() was always false and no workflow ever matched a card.
|
|
// Keep using the original, now-enriched $cards instead.
|
|
$cardService->enrichCards($cards);
|
|
|
|
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;
|
|
}));
|
|
}, []);
|
|
}
|
|
|
|
/**
|
|
* The card date a workflow is evaluated against: Deck's due date or its
|
|
* start date, per `Workflow::DATE_FIELD_*`. Anything unrecognised reads the
|
|
* due date, which is what every workflow meant before the choice existed.
|
|
*
|
|
* The guard is `property_exists()`, deliberately, and this is the one thing
|
|
* to get right here: **`method_exists()` cannot see Deck's getters.**
|
|
* `Card`, `Board`, `Label`, `Acl` and `Assignment` declare none of them as
|
|
* real code — `getStartdate()`, `getDuedate()`, even `getId()` all go
|
|
* through `OCP\AppFramework\Db\Entity::__call()`, and `method_exists()`
|
|
* ignores `__call()` by definition. A `method_exists($card, 'getStartdate')`
|
|
* probe would therefore be false on *every* Deck version and silently turn
|
|
* every start-date workflow into a no-op. `property_exists()` sees the
|
|
* protected `$startdate` declaration and is exactly what `Entity::getter()`
|
|
* checks internally before throwing.
|
|
*
|
|
* In practice the property is always there — `assertDeckAvailable()`
|
|
* enforces Deck >= MIN_DECK_VERSION, which is the release that added it —
|
|
* so this branch is a backstop for a hand-installed Deck, loud enough to be
|
|
* found in the log rather than silently matching nothing.
|
|
*/
|
|
public function getCardDate(Card $card, string $dateField): ?DateTimeInterface {
|
|
$property = self::CARD_DATE_PROPERTIES[$dateField] ?? self::CARD_DATE_PROPERTIES[self::DEFAULT_CARD_DATE];
|
|
|
|
if (!property_exists($card, $property)) {
|
|
if (!isset($this->reportedMissingCardProperties[$property])) {
|
|
$this->reportedMissingCardProperties[$property] = true;
|
|
$this->logger->warning(
|
|
'This Deck version has no Card::$' . $property . '; workflows using it cannot match any card',
|
|
['app' => 'workflow_deck_automation'],
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
$value = $property === 'startdate' ? $card->getStartdate() : $card->getDuedate();
|
|
return $value instanceof DateTimeInterface ? $value : null;
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
public function getCardAssignedUserIds(Card $card): array {
|
|
$assigned = $card->getAssignedUsers() ?? [];
|
|
$uids = [];
|
|
foreach ($assigned as $entry) {
|
|
$uid = $this->extractParticipantUid($entry);
|
|
if ($uid !== null) {
|
|
$uids[] = $uid;
|
|
}
|
|
}
|
|
return $uids;
|
|
}
|
|
|
|
/**
|
|
* @return int[]
|
|
*/
|
|
public function getCardLabelIds(Card $card): array {
|
|
$labels = $card->getLabels() ?? [];
|
|
$ids = [];
|
|
foreach ($labels as $label) {
|
|
if (method_exists($label, 'getId')) {
|
|
$ids[] = (int)$label->getId();
|
|
}
|
|
}
|
|
return $ids;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* Deck's assignment/ACL entries are internal, undocumented value
|
|
* objects whose exact accessor shape has changed across versions, so
|
|
* we probe the common accessor names defensively instead of relying
|
|
* on one fixed method signature.
|
|
*/
|
|
private function extractParticipantUid(mixed $entry): ?string {
|
|
$uid = $this->unwrapUid($entry);
|
|
if ($uid !== null) {
|
|
return $uid;
|
|
}
|
|
if (!is_object($entry)) {
|
|
return null;
|
|
}
|
|
foreach (['getParticipant', 'getUid', 'getParticipantUid', 'getUserId'] as $method) {
|
|
if (method_exists($entry, $method)) {
|
|
$uid = $this->unwrapUid($entry->$method());
|
|
if ($uid !== null) {
|
|
return $uid;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|