Build package / package (push) Successful in 50s
Lint info.xml / xml-lint (push) Successful in 20s
Lint PHP / php-lint (8.2) (push) Successful in 58s
Lint PHP / php-lint (8.3) (push) Successful in 57s
Lint PHP / php-lint (8.4) (push) Successful in 55s
PHPUnit / unit-tests (push) Successful in 1m7s
The settings page rendered, but nothing in it worked: - All NcSelect dropdowns showed "undefined" for every option. In @nextcloud/vue 9 the `label` prop is vue-select's option display *key*, not a caption, so `label="Board"` read `option.Board`. Use `input-label`. - Saving always failed with "Bitte einen Titel angeben". Vue 3 dropped `.sync`; NcTextField and NcCheckboxRadioSwitch bind via `modelValue`, so `:value.sync` / `:checked.sync` never wrote back. Use `v-model`. - NcButton's style prop is now `variant`, `type` is the native button type and `native-type` is gone. `type="tertiary"` rendered `<button type="tertiary">`, which HTML falls back to `submit` for, making the cancel button submit the form. - The user dropdown was always empty. Deck's RelationalEntity replaces resolved relations with a RelationalObject once an entity is enriched, so `$acl->getParticipant()` returns that wrapper and the uid lives in `getPrimaryKey()` -- probing for `getUID()` yielded null. This also broke the background job's assigned-user filter, which shares the extractor. - A board's ACL never contains its owner (a private board has an empty ACL), so participants are now seeded with the owner, group ACL entries are expanded via IGroupManager and display names resolved via IUserManager. - One board appeared twice: getUserBoards() merges own/group/circle boards and includes archived and trashed ones. Deduplicate by id and drop those. Also keep one failing lookup in onBoardChange from taking the other two dropdowns down with it, and document all of the above in CLAUDE.md.
340 lines
9.4 KiB
PHP
340 lines
9.4 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 () {
|
|
return $this->resolve(BoardService::class)->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 nor marked done,
|
|
* enriched so getAssignedUsers()/getLabels() are populated.
|
|
*
|
|
* @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) {
|
|
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;
|
|
}
|
|
}
|