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,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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/vendor/
|
||||||
|
/node_modules/
|
||||||
|
/js/
|
||||||
|
/build/
|
||||||
|
/composer.lock
|
||||||
|
/package-lock.json
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
.phpunit.result.cache
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Nextcloud\CodingStandard\Config;
|
||||||
|
|
||||||
|
$config = new Config();
|
||||||
|
$config->getFinder()
|
||||||
|
->in(__DIR__)
|
||||||
|
->exclude(['vendor', 'node_modules', 'js', 'build', 'l10n']);
|
||||||
|
$config->setCacheFile('.php-cs-fixer.cache');
|
||||||
|
|
||||||
|
return $config;
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||||
|
<id>workflow_deck_automation</id>
|
||||||
|
<name lang="de">Deck Workflow-Automatisierung</name>
|
||||||
|
<name lang="en">Deck Workflow Automation</name>
|
||||||
|
<summary lang="de">Verschiebt überfällige Deck-Karten automatisch anhand konfigurierbarer Regeln</summary>
|
||||||
|
<summary lang="en">Automatically moves overdue Deck cards based on configurable rules</summary>
|
||||||
|
<description lang="de"><![CDATA[
|
||||||
|
Diese App erlaubt es jedem Nutzer, in den persönlichen Einstellungen eigene Automatisierungs-Workflows für die Deck-App anzulegen:
|
||||||
|
|
||||||
|
* Quell-Board und Quell-Stapel wählen
|
||||||
|
* Ziel-Stapel wählen, in den überfällige Karten verschoben werden
|
||||||
|
* optional nach zugewiesenen Benutzern filtern
|
||||||
|
* optional nach Labels/Tags filtern
|
||||||
|
* optional eine E-Mail-Benachrichtigung an sich selbst aktivieren
|
||||||
|
|
||||||
|
Ein Hintergrundjob prüft regelmäßig alle aktiven Workflows und verschiebt Karten, deren Fälligkeitsdatum überschritten wurde, ausschließlich über die internen PHP-Klassen der Deck-App (keine HTTP/OCS-Aufrufe).
|
||||||
|
]]></description>
|
||||||
|
<description lang="en"><![CDATA[
|
||||||
|
This app lets every user configure their own Deck automation workflows from Personal settings:
|
||||||
|
|
||||||
|
* pick a source board and source stack
|
||||||
|
* pick a destination stack overdue cards get moved to
|
||||||
|
* optionally filter by assigned users
|
||||||
|
* optionally filter by labels/tags
|
||||||
|
* optionally enable an email notification to themselves
|
||||||
|
|
||||||
|
A background job periodically evaluates all enabled workflows and moves cards whose due date has passed, using Deck's own internal PHP classes only (no HTTP/OCS calls).
|
||||||
|
]]></description>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<licence>agpl</licence>
|
||||||
|
<author mail="patrick@niebel.ing">Patrick Niebeling</author>
|
||||||
|
<namespace>WorkflowDeckAutomation</namespace>
|
||||||
|
<category>organization</category>
|
||||||
|
<category>tools</category>
|
||||||
|
<bugs>https://github.com/pniebeling/workflow_deck_automation/issues</bugs>
|
||||||
|
<dependencies>
|
||||||
|
<php min-version="8.2" max-version="8.4"/>
|
||||||
|
<database min-version="9.4">pgsql</database>
|
||||||
|
<database>sqlite</database>
|
||||||
|
<database min-version="8.0">mysql</database>
|
||||||
|
<nextcloud min-version="34" max-version="34"/>
|
||||||
|
</dependencies>
|
||||||
|
<background-jobs>
|
||||||
|
<job>OCA\WorkflowDeckAutomation\BackgroundJob\RunWorkflowsJob</job>
|
||||||
|
</background-jobs>
|
||||||
|
<settings>
|
||||||
|
<personal>OCA\WorkflowDeckAutomation\Settings\Personal</personal>
|
||||||
|
<personal-section>OCA\WorkflowDeckAutomation\Settings\PersonalSection</personal-section>
|
||||||
|
</settings>
|
||||||
|
</info>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ocs' => [
|
||||||
|
['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'],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||||
|
<rect x="1" y="4" width="7" height="16" rx="1.5" fill="none" stroke="#ffffff" stroke-width="1.5" />
|
||||||
|
<rect x="16" y="4" width="7" height="16" rx="1.5" fill="none" stroke="#ffffff" stroke-width="1.5" />
|
||||||
|
<path d="M9 12h5.5m0 0-2-2m2 2-2 2" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 429 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||||
|
<rect x="1" y="4" width="7" height="16" rx="1.5" fill="none" stroke="#000000" stroke-width="1.5" />
|
||||||
|
<rect x="16" y="4" width="7" height="16" rx="1.5" fill="none" stroke="#000000" stroke-width="1.5" />
|
||||||
|
<path d="M9 12h5.5m0 0-2-2m2 2-2 2" fill="none" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 429 B |
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
|
||||||
|
bootstrap="tests/bootstrap.php"
|
||||||
|
colors="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="unit">
|
||||||
|
<directory>tests/Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory suffix=".php">lib</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<template>
|
||||||
|
<NcSettingsSection
|
||||||
|
name="Deck Workflow-Automatisierung"
|
||||||
|
description="Verschiebt überfällige Karten aus einem Deck-Stapel automatisch in einen anderen Stapel. Ein Hintergrundjob prüft die Regeln alle paar Minuten.">
|
||||||
|
<NcNoteCard v-if="loadError" type="error">
|
||||||
|
{{ loadError }}
|
||||||
|
</NcNoteCard>
|
||||||
|
|
||||||
|
<div v-if="loading" class="wfda-loading">
|
||||||
|
<NcLoadingIcon :size="32" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<table v-if="workflows.length" class="wfda-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Von Stapel</th>
|
||||||
|
<th>Nach Stapel</th>
|
||||||
|
<th>Filter</th>
|
||||||
|
<th>E-Mail</th>
|
||||||
|
<th>Aktiv</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="workflow in workflows" :key="workflow.id">
|
||||||
|
<td>{{ workflow.title }}</td>
|
||||||
|
<td>{{ stackLabel(workflow.boardId, workflow.sourceStackId) }}</td>
|
||||||
|
<td>{{ stackLabel(workflow.boardId, workflow.targetStackId) }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="!workflow.filterUserIds.length && !workflow.filterLabelIds.length">–</span>
|
||||||
|
<span v-else>
|
||||||
|
<span v-if="workflow.filterUserIds.length">{{ workflow.filterUserIds.length }} Benutzer</span>
|
||||||
|
<span v-if="workflow.filterUserIds.length && workflow.filterLabelIds.length">, </span>
|
||||||
|
<span v-if="workflow.filterLabelIds.length">{{ workflow.filterLabelIds.length }} Label</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ workflow.notifyEmail ? 'Ja' : 'Nein' }}</td>
|
||||||
|
<td>{{ workflow.enabled ? 'Ja' : 'Nein' }}</td>
|
||||||
|
<td class="wfda-actions">
|
||||||
|
<NcButton type="tertiary" @click="editWorkflow(workflow)">
|
||||||
|
Bearbeiten
|
||||||
|
</NcButton>
|
||||||
|
<NcButton type="tertiary" @click="removeWorkflow(workflow)">
|
||||||
|
Löschen
|
||||||
|
</NcButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else class="wfda-empty">
|
||||||
|
Noch keine Workflows angelegt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<NcButton v-if="!showForm" type="primary" class="wfda-add-button" @click="startCreate">
|
||||||
|
Workflow hinzufügen
|
||||||
|
</NcButton>
|
||||||
|
|
||||||
|
<form v-if="showForm" class="wfda-form" @submit.prevent="save">
|
||||||
|
<NcTextField
|
||||||
|
:value.sync="form.title"
|
||||||
|
label="Titel"
|
||||||
|
required />
|
||||||
|
|
||||||
|
<NcSelect
|
||||||
|
v-model="form.boardId"
|
||||||
|
label="Board"
|
||||||
|
:options="boardOptions"
|
||||||
|
:reduce="option => option.value"
|
||||||
|
placeholder="Board wählen"
|
||||||
|
@update:model-value="onBoardChange" />
|
||||||
|
|
||||||
|
<NcSelect
|
||||||
|
v-model="form.sourceStackId"
|
||||||
|
label="Quell-Stapel"
|
||||||
|
:options="stackOptions"
|
||||||
|
:reduce="option => option.value"
|
||||||
|
:disabled="!form.boardId"
|
||||||
|
placeholder="Quell-Stapel wählen" />
|
||||||
|
|
||||||
|
<NcSelect
|
||||||
|
v-model="form.targetStackId"
|
||||||
|
label="Ziel-Stapel"
|
||||||
|
:options="stackOptions"
|
||||||
|
:reduce="option => option.value"
|
||||||
|
:disabled="!form.boardId"
|
||||||
|
placeholder="Ziel-Stapel wählen" />
|
||||||
|
|
||||||
|
<NcSelect
|
||||||
|
v-model="form.filterUserIds"
|
||||||
|
label="Nur für zugewiesene Benutzer (optional)"
|
||||||
|
:options="participantOptions"
|
||||||
|
:reduce="option => option.value"
|
||||||
|
:disabled="!form.boardId"
|
||||||
|
multiple
|
||||||
|
placeholder="Alle Benutzer" />
|
||||||
|
|
||||||
|
<NcSelect
|
||||||
|
v-model="form.filterLabelIds"
|
||||||
|
label="Nur mit Label (optional)"
|
||||||
|
:options="labelOptions"
|
||||||
|
:reduce="option => option.value"
|
||||||
|
:disabled="!form.boardId"
|
||||||
|
multiple
|
||||||
|
placeholder="Alle Labels" />
|
||||||
|
|
||||||
|
<NcCheckboxRadioSwitch :checked.sync="form.notifyEmail">
|
||||||
|
Benachrichtigungs-E-Mail an mich senden
|
||||||
|
</NcCheckboxRadioSwitch>
|
||||||
|
|
||||||
|
<NcCheckboxRadioSwitch :checked.sync="form.enabled">
|
||||||
|
Workflow aktiv
|
||||||
|
</NcCheckboxRadioSwitch>
|
||||||
|
|
||||||
|
<NcNoteCard v-if="formError" type="error">
|
||||||
|
{{ formError }}
|
||||||
|
</NcNoteCard>
|
||||||
|
|
||||||
|
<div class="wfda-form-actions">
|
||||||
|
<NcButton type="primary" native-type="submit" :disabled="saving">
|
||||||
|
Speichern
|
||||||
|
</NcButton>
|
||||||
|
<NcButton type="tertiary" :disabled="saving" @click="cancelForm">
|
||||||
|
Abbrechen
|
||||||
|
</NcButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
</NcSettingsSection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
|
||||||
|
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
|
||||||
|
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
|
||||||
|
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
|
||||||
|
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
|
||||||
|
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
|
||||||
|
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
|
||||||
|
import {
|
||||||
|
createWorkflow,
|
||||||
|
deleteWorkflow,
|
||||||
|
fetchBoards,
|
||||||
|
fetchLabels,
|
||||||
|
fetchParticipants,
|
||||||
|
fetchStacks,
|
||||||
|
fetchWorkflows,
|
||||||
|
updateWorkflow,
|
||||||
|
} from './api.js'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref('')
|
||||||
|
const workflows = ref([])
|
||||||
|
const boards = ref([])
|
||||||
|
const stacksByBoard = reactive({})
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const formError = ref('')
|
||||||
|
const editingId = ref(null)
|
||||||
|
|
||||||
|
const stackOptions = ref([])
|
||||||
|
const labelOptions = ref([])
|
||||||
|
const participantOptions = ref([])
|
||||||
|
|
||||||
|
const boardOptions = computed(() => boards.value.map((board) => ({ value: board.id, label: board.title })))
|
||||||
|
|
||||||
|
const form = reactive(emptyForm())
|
||||||
|
|
||||||
|
function emptyForm() {
|
||||||
|
return {
|
||||||
|
title: '',
|
||||||
|
boardId: null,
|
||||||
|
sourceStackId: null,
|
||||||
|
targetStackId: null,
|
||||||
|
filterUserIds: [],
|
||||||
|
filterLabelIds: [],
|
||||||
|
notifyEmail: false,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackLabel(boardId, stackId) {
|
||||||
|
const stacks = stacksByBoard[boardId]
|
||||||
|
if (!stacks) {
|
||||||
|
return `#${stackId}`
|
||||||
|
}
|
||||||
|
const stack = stacks.find((entry) => entry.id === stackId)
|
||||||
|
return stack ? stack.title : `#${stackId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStacksFor(boardId) {
|
||||||
|
if (!boardId || stacksByBoard[boardId]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
stacksByBoard[boardId] = await fetchStacks(boardId)
|
||||||
|
} catch (e) {
|
||||||
|
stacksByBoard[boardId] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
const [loadedWorkflows, loadedBoards] = await Promise.all([fetchWorkflows(), fetchBoards()])
|
||||||
|
workflows.value = loadedWorkflows
|
||||||
|
boards.value = loadedBoards
|
||||||
|
await Promise.all(loadedWorkflows.map((workflow) => loadStacksFor(workflow.boardId)))
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = 'Konnte Workflows oder Deck-Boards nicht laden. Ist die Deck-App aktiviert?'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBoardChange(boardId) {
|
||||||
|
form.sourceStackId = null
|
||||||
|
form.targetStackId = null
|
||||||
|
form.filterUserIds = []
|
||||||
|
form.filterLabelIds = []
|
||||||
|
stackOptions.value = []
|
||||||
|
labelOptions.value = []
|
||||||
|
participantOptions.value = []
|
||||||
|
|
||||||
|
if (!boardId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [stacks, labels, participants] = await Promise.all([
|
||||||
|
fetchStacks(boardId),
|
||||||
|
fetchLabels(boardId),
|
||||||
|
fetchParticipants(boardId),
|
||||||
|
])
|
||||||
|
|
||||||
|
stacksByBoard[boardId] = stacks
|
||||||
|
stackOptions.value = stacks.map((stack) => ({ value: stack.id, label: stack.title }))
|
||||||
|
labelOptions.value = labels.map((label) => ({ value: label.id, label: label.title }))
|
||||||
|
participantOptions.value = participants.map((participant) => ({ value: participant.uid, label: participant.displayName }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCreate() {
|
||||||
|
Object.assign(form, emptyForm())
|
||||||
|
editingId.value = null
|
||||||
|
formError.value = ''
|
||||||
|
stackOptions.value = []
|
||||||
|
labelOptions.value = []
|
||||||
|
participantOptions.value = []
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editWorkflow(workflow) {
|
||||||
|
Object.assign(form, {
|
||||||
|
title: workflow.title,
|
||||||
|
boardId: workflow.boardId,
|
||||||
|
sourceStackId: workflow.sourceStackId,
|
||||||
|
targetStackId: workflow.targetStackId,
|
||||||
|
filterUserIds: [...workflow.filterUserIds],
|
||||||
|
filterLabelIds: [...workflow.filterLabelIds],
|
||||||
|
notifyEmail: workflow.notifyEmail,
|
||||||
|
enabled: workflow.enabled,
|
||||||
|
})
|
||||||
|
editingId.value = workflow.id
|
||||||
|
formError.value = ''
|
||||||
|
showForm.value = true
|
||||||
|
await onBoardChange(workflow.boardId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelForm() {
|
||||||
|
showForm.value = false
|
||||||
|
formError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
formError.value = ''
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
formError.value = 'Bitte einen Titel angeben.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.boardId || !form.sourceStackId || !form.targetStackId) {
|
||||||
|
formError.value = 'Bitte Board, Quell- und Ziel-Stapel wählen.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.sourceStackId === form.targetStackId) {
|
||||||
|
formError.value = 'Quell- und Ziel-Stapel müssen sich unterscheiden.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await updateWorkflow(editingId.value, form)
|
||||||
|
} else {
|
||||||
|
await createWorkflow(form)
|
||||||
|
}
|
||||||
|
showForm.value = false
|
||||||
|
await loadAll()
|
||||||
|
} catch (e) {
|
||||||
|
formError.value = e?.response?.data?.ocs?.meta?.message || 'Speichern fehlgeschlagen.'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeWorkflow(workflow) {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
if (!window.confirm(`Workflow "${workflow.title}" wirklich löschen?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await deleteWorkflow(workflow.id)
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadAll)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wfda-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wfda-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wfda-table th,
|
||||||
|
.wfda-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wfda-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wfda-empty {
|
||||||
|
color: var(--color-text-maxcontrast);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wfda-add-button {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wfda-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wfda-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+45
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
/** @var array $_ */
|
||||||
|
?>
|
||||||
|
<div id="workflow-deck-automation-personal-settings"></div>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\WorkflowDeckAutomation\Tests\Unit\Service;
|
||||||
|
|
||||||
|
use OCA\WorkflowDeckAutomation\Service\WorkflowRunner;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class WorkflowRunnerFilterTest extends TestCase {
|
||||||
|
public function testIsOverdueReturnsFalseWithoutDueDate(): void {
|
||||||
|
$this->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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createAppConfig } from '@nextcloud/vite-config'
|
||||||
|
|
||||||
|
export default createAppConfig({
|
||||||
|
'workflow-deck-automation-personal-settings': 'src/main.js',
|
||||||
|
}, {
|
||||||
|
createEmptyCSSEntryPoints: true,
|
||||||
|
config: {
|
||||||
|
build: {
|
||||||
|
cssCodeSplit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user