Add Deck workflow automation Nextcloud app
Ports the standalone due-date cron script into a proper Nextcloud 34 app with a personal-settings UI, per-user workflow configuration (source/target stack, assigned-user and label filters, email notification), a TimedJob background runner, and Gitea CI pipelines. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\AppInfo;
|
||||
|
||||
use OCP\AppFramework\App;
|
||||
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||
|
||||
class Application extends App implements IBootstrap {
|
||||
public const APP_ID = 'workflow_deck_automation';
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct(self::APP_ID);
|
||||
}
|
||||
|
||||
public function register(IRegistrationContext $context): void {
|
||||
}
|
||||
|
||||
public function boot(IBootContext $context): void {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\BackgroundJob;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\Service\WorkflowRunner;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
|
||||
class RunWorkflowsJob extends TimedJob {
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
private WorkflowRunner $runner,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
$this->setInterval(5 * 60);
|
||||
$this->setTimeSensitivity(IJob::TIME_INSENSITIVE);
|
||||
$this->setAllowParallelRuns(false);
|
||||
}
|
||||
|
||||
protected function run($argument): void {
|
||||
$this->runner->run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Controller;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\Db\Workflow;
|
||||
use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
|
||||
use OCA\WorkflowDeckAutomation\Service\DeckIntegrationService;
|
||||
use OCA\WorkflowDeckAutomation\Service\DeckUnavailableException;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCS\OCSBadRequestException;
|
||||
use OCP\AppFramework\OCS\OCSNotFoundException;
|
||||
use OCP\AppFramework\OCS\OCSPreconditionFailedException;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
|
||||
class WorkflowController extends OCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private WorkflowMapper $workflowMapper,
|
||||
private DeckIntegrationService $deckService,
|
||||
private IUserSession $userSession,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
private function currentUserId(): string {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
throw new OCSPreconditionFailedException('Not logged in');
|
||||
}
|
||||
return $user->getUID();
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
public function index(): DataResponse {
|
||||
$workflows = $this->workflowMapper->findAllForUser($this->currentUserId());
|
||||
return new DataResponse(array_map(static fn (Workflow $w) => $w->jsonSerialize(), $workflows));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $filterUserIds
|
||||
* @param int[] $filterLabelIds
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function create(
|
||||
string $title,
|
||||
int $boardId,
|
||||
int $sourceStackId,
|
||||
int $targetStackId,
|
||||
array $filterUserIds = [],
|
||||
array $filterLabelIds = [],
|
||||
bool $notifyEmail = false,
|
||||
bool $enabled = true,
|
||||
): DataResponse {
|
||||
$this->validate($title, $sourceStackId, $targetStackId);
|
||||
|
||||
$workflow = new Workflow();
|
||||
$workflow->setUserId($this->currentUserId());
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $notifyEmail, $enabled);
|
||||
|
||||
$workflow = $this->workflowMapper->insert($workflow);
|
||||
return new DataResponse($workflow->jsonSerialize(), Http::STATUS_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $filterUserIds
|
||||
* @param int[] $filterLabelIds
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function update(
|
||||
int $id,
|
||||
string $title,
|
||||
int $boardId,
|
||||
int $sourceStackId,
|
||||
int $targetStackId,
|
||||
array $filterUserIds = [],
|
||||
array $filterLabelIds = [],
|
||||
bool $notifyEmail = false,
|
||||
bool $enabled = true,
|
||||
): DataResponse {
|
||||
$this->validate($title, $sourceStackId, $targetStackId);
|
||||
|
||||
try {
|
||||
$workflow = $this->workflowMapper->findForUser($id, $this->currentUserId());
|
||||
} catch (DoesNotExistException $e) {
|
||||
throw new OCSNotFoundException('Workflow not found');
|
||||
}
|
||||
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $notifyEmail, $enabled);
|
||||
$workflow = $this->workflowMapper->update($workflow);
|
||||
|
||||
return new DataResponse($workflow->jsonSerialize());
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
public function destroy(int $id): DataResponse {
|
||||
try {
|
||||
$workflow = $this->workflowMapper->findForUser($id, $this->currentUserId());
|
||||
} catch (DoesNotExistException $e) {
|
||||
throw new OCSNotFoundException('Workflow not found');
|
||||
}
|
||||
|
||||
$this->workflowMapper->delete($workflow);
|
||||
return new DataResponse([]);
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
public function boards(): DataResponse {
|
||||
$this->assertDeck();
|
||||
return new DataResponse($this->deckService->listBoardsForCurrentUser());
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
public function stacks(int $boardId): DataResponse {
|
||||
$this->assertDeck();
|
||||
return new DataResponse($this->deckService->listStacks($boardId));
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
public function labels(int $boardId): DataResponse {
|
||||
$this->assertDeck();
|
||||
return new DataResponse($this->deckService->listLabels($boardId));
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
public function participants(int $boardId): DataResponse {
|
||||
$this->assertDeck();
|
||||
return new DataResponse($this->deckService->listParticipants($boardId));
|
||||
}
|
||||
|
||||
private function assertDeck(): void {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
throw new OCSPreconditionFailedException('Not logged in');
|
||||
}
|
||||
try {
|
||||
$this->deckService->assertDeckAvailable($user);
|
||||
} catch (DeckUnavailableException $e) {
|
||||
throw new OCSPreconditionFailedException('Deck is not available: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function validate(string $title, int $sourceStackId, int $targetStackId): void {
|
||||
if (trim($title) === '') {
|
||||
throw new OCSBadRequestException('Title must not be empty');
|
||||
}
|
||||
if ($sourceStackId === $targetStackId) {
|
||||
throw new OCSBadRequestException('Source and target stack must differ');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $filterUserIds
|
||||
* @param int[] $filterLabelIds
|
||||
*/
|
||||
private function applyFields(
|
||||
Workflow $workflow,
|
||||
string $title,
|
||||
int $boardId,
|
||||
int $sourceStackId,
|
||||
int $targetStackId,
|
||||
array $filterUserIds,
|
||||
array $filterLabelIds,
|
||||
bool $notifyEmail,
|
||||
bool $enabled,
|
||||
): void {
|
||||
$workflow->setTitle($title);
|
||||
$workflow->setBoardId($boardId);
|
||||
$workflow->setSourceStackId($sourceStackId);
|
||||
$workflow->setTargetStackId($targetStackId);
|
||||
$workflow->setFilterUserIds($filterUserIds === [] ? null : json_encode(array_values($filterUserIds)));
|
||||
$workflow->setFilterLabelIds($filterLabelIds === [] ? null : json_encode(array_values($filterLabelIds)));
|
||||
$workflow->setNotifyEmail($notifyEmail);
|
||||
$workflow->setEnabled($enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Db;
|
||||
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
use OCP\DB\Types;
|
||||
|
||||
/**
|
||||
* @method string getUserId()
|
||||
* @method void setUserId(string $userId)
|
||||
* @method string getTitle()
|
||||
* @method void setTitle(string $title)
|
||||
* @method int getBoardId()
|
||||
* @method void setBoardId(int $boardId)
|
||||
* @method int getSourceStackId()
|
||||
* @method void setSourceStackId(int $sourceStackId)
|
||||
* @method int getTargetStackId()
|
||||
* @method void setTargetStackId(int $targetStackId)
|
||||
* @method string|null getFilterUserIds()
|
||||
* @method void setFilterUserIds(?string $filterUserIds)
|
||||
* @method string|null getFilterLabelIds()
|
||||
* @method void setFilterLabelIds(?string $filterLabelIds)
|
||||
* @method bool getNotifyEmail()
|
||||
* @method void setNotifyEmail(bool $notifyEmail)
|
||||
* @method bool getEnabled()
|
||||
* @method void setEnabled(bool $enabled)
|
||||
* @method \DateTime|null getLastRun()
|
||||
* @method void setLastRun(?\DateTime $lastRun)
|
||||
*/
|
||||
class Workflow extends Entity implements \JsonSerializable {
|
||||
protected $userId;
|
||||
protected $title;
|
||||
protected $boardId;
|
||||
protected $sourceStackId;
|
||||
protected $targetStackId;
|
||||
protected $filterUserIds;
|
||||
protected $filterLabelIds;
|
||||
protected $notifyEmail;
|
||||
protected $enabled;
|
||||
protected $lastRun;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('id', Types::INTEGER);
|
||||
$this->addType('userId', Types::STRING);
|
||||
$this->addType('title', Types::STRING);
|
||||
$this->addType('boardId', Types::INTEGER);
|
||||
$this->addType('sourceStackId', Types::INTEGER);
|
||||
$this->addType('targetStackId', Types::INTEGER);
|
||||
$this->addType('filterUserIds', Types::STRING);
|
||||
$this->addType('filterLabelIds', Types::STRING);
|
||||
$this->addType('notifyEmail', Types::BOOLEAN);
|
||||
$this->addType('enabled', Types::BOOLEAN);
|
||||
$this->addType('lastRun', Types::DATETIME);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getFilterUserIdsArray(): array {
|
||||
return $this->decodeIds($this->getFilterUserIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
public function getFilterLabelIdsArray(): array {
|
||||
return array_map('intval', $this->decodeIds($this->getFilterLabelIds()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function decodeIds(?string $json): array {
|
||||
if ($json === null || $json === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($json, true);
|
||||
return is_array($decoded) ? array_values($decoded) : [];
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->getId(),
|
||||
'userId' => $this->getUserId(),
|
||||
'title' => $this->getTitle(),
|
||||
'boardId' => $this->getBoardId(),
|
||||
'sourceStackId' => $this->getSourceStackId(),
|
||||
'targetStackId' => $this->getTargetStackId(),
|
||||
'filterUserIds' => $this->getFilterUserIdsArray(),
|
||||
'filterLabelIds' => $this->getFilterLabelIdsArray(),
|
||||
'notifyEmail' => (bool)$this->getNotifyEmail(),
|
||||
'enabled' => (bool)$this->getEnabled(),
|
||||
'lastRun' => $this->getLastRun()?->format(\DateTimeInterface::ATOM),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Db;
|
||||
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @extends QBMapper<Workflow>
|
||||
*/
|
||||
class WorkflowMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, 'wfda_workflows', Workflow::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DoesNotExistException
|
||||
* @throws MultipleObjectsReturnedException
|
||||
*/
|
||||
public function findForUser(int $id, string $userId): Workflow {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Workflow[]
|
||||
*/
|
||||
public function findAllForUser(string $userId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->orderBy('id', 'ASC');
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Workflow[]
|
||||
*/
|
||||
public function findAllEnabled(): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('enabled', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)))
|
||||
->orderBy('user_id', 'ASC')
|
||||
->addOrderBy('id', 'ASC');
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
class Version1000Date20260813120000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('wfda_workflows')) {
|
||||
$table = $schema->createTable('wfda_workflows');
|
||||
$table->addColumn('id', Types::INTEGER, [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->addColumn('user_id', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
]);
|
||||
$table->addColumn('title', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 255,
|
||||
]);
|
||||
$table->addColumn('board_id', Types::INTEGER, [
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->addColumn('source_stack_id', Types::INTEGER, [
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->addColumn('target_stack_id', Types::INTEGER, [
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->addColumn('filter_user_ids', Types::TEXT, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
$table->addColumn('filter_label_ids', Types::TEXT, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
$table->addColumn('notify_email', Types::BOOLEAN, [
|
||||
'notnull' => true,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->addColumn('enabled', Types::BOOLEAN, [
|
||||
'notnull' => true,
|
||||
'default' => true,
|
||||
]);
|
||||
$table->addColumn('last_run', Types::DATETIME, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addIndex(['user_id'], 'wfda_workflows_uid_idx');
|
||||
$table->addIndex(['enabled'], 'wfda_workflows_enabled_idx');
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?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\IUser;
|
||||
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';
|
||||
|
||||
public function __construct(
|
||||
private IAppManager $appManager,
|
||||
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();
|
||||
}, []);
|
||||
|
||||
return array_map(
|
||||
static fn ($board) => ['id' => $board->getId(), 'title' => $board->getTitle()],
|
||||
$boards,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{uid: string, displayName: string}>
|
||||
*/
|
||||
public function listParticipants(int $boardId): array {
|
||||
$acl = $this->call(function () use ($boardId) {
|
||||
$board = $this->resolve(BoardService::class)->find($boardId, true);
|
||||
return $board->getAcl() ?? [];
|
||||
}, []);
|
||||
|
||||
$participants = [];
|
||||
foreach ($acl as $entry) {
|
||||
$uid = $this->extractParticipantUid($entry);
|
||||
if ($uid !== null && !isset($participants[$uid])) {
|
||||
$participants[$uid] = ['uid' => $uid, 'displayName' => $uid];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($participants);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (is_string($entry)) {
|
||||
return $entry;
|
||||
}
|
||||
if (!is_object($entry)) {
|
||||
return null;
|
||||
}
|
||||
foreach (['getParticipant', 'getUid', 'getParticipantUid', 'getUserId'] as $method) {
|
||||
if (method_exists($entry, $method)) {
|
||||
$value = $entry->$method();
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
if (is_object($value) && method_exists($value, 'getUID')) {
|
||||
return $value->getUID();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Service;
|
||||
|
||||
/**
|
||||
* Thrown whenever the Deck app is disabled for a user, or its internal
|
||||
* classes could not be resolved. Deck's OCA\Deck\* classes are not a
|
||||
* documented public API, so every access point is wrapped and normalised
|
||||
* into this exception instead of leaking Deck-internal exception types.
|
||||
*/
|
||||
class DeckUnavailableException extends \RuntimeException {
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Service;
|
||||
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\WorkflowDeckAutomation\Db\Workflow;
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUser;
|
||||
use OCP\Mail\IMailer;
|
||||
use OCP\Util;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
class NotificationMailer {
|
||||
public function __construct(
|
||||
private IMailer $mailer,
|
||||
private IURLGenerator $urlGenerator,
|
||||
private IL10N $l10n,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function sendCardMovedNotification(IUser $user, Workflow $workflow, Card $card): void {
|
||||
$email = $user->getEMailAddress();
|
||||
if ($email === null || $email === '') {
|
||||
$this->logger->info('Skipping notification for workflow ' . $workflow->getId() . ': user ' . $user->getUID() . ' has no email address', [
|
||||
'app' => 'workflow_deck_automation',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$cardLink = $this->urlGenerator->getAbsoluteURL(
|
||||
'/apps/deck/board/' . $workflow->getBoardId() . '/card/' . $card->getId(),
|
||||
);
|
||||
|
||||
$template = $this->mailer->createEMailTemplate('workflow_deck_automation.CardMoved', [
|
||||
'cardTitle' => $card->getTitle(),
|
||||
'workflowTitle' => $workflow->getTitle(),
|
||||
]);
|
||||
$template->setSubject($this->l10n->t('Deck card moved: %s', [$card->getTitle()]));
|
||||
$template->addHeader();
|
||||
$template->addHeading($this->l10n->t('A card was moved automatically'), false);
|
||||
$template->addBodyText($this->l10n->t(
|
||||
'The card "%1$s" was moved because it is overdue (workflow "%2$s").',
|
||||
[$card->getTitle(), $workflow->getTitle()],
|
||||
));
|
||||
$template->addBodyButton($this->l10n->t('Open card'), $cardLink);
|
||||
$template->addFooter();
|
||||
|
||||
$message = $this->mailer->createMessage();
|
||||
$message->setTo([$email => $user->getDisplayName()]);
|
||||
$message->setFrom([Util::getDefaultEmailAddress('noreply') => $this->l10n->t('Deck Workflow Automation')]);
|
||||
$message->useTemplate($template);
|
||||
|
||||
$failedRecipients = $this->mailer->send($message);
|
||||
if (!empty($failedRecipients)) {
|
||||
$this->logger->error('Notification mail for card ' . $card->getId() . ' failed for: ' . implode(', ', $failedRecipients), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Could not send notification mail for card ' . $card->getId() . ': ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Service;
|
||||
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\WorkflowDeckAutomation\Db\Workflow;
|
||||
use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Evaluates all enabled workflows and moves overdue cards.
|
||||
*
|
||||
* Deck's permission checks read the *live* user session, not a value
|
||||
* frozen at construction, and this runner has to evaluate rules for many
|
||||
* different users within a single background-job process. So workflows
|
||||
* are grouped by owner and, for each owner, we temporarily impersonate
|
||||
* that user (push their IUser onto the session, restore afterwards) —
|
||||
* the standard Nextcloud pattern for multi-user cron jobs.
|
||||
*/
|
||||
class WorkflowRunner {
|
||||
private ?IUser $impersonationRestore = null;
|
||||
|
||||
public function __construct(
|
||||
private WorkflowMapper $workflowMapper,
|
||||
private DeckIntegrationService $deckService,
|
||||
private NotificationMailer $mailer,
|
||||
private IUserManager $userManager,
|
||||
private IUserSession $userSession,
|
||||
private IRootFolder $rootFolder,
|
||||
private ITimeFactory $timeFactory,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function run(): void {
|
||||
$workflows = $this->workflowMapper->findAllEnabled();
|
||||
if ($workflows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$byUser = [];
|
||||
foreach ($workflows as $workflow) {
|
||||
$byUser[$workflow->getUserId()][] = $workflow;
|
||||
}
|
||||
|
||||
foreach ($byUser as $userId => $userWorkflows) {
|
||||
$this->runForUser((string)$userId, $userWorkflows);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Workflow[] $workflows
|
||||
*/
|
||||
private function runForUser(string $userId, array $workflows): void {
|
||||
$user = $this->userManager->get($userId);
|
||||
if ($user === null || !$user->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->deckService->assertDeckAvailable($user);
|
||||
} catch (DeckUnavailableException $e) {
|
||||
$this->logger->info('Skipping workflows for {user}: ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'user' => $userId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->impersonate($user);
|
||||
try {
|
||||
foreach ($workflows as $workflow) {
|
||||
$this->runWorkflow($user, $workflow);
|
||||
}
|
||||
} finally {
|
||||
$this->clearImpersonation();
|
||||
}
|
||||
}
|
||||
|
||||
private function runWorkflow(IUser $user, Workflow $workflow): void {
|
||||
try {
|
||||
$cards = $this->deckService->getActiveCardsInStack($workflow->getSourceStackId());
|
||||
} catch (DeckUnavailableException $e) {
|
||||
$this->logger->warning('Could not read stack for workflow {id}: ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'id' => $workflow->getId(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$filterUserIds = $workflow->getFilterUserIdsArray();
|
||||
$filterLabelIds = $workflow->getFilterLabelIdsArray();
|
||||
|
||||
foreach ($cards as $card) {
|
||||
if (!self::isOverdue($card->getDaysUntilDue())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assignedUserIds = $this->deckService->getCardAssignedUserIds($card);
|
||||
$labelIds = $this->deckService->getCardLabelIds($card);
|
||||
if (!self::cardMatchesFilters($assignedUserIds, $labelIds, $filterUserIds, $filterLabelIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->moveAndNotify($user, $workflow, $card);
|
||||
}
|
||||
|
||||
$workflow->setLastRun($this->timeFactory->getDateTime());
|
||||
$this->workflowMapper->update($workflow);
|
||||
}
|
||||
|
||||
private function moveAndNotify(IUser $user, Workflow $workflow, Card $card): void {
|
||||
try {
|
||||
$this->deckService->moveCard($card->getId(), $workflow->getTargetStackId());
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Failed to move card {card} for workflow {id}: ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'card' => $card->getId(),
|
||||
'id' => $workflow->getId(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($workflow->getNotifyEmail()) {
|
||||
$this->mailer->sendCardMovedNotification($user, $workflow, $card);
|
||||
}
|
||||
}
|
||||
|
||||
public static function isOverdue(?int $daysUntilDue): bool {
|
||||
return $daysUntilDue !== null && $daysUntilDue < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure filter-matching logic (kept static/side-effect free so it can
|
||||
* be unit tested without real Deck objects). Both filters are OR
|
||||
* inside themselves and AND between each other: an empty filter list
|
||||
* means "no restriction on this dimension".
|
||||
*
|
||||
* @param string[] $cardAssignedUserIds
|
||||
* @param int[] $cardLabelIds
|
||||
* @param string[] $filterUserIds
|
||||
* @param int[] $filterLabelIds
|
||||
*/
|
||||
public static function cardMatchesFilters(
|
||||
array $cardAssignedUserIds,
|
||||
array $cardLabelIds,
|
||||
array $filterUserIds,
|
||||
array $filterLabelIds,
|
||||
): bool {
|
||||
if ($filterUserIds !== [] && array_intersect($filterUserIds, $cardAssignedUserIds) === []) {
|
||||
return false;
|
||||
}
|
||||
if ($filterLabelIds !== [] && array_intersect($filterLabelIds, $cardLabelIds) === []) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function impersonate(IUser $user): void {
|
||||
$this->impersonationRestore = $this->userSession->getUser();
|
||||
$this->userSession->setUser($user);
|
||||
try {
|
||||
// Best-effort filesystem setup; some Deck-internal helpers
|
||||
// (attachments, activity) expect an initialised user FS.
|
||||
$this->rootFolder->getUserFolder($user->getUID());
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->debug('Filesystem setup failed for ' . $user->getUID() . ': ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function clearImpersonation(): void {
|
||||
$this->userSession->setUser($this->impersonationRestore);
|
||||
$this->impersonationRestore = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Settings;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\AppInfo\Application;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\Settings\ISettings;
|
||||
use OCP\Util;
|
||||
|
||||
class Personal implements ISettings {
|
||||
public function getForm(): TemplateResponse {
|
||||
Util::addScript(Application::APP_ID, 'workflow-deck-automation-personal-settings');
|
||||
Util::addStyle(Application::APP_ID, 'workflow-deck-automation-personal-settings');
|
||||
|
||||
return new TemplateResponse(Application::APP_ID, 'settings/personal', [], '');
|
||||
}
|
||||
|
||||
public function getSection(): string {
|
||||
return Application::APP_ID;
|
||||
}
|
||||
|
||||
public function getPriority(): int {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Settings;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\AppInfo\Application;
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Settings\IIconSection;
|
||||
|
||||
class PersonalSection implements IIconSection {
|
||||
public function __construct(
|
||||
private IL10N $l10n,
|
||||
private IURLGenerator $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getID(): string {
|
||||
return Application::APP_ID;
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
return $this->l10n->t('Deck workflow automation');
|
||||
}
|
||||
|
||||
public function getPriority(): int {
|
||||
return 50;
|
||||
}
|
||||
|
||||
public function getIcon(): string {
|
||||
return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user