From 13903f81371af5c8479d87c1b0a256f39b6b49fc Mon Sep 17 00:00:00 2001 From: Patrick Niebeling Date: Thu, 13 Aug 2026 14:33:28 +0200 Subject: [PATCH] Hide inactive boards, keep source and target stack apart, make the interval configurable - Boards: BoardService::getUserBoards() defaults to $includeArchived = true, and that flag also gates the `deleted_at = 0` condition, so archived and trashed boards showed up in the dropdown. Request the filtered query instead, with a fallback to the no-arg call if the signature ever changes. Stacks need nothing: StackMapper::findAll() always filters deleted_at. - Each stack dropdown now hides whatever the other one holds, so source and target can no longer be set to the same stack. The save-time check stays as the backstop for rows stored before this rule. - RunWorkflowsJob reads its interval from config.php ('workflow_deck_automation.interval', seconds, default 300, clamped to a 60s minimum). TimedJob re-reads the interval on every cron pass, so a changed value takes effect without any occ command. --- CLAUDE.md | 8 +++++++- README.md | 18 ++++++++++++++++-- lib/BackgroundJob/RunWorkflowsJob.php | 26 +++++++++++++++++++++++++- lib/Service/DeckIntegrationService.php | 14 +++++++++++++- src/PersonalSettings.vue | 10 ++++++++-- 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cc55af6..74659b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,10 @@ Deck's ACL/permission checks read the *live* `IUserSession`, not a value frozen The filter-matching logic (`WorkflowRunner::cardMatchesFilters()`, `::isOverdue()`) is deliberately `static` and side-effect-free so it's unit-testable without a real Deck installation — see `tests/Unit/Service/WorkflowRunnerFilterTest.php`. +## Job interval comes from config.php + +`RunWorkflowsJob` reads `workflow_deck_automation.interval` (seconds, default 300, clamped to a 60s minimum) via `IConfig::getSystemValueInt()` in its constructor. That works *because* `TimedJob` re-reads `$this->interval` on every cron pass from the freshly constructed job — so editing `config.php` takes effect immediately, with no `occ` command and no re-registration. Don't move this into `appinfo/info.xml` or a stored app config; the constants (`INTERVAL_CONFIG_KEY`, `DEFAULT_INTERVAL`, `MINIMUM_INTERVAL`) on the job are the single source of truth and are referenced from the README. + ## Registration is declarative, not Bootstrap-based Background jobs and classic personal settings are registered in `appinfo/info.xml` (``, `/`), **not** via `IRegistrationContext` in `lib/AppInfo/Application.php` — that interface has no `registerBackgroundJob()`/`registerSettings()` methods on NC 34. `Application.php` is intentionally near-empty. @@ -112,7 +116,9 @@ This app is on `@nextcloud/vue` ^9 / Vue 3. The v8 (Vue 2) prop spellings that m Deck's `RelationalEntity` swaps resolved relations for an `OCA\Deck\Db\RelationalObject` once an entity is enriched. So on an enriched board/card, `$acl->getParticipant()`, `$board->getOwner()` and a card assignment's participant return a `RelationalObject` (wrapping `OCA\Deck\Db\User`/`Group`/`Circle`), **not** a uid — the uid is its `getPrimaryKey()`. Probing for `getUID()` on the returned object finds nothing and yields `null`, which is how the settings UI's user dropdown came up empty *and* why the runner's assigned-user filter could never match. `DeckIntegrationService::unwrapUid()` handles both shapes (bare string and `RelationalObject`); route any new participant/owner field through it. -Two more Deck facts that bit us in the same pass: a board's ACL does **not** contain its owner (a private board has an empty ACL, so participants must be seeded with `$board->getOwner()`), and ACL entries can be groups (`type === 1`) whose members have to be expanded via `IGroupManager`. `BoardService::getUserBoards()` merges own/group/circle boards and can return the same board twice, and it includes archived and trashed boards — `listBoardsForCurrentUser()` dedupes by id and drops those. +Two more Deck facts that bit us in the same pass: a board's ACL does **not** contain its owner (a private board has an empty ACL, so participants must be seeded with `$board->getOwner()`), and ACL entries can be groups (`type === 1`) whose members have to be expanded via `IGroupManager`. + +**`BoardService::getUserBoards()` defaults to `$includeArchived = true`, and that flag also gates the `deleted_at = 0` condition** — with the default you get archived *and* trashed boards back. We call `getUserBoards(null, false)` (with a fallback to the no-arg form if Deck ever changes the signature) and still dedupe by id afterwards, because it merges own/group/circle boards and can return the same board twice. Stacks need no such handling: `StackMapper::findAll()` filters `deleted_at = 0` unconditionally and stacks have no archived state. ## Local-only notes diff --git a/README.md b/README.md index 846d6e6..1c551d0 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Ein Nutzer kann beliebig viele Workflows anlegen, bearbeiten, deaktivieren oder ## 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. +- **Hintergrundjob statt Seitenaufruf.** [`lib/BackgroundJob/RunWorkflowsJob.php`](lib/BackgroundJob/RunWorkflowsJob.php) ist ein `TimedJob`, der standardmäßig alle 5 Minuten läuft (abhängig vom Nextcloud-Cron-Intervall) und [`lib/Service/WorkflowRunner.php`](lib/Service/WorkflowRunner.php) aufruft. Das Intervall ist über die `config.php` einstellbar, siehe unten. - **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. @@ -36,7 +36,21 @@ Ein Nutzer kann beliebig viele Workflows anlegen, bearbeiten, deaktivieren oder ## 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. +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. Zur Auswahl stehen nur aktive Boards — archivierte und im Papierkorb liegende Boards sowie gelöschte Stapel blendet Deck bzw. die App aus. + +## Konfiguration + +Das Prüfintervall des Hintergrundjobs lässt sich in der `config.php` setzen (Wert in **Sekunden**): + +```php +'workflow_deck_automation.interval' => 300, +``` + +- Standard ohne Eintrag: `300` (5 Minuten). +- Minimum: `60` — kleinere Werte werden auf 60 Sekunden angehoben. +- Die Änderung greift beim nächsten Cron-Durchlauf; ein `occ`-Befehl oder eine Neuinstallation der App ist nicht nötig. + +Zu beachten: Das ist eine *Untergrenze* für den Abstand zwischen zwei Läufen, keine Garantie. Nextclouds Cron selbst läuft üblicherweise nur alle 5 Minuten — ein Intervall von 60 Sekunden führt also nur dann zu minütlichen Läufen, wenn der System-Cron entsprechend häufig ausgeführt wird. ## Entwicklung diff --git a/lib/BackgroundJob/RunWorkflowsJob.php b/lib/BackgroundJob/RunWorkflowsJob.php index 3635e5f..6149102 100644 --- a/lib/BackgroundJob/RunWorkflowsJob.php +++ b/lib/BackgroundJob/RunWorkflowsJob.php @@ -8,18 +8,42 @@ use OCA\WorkflowDeckAutomation\Service\WorkflowRunner; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJob; use OCP\BackgroundJob\TimedJob; +use OCP\IConfig; class RunWorkflowsJob extends TimedJob { + /** + * config.php key holding how often (in seconds) workflows are checked. + */ + public const INTERVAL_CONFIG_KEY = 'workflow_deck_automation.interval'; + + public const DEFAULT_INTERVAL = 5 * 60; + + /** + * Nextcloud's cron itself only ticks every 5 minutes by default, so + * anything below a minute would only add load without running more + * often. Values under this are clamped rather than rejected. + */ + public const MINIMUM_INTERVAL = 60; + public function __construct( ITimeFactory $time, + IConfig $config, private WorkflowRunner $runner, ) { parent::__construct($time); - $this->setInterval(5 * 60); + // TimedJob compares against this value on every cron pass, so a + // changed config.php takes effect on the next pass — no occ command + // and no re-registration of the job needed. + $this->setInterval(self::resolveInterval($config)); $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); $this->setAllowParallelRuns(false); } + private static function resolveInterval(IConfig $config): int { + $interval = $config->getSystemValueInt(self::INTERVAL_CONFIG_KEY, self::DEFAULT_INTERVAL); + return max(self::MINIMUM_INTERVAL, $interval); + } + protected function run($argument): void { $this->runner->run(); } diff --git a/lib/Service/DeckIntegrationService.php b/lib/Service/DeckIntegrationService.php index 9391814..d2e5007 100644 --- a/lib/Service/DeckIntegrationService.php +++ b/lib/Service/DeckIntegrationService.php @@ -60,7 +60,19 @@ class DeckIntegrationService { */ public function listBoardsForCurrentUser(): array { $boards = $this->call(function () { - return $this->resolve(BoardService::class)->getUserBoards(); + $boardService = $this->resolve(BoardService::class); + try { + // getUserBoards(?int $since, bool $includeArchived, …): with + // the default $includeArchived = true, Deck skips the + // `archived = false AND deleted_at = 0` conditions entirely, + // so archived *and* trashed boards come back. Ask for the + // filtered query instead of sorting them out afterwards. + return $boardService->getUserBoards(null, false); + } catch (Throwable $e) { + // Older/newer Deck with a different signature: fall back to + // the unfiltered call, isBoardHidden() below still filters. + return $boardService->getUserBoards(); + } }, []); // Deck merges own/group/circle boards, so the same board can come diff --git a/src/PersonalSettings.vue b/src/PersonalSettings.vue index e9a7c94..208440e 100644 --- a/src/PersonalSettings.vue +++ b/src/PersonalSettings.vue @@ -74,7 +74,7 @@ @@ -82,7 +82,7 @@ @@ -167,6 +167,12 @@ const participantOptions = ref([]) const boardOptions = computed(() => boards.value.map((board) => ({ value: board.id, label: board.title }))) +// Source and target must differ, so each dropdown hides what the other one +// already holds — the error message on save stays as the backstop for +// workflows stored before this rule existed. +const sourceStackOptions = computed(() => stackOptions.value.filter((option) => option.value !== form.targetStackId)) +const targetStackOptions = computed(() => stackOptions.value.filter((option) => option.value !== form.sourceStackId)) + const form = reactive(emptyForm()) function emptyForm() {