commit 300acde27ec3363f4639de82caaa6c0b97bb42ca Author: Patrick Niebeling Date: Thu Aug 13 10:13:05 2026 +0200 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 diff --git a/.gitea/workflows/build-release.yml b/.gitea/workflows/build-release.yml new file mode 100644 index 0000000..c9fd0ea --- /dev/null +++ b/.gitea/workflows/build-release.yml @@ -0,0 +1,35 @@ +name: Build release artifact + +on: + push: + tags: + - 'v*' + +jobs: + package: + runs-on: gitea-runner-server03 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '24' + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - name: Install JS dependencies + run: npm ci + + - name: Build frontend + run: npm run build + + - name: Package appstore artifact + run: make appstore + + - uses: actions/upload-artifact@v4 + with: + name: workflow_deck_automation-${{ gitea.ref_name }} + path: build/artifacts/appstore/*.tar.gz diff --git a/.gitea/workflows/lint-info-xml.yml b/.gitea/workflows/lint-info-xml.yml new file mode 100644 index 0000000..8227275 --- /dev/null +++ b/.gitea/workflows/lint-info-xml.yml @@ -0,0 +1,23 @@ +name: Lint info.xml + +on: + push: + branches: [main] + pull_request: + +jobs: + xml-lint: + runs-on: gitea-runner-server03 + steps: + - uses: actions/checkout@v4 + + - name: Install xmllint + run: | + apt-get update + apt-get install -y --no-install-recommends libxml2-utils + + - name: Download appstore schema + run: wget -q https://raw.githubusercontent.com/nextcloud/appstore/master/nextcloudappstore/api/v1/release/info.xsd -O info.xsd + + - name: Validate appinfo/info.xml + run: xmllint --schema info.xsd appinfo/info.xml --noout diff --git a/.gitea/workflows/lint-php.yml b/.gitea/workflows/lint-php.yml new file mode 100644 index 0000000..02cfd5b --- /dev/null +++ b/.gitea/workflows/lint-php.yml @@ -0,0 +1,24 @@ +name: Lint PHP + +on: + push: + branches: [main] + pull_request: + +jobs: + php-lint: + runs-on: gitea-runner-server03 + strategy: + matrix: + php-version: ['8.2', '8.3', '8.4'] + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: Syntax check + run: | + find lib tests appinfo -name '*.php' -print0 | xargs -0 -n1 -- php -l diff --git a/.gitea/workflows/phpunit.yml b/.gitea/workflows/phpunit.yml new file mode 100644 index 0000000..82c0f24 --- /dev/null +++ b/.gitea/workflows/phpunit.yml @@ -0,0 +1,24 @@ +name: PHPUnit + +on: + push: + branches: [main] + pull_request: + +jobs: + unit-tests: + runs-on: gitea-runner-server03 + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: sqlite, pdo_sqlite + coverage: none + + - name: Install dependencies + run: composer install --no-progress --prefer-dist + + - name: Run unit tests + run: composer run test:unit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9898da --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/vendor/ +/node_modules/ +/js/ +/build/ +/composer.lock +/package-lock.json +.php-cs-fixer.cache +.phpunit.result.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..46072b6 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,13 @@ +getFinder() + ->in(__DIR__) + ->exclude(['vendor', 'node_modules', 'js', 'build', 'l10n']); +$config->setCacheFile('.php-cs-fixer.cache'); + +return $config; diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f56b26c --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +app_name=workflow_deck_automation +build_directory=$(CURDIR)/build +appstore_build_directory=$(build_directory)/appstore/$(app_name) +appstore_artifact_directory=$(build_directory)/artifacts/appstore + +.PHONY: appstore +appstore: clean + mkdir -p $(appstore_build_directory) $(appstore_artifact_directory) + rsync -av . $(appstore_build_directory) \ + --exclude=/.git \ + --exclude=/.gitea \ + --exclude=/.gitignore \ + --exclude=/composer.json \ + --exclude=/composer.lock \ + --exclude=/package.json \ + --exclude=/package-lock.json \ + --exclude=/node_modules \ + --exclude=/vite.config.js \ + --exclude=/src \ + --exclude=/tests \ + --exclude=/vendor \ + --exclude=/build \ + --exclude=/.php-cs-fixer.dist.php \ + --exclude=/.php-cs-fixer.cache \ + --exclude=/phpunit.xml \ + --exclude=/README.md \ + --exclude=/Makefile + tar -czf $(appstore_artifact_directory)/$(app_name).tar.gz -C $(build_directory)/appstore $(app_name) + +.PHONY: clean +clean: + rm -rf $(build_directory) diff --git a/README.md b/README.md new file mode 100644 index 0000000..8efde78 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Deck Workflow-Automatisierung + +Nextcloud-App für **Nextcloud Hub 26 Spring (Server 34.x)**, die überfällige Karten der [Deck](https://github.com/nextcloud/deck)-App automatisch von einem Stapel in einen anderen verschiebt und optional eine E-Mail-Benachrichtigung verschickt. + +Ersetzt das früher genutzte eigenständige PHP-Cron-Skript durch eine vollwertige App mit Oberfläche in den **persönlichen Einstellungen** — jeder Nutzer kann dort seine eigenen Regeln ("Workflows") anlegen, ohne Admin-Rechte oder Server-Zugriff zu benötigen. + +## Funktionsumfang + +Pro Workflow lässt sich konfigurieren: + +- Quell-Board und Quell-Stapel +- Ziel-Stapel, in den überfällige Karten verschoben werden +- optionaler Filter auf zugewiesene Benutzer (Karte muss **mindestens einem** der gewählten Benutzer zugewiesen sein) +- optionaler Filter auf Labels/Tags (Karte muss **mindestens eines** der gewählten Labels haben) +- Checkbox: E-Mail-Benachrichtigung an den Workflow-Besitzer, sobald eine Karte verschoben wurde + +Ein Nutzer kann beliebig viele Workflows anlegen, bearbeiten, deaktivieren oder löschen. + +## Architektur + +- **Keine HTTP/OCS-Aufrufe gegen Deck.** Alles Lesen (Boards, Stapel, Karten, Labels, zugewiesene Benutzer) und das Verschieben von Karten läuft ausschließlich über Decks eigene interne PHP-Klassen (`OCA\Deck\Service\CardService`, `StackService`, `BoardService`, `OCA\Deck\Db\CardMapper`, …), aufgelöst per Dependency Injection direkt im selben PHP-Prozess. Sämtlicher Deck-Zugriff ist in [`lib/Service/DeckIntegrationService.php`](lib/Service/DeckIntegrationService.php) gebündelt. +- **Hintergrundjob statt Seitenaufruf.** [`lib/BackgroundJob/RunWorkflowsJob.php`](lib/BackgroundJob/RunWorkflowsJob.php) ist ein `TimedJob`, der alle 5 Minuten läuft (abhängig vom Nextcloud-Cron-Intervall) und [`lib/Service/WorkflowRunner.php`](lib/Service/WorkflowRunner.php) aufruft. +- **Rechte-Kontext je Nutzer.** Da Decks Berechtigungsprüfungen die aktuell eingeloggte Session lesen, ein Hintergrundjob aber standardmäßig keinen eingeloggten Nutzer hat und Regeln mehrerer Nutzer in einem einzigen Lauf auswerten muss, "verkörpert" der `WorkflowRunner` für die Dauer der jeweiligen Workflows kurzzeitig den entsprechenden Besitzer (`IUserSession::setUser()`), bevor die Deck-Klassen aufgerufen werden. +- **E-Mail** läuft über Nextclouds eigenen `IMailer` (nutzt also den in der Nextcloud-Administration hinterlegten Mailserver) und geht an die im Profil des Workflow-Besitzers hinterlegte Adresse. + +### Wichtiger Hinweis zu Decks internen Klassen + +`OCA\Deck\*` ist keine dokumentierte, stabile öffentliche API von Deck (keine `@since`-Markierungen, keine offizielle Zusicherung von Abwärtskompatibilität). Diese App verwendet sie trotzdem bewusst direkt, wie es die Vorgabe verlangt — jeder Zugriff läuft defensiv abgesichert über `DeckIntegrationService` (Prüfung, ob Deck aktiviert ist, `class_exists()`-Checks, try/catch mit Logging statt Absturz). Nach größeren Deck-Updates lohnt sich ein Blick ins Nextcloud-Log, falls Workflows plötzlich nicht mehr greifen. + +## Installation + +1. App in `apps/workflow_deck_automation` des Nextcloud-Servers ablegen (oder über den Appstore-Build aus der CI, siehe unten). +2. In der Nextcloud-Administration unter *Apps* aktivieren. +3. Die [Deck-App](https://apps.nextcloud.com/apps/deck) muss installiert und für die jeweiligen Nutzer aktiviert sein. +4. Sicherstellen, dass der [Hintergrundjob-Modus](https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/background_jobs_configuration.html) auf *Cron (empfohlen)* steht, damit `RunWorkflowsJob` regelmäßig läuft. + +## Benutzung + +Jeder Nutzer findet die Einstellungen unter **Persönliche Einstellungen → Deck Workflow-Automatisierung**. Dort können neue Workflows angelegt, bestehende bearbeitet oder gelöscht werden. Die Dropdowns für Board/Stapel/Label/Benutzer werden live aus Deck geladen. + +## Entwicklung + +Voraussetzungen: PHP 8.2+, Composer, Node.js 24+, npm 11+. + +```bash +composer install +npm install +npm run build # einmaliger Produktions-Build +npm run watch # Entwicklung mit automatischem Rebuild +``` + +### Tests & Linting + +```bash +composer run lint # php -l über lib/ und tests/ +composer run cs:check # nextcloud/coding-standard (php-cs-fixer) +composer run test:unit # PHPUnit — reine Filter-Logik, benötigt keine Deck-Installation +``` + +Ein echter End-to-End-Test (Karte anlegen, Workflow konfigurieren, Hintergrundjob auslösen, Verschiebung + Mail prüfen) lässt sich nur gegen eine echte Nextcloud-34-Instanz mit installierter Deck-App durchführen, z. B. per: + +```bash +php occ background-job:worker workflow_deck_automation +``` + +### Release-Paket bauen + +```bash +make appstore +``` + +Erzeugt `build/artifacts/appstore/workflow_deck_automation.tar.gz`. + +## CI (Gitea Actions) + +In [`.gitea/workflows/`](.gitea/workflows/): + +| Workflow | Zweck | +| --- | --- | +| `lint-php.yml` | `php -l` über eine PHP-8.2–8.4-Matrix | +| `lint-info-xml.yml` | validiert `appinfo/info.xml` gegen das Appstore-XML-Schema | +| `phpunit.yml` | führt die PHPUnit-Tests aus (SQLite/rein logisch, kein DB-Service nötig) | +| `build-release.yml` | baut bei Push eines `v*`-Tags das Frontend und paketiert das Appstore-Archiv | + +## Datenmodell + +Workflows werden in der Tabelle `wfda_workflows` gespeichert (siehe [`lib/Migration/Version1000Date20260813120000.php`](lib/Migration/Version1000Date20260813120000.php)): eine Zeile pro Workflow, mit `user_id`-Bezug, Board-/Stapel-IDs, JSON-kodierten Filterlisten sowie `enabled`/`notify_email`/`last_run`. + +## Lizenz + +AGPL-3.0-or-later diff --git a/appinfo/info.xml b/appinfo/info.xml new file mode 100644 index 0000000..2124fc0 --- /dev/null +++ b/appinfo/info.xml @@ -0,0 +1,52 @@ + + + workflow_deck_automation + Deck Workflow-Automatisierung + Deck Workflow Automation + Verschiebt überfällige Deck-Karten automatisch anhand konfigurierbarer Regeln + Automatically moves overdue Deck cards based on configurable rules + + + 1.0.0 + agpl + Patrick Niebeling + WorkflowDeckAutomation + organization + tools + https://github.com/pniebeling/workflow_deck_automation/issues + + + pgsql + sqlite + mysql + + + + OCA\WorkflowDeckAutomation\BackgroundJob\RunWorkflowsJob + + + OCA\WorkflowDeckAutomation\Settings\Personal + OCA\WorkflowDeckAutomation\Settings\PersonalSection + + diff --git a/appinfo/routes.php b/appinfo/routes.php new file mode 100644 index 0000000..eaf19d2 --- /dev/null +++ b/appinfo/routes.php @@ -0,0 +1,17 @@ + [ + ['name' => 'workflow#index', 'url' => '/api/v1/workflows', 'verb' => 'GET'], + ['name' => 'workflow#create', 'url' => '/api/v1/workflows', 'verb' => 'POST'], + ['name' => 'workflow#update', 'url' => '/api/v1/workflows/{id}', 'verb' => 'PUT'], + ['name' => 'workflow#destroy', 'url' => '/api/v1/workflows/{id}', 'verb' => 'DELETE'], + + ['name' => 'workflow#boards', 'url' => '/api/v1/boards', 'verb' => 'GET'], + ['name' => 'workflow#stacks', 'url' => '/api/v1/boards/{boardId}/stacks', 'verb' => 'GET'], + ['name' => 'workflow#labels', 'url' => '/api/v1/boards/{boardId}/labels', 'verb' => 'GET'], + ['name' => 'workflow#participants', 'url' => '/api/v1/boards/{boardId}/participants', 'verb' => 'GET'], + ], +]; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6dc79d8 --- /dev/null +++ b/composer.json @@ -0,0 +1,34 @@ +{ + "name": "pniebeling/workflow_deck_automation", + "description": "Automates moving overdue Deck cards between stacks, configurable per user, via Deck's own internal classes.", + "license": "AGPL-3.0-or-later", + "type": "project", + "config": { + "optimize-autoloader": true, + "sort-packages": true, + "platform": { + "php": "8.2" + } + }, + "autoload": { + "psr-4": { + "OCA\\WorkflowDeckAutomation\\": "lib/" + } + }, + "autoload-dev": { + "psr-4": { + "OCA\\WorkflowDeckAutomation\\Tests\\": "tests/" + } + }, + "require-dev": { + "nextcloud/ocp": "dev-master", + "nextcloud/coding-standard": "^1.3", + "phpunit/phpunit": "^10.5" + }, + "scripts": { + "lint": "find lib tests -name '*.php' -print0 | xargs -0 -n1 -- php -l", + "cs:check": "php-cs-fixer fix --dry-run --diff", + "cs:fix": "php-cs-fixer fix", + "test:unit": "phpunit --testsuite unit" + } +} diff --git a/img/app-dark.svg b/img/app-dark.svg new file mode 100644 index 0000000..ebed0c0 --- /dev/null +++ b/img/app-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/img/app.svg b/img/app.svg new file mode 100644 index 0000000..974a80f --- /dev/null +++ b/img/app.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php new file mode 100644 index 0000000..db04d2c --- /dev/null +++ b/lib/AppInfo/Application.php @@ -0,0 +1,24 @@ +setInterval(5 * 60); + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + $this->setAllowParallelRuns(false); + } + + protected function run($argument): void { + $this->runner->run(); + } +} diff --git a/lib/Controller/WorkflowController.php b/lib/Controller/WorkflowController.php new file mode 100644 index 0000000..6c8f364 --- /dev/null +++ b/lib/Controller/WorkflowController.php @@ -0,0 +1,183 @@ +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); + } +} diff --git a/lib/Db/Workflow.php b/lib/Db/Workflow.php new file mode 100644 index 0000000..11d71fa --- /dev/null +++ b/lib/Db/Workflow.php @@ -0,0 +1,98 @@ +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), + ]; + } +} diff --git a/lib/Db/WorkflowMapper.php b/lib/Db/WorkflowMapper.php new file mode 100644 index 0000000..47d4ecc --- /dev/null +++ b/lib/Db/WorkflowMapper.php @@ -0,0 +1,61 @@ + + */ +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); + } +} diff --git a/lib/Migration/Version1000Date20260813120000.php b/lib/Migration/Version1000Date20260813120000.php new file mode 100644 index 0000000..8e2a894 --- /dev/null +++ b/lib/Migration/Version1000Date20260813120000.php @@ -0,0 +1,66 @@ +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; + } +} diff --git a/lib/Service/DeckIntegrationService.php b/lib/Service/DeckIntegrationService.php new file mode 100644 index 0000000..2b82cde --- /dev/null +++ b/lib/Service/DeckIntegrationService.php @@ -0,0 +1,230 @@ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 $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; + } +} diff --git a/lib/Service/DeckUnavailableException.php b/lib/Service/DeckUnavailableException.php new file mode 100644 index 0000000..4867a8e --- /dev/null +++ b/lib/Service/DeckUnavailableException.php @@ -0,0 +1,14 @@ +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, + ]); + } + } +} diff --git a/lib/Service/WorkflowRunner.php b/lib/Service/WorkflowRunner.php new file mode 100644 index 0000000..94228ff --- /dev/null +++ b/lib/Service/WorkflowRunner.php @@ -0,0 +1,185 @@ +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; + } +} diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php new file mode 100644 index 0000000..81bba5c --- /dev/null +++ b/lib/Settings/Personal.php @@ -0,0 +1,27 @@ +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'); + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f785261 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "workflow_deck_automation", + "version": "1.0.0", + "private": true, + "description": "Automates moving overdue Deck cards between stacks, configurable per user.", + "scripts": { + "build": "vite build", + "dev": "vite --mode development build", + "watch": "vite --mode development build --watch" + }, + "license": "AGPL-3.0-or-later", + "dependencies": { + "@nextcloud/axios": "^2.5.0", + "@nextcloud/l10n": "^3.1.0", + "@nextcloud/router": "^3.0.1", + "@nextcloud/vue": "^9.0.0", + "vue": "^3.5.0" + }, + "devDependencies": { + "@nextcloud/browserslist-config": "^3.0.1", + "@nextcloud/vite-config": "^2.2.0", + "vite": "^6.0.0" + }, + "engines": { + "node": "^24.0.0", + "npm": "^11.0.0" + }, + "browserslist": [ + "extends @nextcloud/browserslist-config" + ] +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..2ece243 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,16 @@ + + + + + tests/Unit + + + + + lib + + + diff --git a/src/PersonalSettings.vue b/src/PersonalSettings.vue new file mode 100644 index 0000000..bf8bda5 --- /dev/null +++ b/src/PersonalSettings.vue @@ -0,0 +1,369 @@ + + + + + diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..da482c2 --- /dev/null +++ b/src/api.js @@ -0,0 +1,45 @@ +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +function ocsUrl(path) { + return generateOcsUrl(`apps/workflow_deck_automation/api/v1${path}`) +} + +export async function fetchWorkflows() { + const { data } = await axios.get(ocsUrl('/workflows')) + return data.ocs.data +} + +export async function createWorkflow(payload) { + const { data } = await axios.post(ocsUrl('/workflows'), payload) + return data.ocs.data +} + +export async function updateWorkflow(id, payload) { + const { data } = await axios.put(ocsUrl(`/workflows/${id}`), payload) + return data.ocs.data +} + +export async function deleteWorkflow(id) { + await axios.delete(ocsUrl(`/workflows/${id}`)) +} + +export async function fetchBoards() { + const { data } = await axios.get(ocsUrl('/boards')) + return data.ocs.data +} + +export async function fetchStacks(boardId) { + const { data } = await axios.get(ocsUrl(`/boards/${boardId}/stacks`)) + return data.ocs.data +} + +export async function fetchLabels(boardId) { + const { data } = await axios.get(ocsUrl(`/boards/${boardId}/labels`)) + return data.ocs.data +} + +export async function fetchParticipants(boardId) { + const { data } = await axios.get(ocsUrl(`/boards/${boardId}/participants`)) + return data.ocs.data +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..ba3b9d0 --- /dev/null +++ b/src/main.js @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import PersonalSettings from './PersonalSettings.vue' + +const mountPoint = document.getElementById('workflow-deck-automation-personal-settings') +if (mountPoint) { + createApp(PersonalSettings).mount(mountPoint) +} diff --git a/templates/settings/personal.php b/templates/settings/personal.php new file mode 100644 index 0000000..1ee1517 --- /dev/null +++ b/templates/settings/personal.php @@ -0,0 +1,4 @@ + +
diff --git a/tests/Unit/Service/WorkflowRunnerFilterTest.php b/tests/Unit/Service/WorkflowRunnerFilterTest.php new file mode 100644 index 0000000..6f71fda --- /dev/null +++ b/tests/Unit/Service/WorkflowRunnerFilterTest.php @@ -0,0 +1,46 @@ +assertFalse(WorkflowRunner::isOverdue(null)); + } + + public function testIsOverdueReturnsFalseForFutureDueDate(): void { + $this->assertFalse(WorkflowRunner::isOverdue(0)); + $this->assertFalse(WorkflowRunner::isOverdue(3)); + } + + public function testIsOverdueReturnsTrueForPastDueDate(): void { + $this->assertTrue(WorkflowRunner::isOverdue(-1)); + $this->assertTrue(WorkflowRunner::isOverdue(-30)); + } + + public function testNoFiltersMatchesAnyCard(): void { + $this->assertTrue(WorkflowRunner::cardMatchesFilters(['alice'], [1, 2], [], [])); + $this->assertTrue(WorkflowRunner::cardMatchesFilters([], [], [], [])); + } + + public function testUserFilterIsOrAgainstAssignedUsers(): void { + $this->assertTrue(WorkflowRunner::cardMatchesFilters(['alice', 'bob'], [], ['bob', 'carol'], [])); + $this->assertFalse(WorkflowRunner::cardMatchesFilters(['alice'], [], ['bob', 'carol'], [])); + } + + public function testLabelFilterIsOrAgainstCardLabels(): void { + $this->assertTrue(WorkflowRunner::cardMatchesFilters([], [1, 2], [], [2, 3])); + $this->assertFalse(WorkflowRunner::cardMatchesFilters([], [1], [], [2, 3])); + } + + public function testUserAndLabelFiltersAreCombinedWithAnd(): void { + // user matches, label does not -> overall false + $this->assertFalse(WorkflowRunner::cardMatchesFilters(['alice'], [1], ['alice'], [99])); + // both match -> true + $this->assertTrue(WorkflowRunner::cardMatchesFilters(['alice'], [1], ['alice'], [1])); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..a075e1e --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,5 @@ +