Deck's CardMapper::findAllByStack() filters on stack_id and archived only -- there is no deleted_at condition -- so getActiveCardsInStack() also returned cards the user had deleted. They stay in the table until Deck's DeleteCron purges them, which is long enough for an overdue one to get moved out of the trash and back into a stack.
361 lines
10 KiB
PHP
361 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\WorkflowDeckAutomation\Service;
|
|
|
|
use OCA\Deck\Db\Card;
|
|
use OCA\Deck\Db\CardMapper;
|
|
use OCA\Deck\Service\BoardService;
|
|
use OCA\Deck\Service\CardService;
|
|
use OCA\Deck\Service\StackService;
|
|
use OCP\App\IAppManager;
|
|
use OCP\IGroupManager;
|
|
use OCP\IUser;
|
|
use OCP\IUserManager;
|
|
use OCP\Server;
|
|
use Psr\Log\LoggerInterface;
|
|
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_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;
|
|
|
|
public function __construct(
|
|
private IAppManager $appManager,
|
|
private IUserManager $userManager,
|
|
private IGroupManager $groupManager,
|
|
private LoggerInterface $logger,
|
|
) {
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id: int, title: string}>
|
|
*/
|
|
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' => $stack->getId(), 'title' => $stack->getTitle()],
|
|
$stacks,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id: int, title: string, color: string|null}>
|
|
*/
|
|
public function listLabels(int $boardId): array {
|
|
$labels = $this->call(function () use ($boardId) {
|
|
$board = $this->resolve(BoardService::class)->find($boardId, true);
|
|
return $board->getLabels() ?? [];
|
|
}, []);
|
|
|
|
return array_map(
|
|
static fn ($label) => ['id' => $label->getId(), 'title' => $label->getTitle(), 'color' => $label->getColor()],
|
|
$labels,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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(function () use ($boardId) {
|
|
$board = $this->resolve(BoardService::class)->find($boardId, true);
|
|
|
|
$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;
|
|
}, []);
|
|
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
$cards = $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;
|
|
}));
|
|
}, []);
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
}
|