1 Commits
Author SHA1 Message Date
gnilebein 28b4edc811 revert 0e66319a06
Build package / php-lint (8.4) (push) Successful in 43s
Build package / php-lint (8.5) (push) Successful in 40s
Build package / xml-lint (push) Successful in 12s
Build package / unit-tests (push) Successful in 38s
Build package / package (push) Successful in 52s
revert Let each workflow watch the due date or the start date

Deck 1.18 added a start date to cards (Card::$startdate), so a workflow
no longer has to mean "overdue". Each rule now carries a date_field
('due' | 'start'), chosen in the settings form next to board and stacks.

Existing rules keep the due date: the new column's default supplies it
for every stored row, so there is no backfill step, and
Workflow::getDateFieldOrDue() covers entities that were never near the
database as well as hand-edited values. The controller is the one place
that rejects an unknown value instead of normalising it - a client
asking for a rule it would not get should hear about it.

The probe for the field is property_exists(), not method_exists():
Deck's entities declare no getter as real code - getStartdate(),
getDuedate(), even getId() all go through Entity::__call(), which
method_exists() ignores by definition. A method_exists() guard would
have been false on every Deck version and would have turned every
start-date rule into a silent no-op.

assertDeckAvailable() now also refuses Deck older than 1.18.0. That
floor cannot live in appinfo/info.xml - neither the server's nor the app
store's schema has an app-to-app dependency element - but
<nextcloud min-version="34"> already implies it, since 1.18.x is the
only Deck release line published for Nextcloud 34.

isOverdue() becomes hasPassed(): one function for both dates, since the
comparison and the argument against Deck's day-truncating
getDaysUntilDue() are identical for either.

Also carries a pending NcSelect icon-alignment fix that was already in
the working tree.

Bump to 34.1.0.
2026-08-26 07:10:55 +02:00
13 changed files with 41 additions and 390 deletions
+2 -35
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is ## What this is
A Nextcloud app (`workflow_deck_automation`, namespace `WorkflowDeckAutomation`) targeting **Nextcloud Hub 26 Spring (server 34.x)**. It lets each user configure, from their personal settings, automation "workflows" that move [Deck](https://github.com/nextcloud/deck) cards from one stack to another once their due date or start date has passed, with optional assigned-user/label filters and an email notification. A background `Job` evaluates all enabled workflows on every cron pass. A Nextcloud app (`workflow_deck_automation`, namespace `WorkflowDeckAutomation`) targeting **Nextcloud Hub 26 Spring (server 34.x)**. It lets each user configure, from their personal settings, automation "workflows" that move overdue [Deck](https://github.com/nextcloud/deck) cards from one stack to another, with optional assigned-user/label filters and an email notification. A background `Job` evaluates all enabled workflows on every cron pass.
## Non-negotiable architectural constraint ## Non-negotiable architectural constraint
@@ -12,27 +12,6 @@ A Nextcloud app (`workflow_deck_automation`, namespace `WorkflowDeckAutomation`)
All Deck access is funneled through `lib/Service/DeckIntegrationService.php` — it is the *only* class that references `OCA\Deck\*`. If you need new Deck data, extend that class rather than reaching into Deck internals from elsewhere. Every call in there is wrapped (`class_exists()` checks, `IAppManager::isEnabledForUser('deck', …)`, try/catch → `DeckUnavailableException`) because `OCA\Deck\*` is not a documented/stable public API — it has no `@since` markers and can change between Deck releases without notice. All Deck access is funneled through `lib/Service/DeckIntegrationService.php` — it is the *only* class that references `OCA\Deck\*`. If you need new Deck data, extend that class rather than reaching into Deck internals from elsewhere. Every call in there is wrapped (`class_exists()` checks, `IAppManager::isEnabledForUser('deck', …)`, try/catch → `DeckUnavailableException`) because `OCA\Deck\*` is not a documented/stable public API — it has no `@since` markers and can change between Deck releases without notice.
## `method_exists()` cannot see Deck's getters — use `property_exists()`
`Card`, `Board`, `Label`, `Acl` and `Assignment` declare **no getters as real code**. `getStartdate()`, `getDuedate()`, `getDeletedAt()`, `getArchived()`, even `getId()` are `@method` docblocks resolved by `OCP\AppFramework\Db\Entity::__call()` (`Entity` itself declares neither `getId()` nor `setId()`). `method_exists()` ignores `__call()` by definition, so **every `method_exists($deckEntity, 'getSomething')` probe in this app is permanently false**, on every Deck version, and the guarded branch is dead code that fails open or closed without a sound.
`DeckIntegrationService::getCardDate()` therefore probes `property_exists($card, 'startdate')` — the protected property declaration is visible, and it is exactly what `Entity::getter()` checks before throwing `BadFunctionCallException`. Use that shape for any new optional Deck field.
**Known dead probes still in the tree** (each one a separate fix, none of them yet made — verify against a live instance before touching, since the symptoms are silent):
- `getCardLabelIds()` / `extractParticipantUid()` — always return `[]`/`null`, so **label and assigned-user filters cannot match any card**, and ACL participants never reach the settings dropdown. Only the board owner survives, because `RelationalObject::getPrimaryKey()` *is* real code — which is why the bug is invisible on a private board (empty ACL, owner = the user themselves) and looks fixed.
- `getActiveCardsInStack()`'s `getDeletedAt` check — trashed cards are moved.
- `findStackIds()`'s `getDeletedAt` check — a board in the trash is not recognised as gone.
- `isBoardHidden()` — dead, but masked by `getUserBoards(null, false)` doing the filtering server-side.
## The Deck version floor lives in code, because info.xml cannot express it
`DeckIntegrationService::MIN_DECK_VERSION` (`1.18.0`) plus the `version_compare()` in `assertDeckAvailable()` is the *only* enforcement point for "this app needs a recent Deck". Neither the server's `resources/app-info.xsd` nor the app store's `info.xsd` has an app-to-app dependency element — `<dependencies>` takes only `php`, `database`, `command`, `lib`, `owncloud`, `nextcloud`, `architecture`, `backend`. Don't go looking for a `<app min-version=…>`; it does not exist in either schema.
`<nextcloud min-version="34" max-version="34"/>` already does most of the work and is the strictest pin available: **Deck 1.18.x is the only Deck release line declaring `>=34.0.0,<35.0.0`**, and 1.18.0 is exactly the release that added the card start date (`Card::$startdate`, PR #7749). So on any Nextcloud this app installs on, Deck already has that field, and 1.17.x cannot be enabled at all (its own `max-version` is 33, so a server upgrade to 34 disables it). What the version check still covers is a Deck dropped in by hand or checked out from git.
`version_compare()` sorts `1.18.0-beta.x` *below* `1.18.0`. That is intended — pre-releases of that line may predate the field. Since `assertDeckAvailable()` is called from both the controller (→ `OCSPreconditionFailedException`, visible as an error on the settings page) and the runner (→ skip + info log), a too-old Deck degrades in both places instead of failing halfway through a card move.
## Per-user impersonation in the background job ## Per-user impersonation in the background job
`RunWorkflowsJob` has no logged-in user by default and must evaluate workflows belonging to many different users within one PHP process. `lib/Service/WorkflowRunner.php` therefore groups workflows by owner and impersonates each owner in turn. **Impersonation here is two steps, and `IUserSession::setUser()` is the one that barely matters:** `RunWorkflowsJob` has no logged-in user by default and must evaluate workflows belonging to many different users within one PHP process. `lib/Service/WorkflowRunner.php` therefore groups workflows by owner and impersonates each owner in turn. **Impersonation here is two steps, and `IUserSession::setUser()` is the one that barely matters:**
@@ -48,19 +27,7 @@ In cron that is *never* the user being impersonated: `null` if any Deck-owned ba
Consequence for `getUserBoards()`: `listBoardsForCurrentUser()` stays **request-only** anyway — Deck may already have cached board lists behind that field, and the job has no use for a board *list*; it asks `findStackIds()` about one known board id. Before using any other `OCA\Deck\*` service from the runner, check whether it carries a frozen `$userId` — the failure mode is silent and user-crossing, not an error. Consequence for `getUserBoards()`: `listBoardsForCurrentUser()` stays **request-only** anyway — Deck may already have cached board lists behind that field, and the job has no use for a board *list*; it asks `findStackIds()` about one known board id. Before using any other `OCA\Deck\*` service from the runner, check whether it carries a frozen `$userId` — the failure mode is silent and user-crossing, not an error.
The filter logic (`WorkflowRunner::cardMatchesFilters()`, `::hasPassed()`, `::findDeadFilters()`) is deliberately `static` and side-effect-free so it's unit-testable without a real Deck installation — see `tests/Unit/Service/WorkflowRunnerFilterTest.php`. Keep new pure logic in that shape; it is the only part of this app that has tests at all. The filter logic (`WorkflowRunner::cardMatchesFilters()`, `::isOverdue()`, `::findDeadFilters()`) is deliberately `static` and side-effect-free so it's unit-testable without a real Deck installation — see `tests/Unit/Service/WorkflowRunnerFilterTest.php`. Keep new pure logic in that shape; it is the only part of this app that has tests at all.
## Due date or start date, per workflow
Each workflow carries a `date_field` (`'due'` | `'start'`, `Workflow::DATE_FIELD_*`) deciding which Deck card date it watches. `WorkflowRunner::hasPassed()` (formerly `isOverdue()`) is one function for both, because the comparison is identical — the raw timestamp against `$now` — and the whole argument against Deck's day-truncating `getDaysUntilDue()` applies unchanged to the start date. What differs is only the wording, in the UI and in `NotificationMailer`.
Three places keep an unknown value from becoming a surprise trigger, and all three resolve **towards the due date**, because that is what every workflow meant before the choice existed:
- the column default `'due'`, which is also the entire migration for existing rows (`Version1001Date20260825120000`, a separate step because `Version1000…` is wrapped in `hasTable()` and would never reach an existing install),
- `Workflow::getDateFieldOrDue()`, for entities that were never near the database and for hand-edited rows,
- `DeckIntegrationService::CARD_DATE_PROPERTIES`'s `?? DEFAULT_CARD_DATE`.
The controller is the exception: it *rejects* an unknown `dateField` instead of normalising it, since a client asking for a rule it would not get should hear about it.
## Workflows are auto-disabled when their Deck targets vanish ## Workflows are auto-disabled when their Deck targets vanish
+2 -5
View File
@@ -1,8 +1,6 @@
# Deck Workflow-Automatisierung # Deck Workflow-Automatisierung
Nextcloud-App für **Nextcloud Hub 26 Spring (Server 34.x)**, die Karten der [Deck](https://github.com/nextcloud/deck)-App automatisch von einem Stapel in einen anderen verschiebt, sobald ihr Fälligkeits- oder Startdatum erreicht ist, und optional eine E-Mail-Benachrichtigung verschickt. 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.
Setzt **Deck ab 1.18.0** voraus (die Version, die das Startdatum eingeführt hat) — auf Nextcloud 34 ist das automatisch erfüllt, da 1.18.x die einzige dort freigegebene Deck-Reihe ist.
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. 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.
@@ -11,8 +9,7 @@ Ersetzt das früher genutzte eigenständige PHP-Cron-Skript durch eine vollwerti
Pro Workflow lässt sich konfigurieren: Pro Workflow lässt sich konfigurieren:
- Quell-Board und Quell-Stapel - Quell-Board und Quell-Stapel
- Ziel-Stapel, in den die Karten verschoben werden - Ziel-Stapel, in den überfällige Karten verschoben werden
- **auslösendes Datum: Fälligkeitsdatum oder Startdatum der Karte** (Karten ohne das gewählte Datum werden nie verschoben; Regeln aus älteren Versionen laufen unverändert auf dem Fälligkeitsdatum)
- optionaler Filter auf zugewiesene Benutzer (Karte muss **mindestens einem** der gewählten Benutzer zugewiesen sein) - 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) - 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 - Checkbox: E-Mail-Benachrichtigung an den Workflow-Besitzer, sobald eine Karte verschoben wurde
+7 -13
View File
@@ -4,37 +4,31 @@
<id>workflow_deck_automation</id> <id>workflow_deck_automation</id>
<name lang="de">Deck Workflow-Automatisierung</name> <name lang="de">Deck Workflow-Automatisierung</name>
<name lang="en">Deck Workflow Automation</name> <name lang="en">Deck Workflow Automation</name>
<summary lang="de">Verschiebt Deck-Karten automatisch anhand konfigurierbarer Regeln</summary> <summary lang="de">Verschiebt überfällige Deck-Karten automatisch anhand konfigurierbarer Regeln</summary>
<summary lang="en">Automatically moves Deck cards based on configurable rules</summary> <summary lang="en">Automatically moves overdue Deck cards based on configurable rules</summary>
<description lang="de"><![CDATA[ <description lang="de"><![CDATA[
Diese App erlaubt es jedem Nutzer, in den persönlichen Einstellungen eigene Automatisierungs-Workflows für die Deck-App anzulegen: 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 * Quell-Board und Quell-Stapel wählen
* Ziel-Stapel wählen, in den die Karten verschoben werden * Ziel-Stapel wählen, in den überfällige Karten verschoben werden
* je Regel wählen, ob das Fälligkeits- oder das Startdatum der Karte ausgewertet wird
* optional nach zugewiesenen Benutzern filtern * optional nach zugewiesenen Benutzern filtern
* optional nach Labels/Tags filtern * optional nach Labels/Tags filtern
* optional eine E-Mail-Benachrichtigung an sich selbst aktivieren * optional eine E-Mail-Benachrichtigung an sich selbst aktivieren
Ein Hintergrundjob prüft regelmäßig alle aktiven Workflows und verschiebt Karten, deren gewähltes Datum in der Vergangenheit liegt, ausschließlich über die internen PHP-Klassen der Deck-App (keine HTTP/OCS-Aufrufe). 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).
Voraussetzung: Nextcloud 34 und die Deck-App ab Version 1.18.0 die einzige Deck-Reihe, die für Nextcloud 34 freigegeben ist.
]]></description> ]]></description>
<description lang="en"><![CDATA[ <description lang="en"><![CDATA[
This app lets every user configure their own Deck automation workflows from Personal settings: This app lets every user configure their own Deck automation workflows from Personal settings:
* pick a source board and source stack * pick a source board and source stack
* pick a destination stack the cards get moved to * pick a destination stack overdue cards get moved to
* pick, per rule, whether the card's due date or its start date is evaluated
* optionally filter by assigned users * optionally filter by assigned users
* optionally filter by labels/tags * optionally filter by labels/tags
* optionally enable an email notification to themselves * optionally enable an email notification to themselves
A background job periodically evaluates all enabled workflows and moves cards whose chosen date lies in the past, using Deck's own internal PHP classes only (no HTTP/OCS calls). 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).
Requires Nextcloud 34 and Deck 1.18.0 or newer - the only Deck release line supported on Nextcloud 34.
]]></description> ]]></description>
<version>34.1.0</version> <version>34.0.2</version>
<licence>agpl</licence> <licence>agpl</licence>
<author mail="patrick@niebel.ing">Patrick Niebeling</author> <author mail="patrick@niebel.ing">Patrick Niebeling</author>
<namespace>WorkflowDeckAutomation</namespace> <namespace>WorkflowDeckAutomation</namespace>
+5 -15
View File
@@ -56,15 +56,14 @@ class WorkflowController extends OCSController {
int $targetStackId, int $targetStackId,
array $filterUserIds = [], array $filterUserIds = [],
array $filterLabelIds = [], array $filterLabelIds = [],
string $dateField = Workflow::DATE_FIELD_DUE,
bool $notifyEmail = false, bool $notifyEmail = false,
bool $enabled = true, bool $enabled = true,
): DataResponse { ): DataResponse {
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $dateField); $this->validate($title, $boardId, $sourceStackId, $targetStackId);
$workflow = new Workflow(); $workflow = new Workflow();
$workflow->setUserId($this->currentUserId()); $workflow->setUserId($this->currentUserId());
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $dateField, $notifyEmail, $enabled); $this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $notifyEmail, $enabled);
$workflow = $this->workflowMapper->insert($workflow); $workflow = $this->workflowMapper->insert($workflow);
return new DataResponse($workflow->jsonSerialize(), Http::STATUS_CREATED); return new DataResponse($workflow->jsonSerialize(), Http::STATUS_CREATED);
@@ -83,11 +82,10 @@ class WorkflowController extends OCSController {
int $targetStackId, int $targetStackId,
array $filterUserIds = [], array $filterUserIds = [],
array $filterLabelIds = [], array $filterLabelIds = [],
string $dateField = Workflow::DATE_FIELD_DUE,
bool $notifyEmail = false, bool $notifyEmail = false,
bool $enabled = true, bool $enabled = true,
): DataResponse { ): DataResponse {
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $dateField); $this->validate($title, $boardId, $sourceStackId, $targetStackId);
try { try {
$workflow = $this->workflowMapper->findForUser($id, $this->currentUserId()); $workflow = $this->workflowMapper->findForUser($id, $this->currentUserId());
@@ -95,7 +93,7 @@ class WorkflowController extends OCSController {
throw new OCSNotFoundException('Workflow not found'); throw new OCSNotFoundException('Workflow not found');
} }
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $dateField, $notifyEmail, $enabled); $this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $notifyEmail, $enabled);
$workflow = $this->workflowMapper->update($workflow); $workflow = $this->workflowMapper->update($workflow);
return new DataResponse($workflow->jsonSerialize()); return new DataResponse($workflow->jsonSerialize());
@@ -149,19 +147,13 @@ class WorkflowController extends OCSController {
} }
} }
private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId, string $dateField): void { private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId): void {
if (trim($title) === '') { if (trim($title) === '') {
throw new OCSBadRequestException('Title must not be empty'); throw new OCSBadRequestException('Title must not be empty');
} }
if ($sourceStackId === $targetStackId) { if ($sourceStackId === $targetStackId) {
throw new OCSBadRequestException('Source and target stack must differ'); throw new OCSBadRequestException('Source and target stack must differ');
} }
// Rejected rather than silently normalised to 'due': a client sending
// something else is asking for a rule it would not get, and the runner
// falls back to the due date without a word.
if (!in_array($dateField, Workflow::DATE_FIELDS, true)) {
throw new OCSBadRequestException('Unknown date field: ' . $dateField);
}
// Reject references to boards or stacks that are gone (or were never // Reject references to boards or stacks that are gone (or were never
// the user's) instead of storing a workflow that can only fail later. // the user's) instead of storing a workflow that can only fail later.
@@ -198,7 +190,6 @@ class WorkflowController extends OCSController {
int $targetStackId, int $targetStackId,
array $filterUserIds, array $filterUserIds,
array $filterLabelIds, array $filterLabelIds,
string $dateField,
bool $notifyEmail, bool $notifyEmail,
bool $enabled, bool $enabled,
): void { ): void {
@@ -208,7 +199,6 @@ class WorkflowController extends OCSController {
$workflow->setTargetStackId($targetStackId); $workflow->setTargetStackId($targetStackId);
$workflow->setFilterUserIds($filterUserIds === [] ? null : json_encode(array_values($filterUserIds))); $workflow->setFilterUserIds($filterUserIds === [] ? null : json_encode(array_values($filterUserIds)));
$workflow->setFilterLabelIds($filterLabelIds === [] ? null : json_encode(array_values($filterLabelIds))); $workflow->setFilterLabelIds($filterLabelIds === [] ? null : json_encode(array_values($filterLabelIds)));
$workflow->setDateField($dateField);
$workflow->setNotifyEmail($notifyEmail); $workflow->setNotifyEmail($notifyEmail);
$workflow->setEnabled($enabled); $workflow->setEnabled($enabled);
} }
-30
View File
@@ -22,8 +22,6 @@ use OCP\DB\Types;
* @method void setFilterUserIds(?string $filterUserIds) * @method void setFilterUserIds(?string $filterUserIds)
* @method string|null getFilterLabelIds() * @method string|null getFilterLabelIds()
* @method void setFilterLabelIds(?string $filterLabelIds) * @method void setFilterLabelIds(?string $filterLabelIds)
* @method string|null getDateField()
* @method void setDateField(string $dateField)
* @method bool getNotifyEmail() * @method bool getNotifyEmail()
* @method void setNotifyEmail(bool $notifyEmail) * @method void setNotifyEmail(bool $notifyEmail)
* @method bool getEnabled() * @method bool getEnabled()
@@ -32,14 +30,6 @@ use OCP\DB\Types;
* @method void setLastRun(?\DateTime $lastRun) * @method void setLastRun(?\DateTime $lastRun)
*/ */
class Workflow extends Entity implements \JsonSerializable { class Workflow extends Entity implements \JsonSerializable {
/** Deck's `Card::$duedate` — the card is picked up once it is overdue. */
public const DATE_FIELD_DUE = 'due';
/** Deck's `Card::$startdate` — the card is picked up once its start date has been reached. */
public const DATE_FIELD_START = 'start';
public const DATE_FIELDS = [self::DATE_FIELD_DUE, self::DATE_FIELD_START];
protected $userId; protected $userId;
protected $title; protected $title;
protected $boardId; protected $boardId;
@@ -47,7 +37,6 @@ class Workflow extends Entity implements \JsonSerializable {
protected $targetStackId; protected $targetStackId;
protected $filterUserIds; protected $filterUserIds;
protected $filterLabelIds; protected $filterLabelIds;
protected $dateField;
protected $notifyEmail; protected $notifyEmail;
protected $enabled; protected $enabled;
protected $lastRun; protected $lastRun;
@@ -61,29 +50,11 @@ class Workflow extends Entity implements \JsonSerializable {
$this->addType('targetStackId', Types::INTEGER); $this->addType('targetStackId', Types::INTEGER);
$this->addType('filterUserIds', Types::STRING); $this->addType('filterUserIds', Types::STRING);
$this->addType('filterLabelIds', Types::STRING); $this->addType('filterLabelIds', Types::STRING);
$this->addType('dateField', Types::STRING);
$this->addType('notifyEmail', Types::BOOLEAN); $this->addType('notifyEmail', Types::BOOLEAN);
$this->addType('enabled', Types::BOOLEAN); $this->addType('enabled', Types::BOOLEAN);
$this->addType('lastRun', Types::DATETIME); $this->addType('lastRun', Types::DATETIME);
} }
/**
* The date field to evaluate, never anything else.
*
* Rows written before the column existed carry 'due' from the schema
* default, but a freshly constructed entity holds `null` and a hand-edited
* row could hold anything. Falling back to the due date keeps the old
* behaviour for every value that is not explicitly the start date — the
* safe direction, since 'due' is what every workflow meant before this
* choice existed.
*
* @return self::DATE_FIELD_*
*/
public function getDateFieldOrDue(): string {
$field = $this->getDateField();
return in_array($field, self::DATE_FIELDS, true) ? $field : self::DATE_FIELD_DUE;
}
/** /**
* @return string[] * @return string[]
*/ */
@@ -119,7 +90,6 @@ class Workflow extends Entity implements \JsonSerializable {
'targetStackId' => $this->getTargetStackId(), 'targetStackId' => $this->getTargetStackId(),
'filterUserIds' => $this->getFilterUserIdsArray(), 'filterUserIds' => $this->getFilterUserIdsArray(),
'filterLabelIds' => $this->getFilterLabelIdsArray(), 'filterLabelIds' => $this->getFilterLabelIdsArray(),
'dateField' => $this->getDateFieldOrDue(),
'notifyEmail' => (bool)$this->getNotifyEmail(), 'notifyEmail' => (bool)$this->getNotifyEmail(),
'enabled' => (bool)$this->getEnabled(), 'enabled' => (bool)$this->getEnabled(),
'lastRun' => $this->getLastRun()?->format(\DateTimeInterface::ATOM), 'lastRun' => $this->getLastRun()?->format(\DateTimeInterface::ATOM),
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Migration;
use Closure;
use OCA\WorkflowDeckAutomation\Db\Workflow;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Adds the per-workflow choice between Deck's due date and its start date.
*
* A separate migration rather than an edit to Version1000...: that one is
* wrapped in `if (!$schema->hasTable(...))`, so changing it would only ever
* reach fresh installs.
*
* The `default` is what migrates the existing rows — every workflow written
* before this release was evaluated against the due date, and the column
* default gives exactly that without a backfill step.
*/
class Version1001Date20260825120000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable('wfda_workflows')) {
return null;
}
$table = $schema->getTable('wfda_workflows');
if ($table->hasColumn('date_field')) {
return null;
}
$table->addColumn('date_field', Types::STRING, [
'notnull' => true,
'length' => 16,
'default' => Workflow::DATE_FIELD_DUE,
]);
return $schema;
}
}
+1 -90
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Service; namespace OCA\WorkflowDeckAutomation\Service;
use DateTimeInterface;
use OCA\Deck\Db\BoardMapper; use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Db\Card; use OCA\Deck\Db\Card;
use OCA\Deck\Db\CardMapper; use OCA\Deck\Db\CardMapper;
@@ -36,25 +35,6 @@ use Throwable;
class DeckIntegrationService { class DeckIntegrationService {
private const DECK_APP_ID = 'deck'; private const DECK_APP_ID = 'deck';
/**
* Oldest Deck this app will talk to.
*
* Deck 1.18.0 is the release that added the card start date
* (`Card::$startdate`, PR #7749), which the start-date workflows read.
* The floor cannot live in appinfo/info.xml: neither the server's
* app-info.xsd nor the app store's info.xsd has an app-to-app dependency
* element, only <nextcloud>.
*
* That pin does most of the work anyway — Deck 1.18.x is the only release
* line declaring `>=34.0.0,<35.0.0`, so on the Nextcloud 34 this app
* requires, every installable Deck already carries the field. What is left
* for this check is a Deck put in place by hand or from git.
*
* version_compare() sorts `1.18.0-beta.x` *below* `1.18.0`, and that is
* intended: the betas of that line may predate the field.
*/
private const MIN_DECK_VERSION = '1.18.0';
/** /**
* Deck's Acl::PERMISSION_TYPE_* values, inlined so this class keeps * Deck's Acl::PERMISSION_TYPE_* values, inlined so this class keeps
* working (degrading to "treat it as a user") if Deck ever moves or * working (degrading to "treat it as a user") if Deck ever moves or
@@ -88,31 +68,11 @@ class DeckIntegrationService {
'OCA\Deck\Activity\ActivityManager', 'OCA\Deck\Activity\ActivityManager',
]; ];
/**
* `Workflow::DATE_FIELD_*` mapped to the property on Deck's Card entity.
* Kept here rather than on the Workflow so this class stays the only one
* that has to know what Deck calls its columns.
*/
private const CARD_DATE_PROPERTIES = [
'due' => 'duedate',
'start' => 'startdate',
];
private const DEFAULT_CARD_DATE = 'due';
/** /**
* @var list<array{ReflectionProperty, object, ?string}> * @var list<array{ReflectionProperty, object, ?string}>
*/ */
private array $identityRestore = []; private array $identityRestore = [];
/**
* Card properties already reported as missing, so the warning in
* getCardDate() is one line per process instead of one per card.
*
* @var array<string, true>
*/
private array $reportedMissingCardProperties = [];
public function __construct( public function __construct(
private IAppManager $appManager, private IAppManager $appManager,
private IUserManager $userManager, private IUserManager $userManager,
@@ -206,16 +166,6 @@ class DeckIntegrationService {
if (!$this->appManager->isEnabledForUser(self::DECK_APP_ID, $user)) { if (!$this->appManager->isEnabledForUser(self::DECK_APP_ID, $user)) {
throw new DeckUnavailableException('Deck is not enabled for user ' . $user->getUID()); throw new DeckUnavailableException('Deck is not enabled for user ' . $user->getUID());
} }
$version = $this->appManager->getAppVersion(self::DECK_APP_ID);
if ($version === '' || version_compare($version, self::MIN_DECK_VERSION, '<')) {
throw new DeckUnavailableException(sprintf(
'Deck %s is installed, but %s or newer is required',
$version !== '' ? $version : '(unknown version)',
self::MIN_DECK_VERSION,
));
}
if (!class_exists(CardService::class) || !class_exists(BoardService::class) || !class_exists(StackService::class)) { if (!class_exists(CardService::class) || !class_exists(BoardService::class) || !class_exists(StackService::class)) {
throw new DeckUnavailableException('Deck internal classes are not available'); throw new DeckUnavailableException('Deck internal classes are not available');
} }
@@ -537,7 +487,7 @@ class DeckIntegrationService {
// *returns* CardDetails wrappers whose own fields (duedate, stackId, ...) were never // *returns* CardDetails wrappers whose own fields (duedate, stackId, ...) were never
// copied from the wrapped card - only its overridden jsonSerialize() reads through to // copied from the wrapped card - only its overridden jsonSerialize() reads through to
// them. Capturing that return value here made every card's getDuedate()/getDaysUntilDue() // them. Capturing that return value here made every card's getDuedate()/getDaysUntilDue()
// resolve to null, so hasPassed() was always false and no workflow ever matched a card. // resolve to null, so isOverdue() was always false and no workflow ever matched a card.
// Keep using the original, now-enriched $cards instead. // Keep using the original, now-enriched $cards instead.
$cardService->enrichCards($cards); $cardService->enrichCards($cards);
@@ -550,45 +500,6 @@ class DeckIntegrationService {
}, []); }, []);
} }
/**
* The card date a workflow is evaluated against: Deck's due date or its
* start date, per `Workflow::DATE_FIELD_*`. Anything unrecognised reads the
* due date, which is what every workflow meant before the choice existed.
*
* The guard is `property_exists()`, deliberately, and this is the one thing
* to get right here: **`method_exists()` cannot see Deck's getters.**
* `Card`, `Board`, `Label`, `Acl` and `Assignment` declare none of them as
* real code — `getStartdate()`, `getDuedate()`, even `getId()` all go
* through `OCP\AppFramework\Db\Entity::__call()`, and `method_exists()`
* ignores `__call()` by definition. A `method_exists($card, 'getStartdate')`
* probe would therefore be false on *every* Deck version and silently turn
* every start-date workflow into a no-op. `property_exists()` sees the
* protected `$startdate` declaration and is exactly what `Entity::getter()`
* checks internally before throwing.
*
* In practice the property is always there — `assertDeckAvailable()`
* enforces Deck >= MIN_DECK_VERSION, which is the release that added it —
* so this branch is a backstop for a hand-installed Deck, loud enough to be
* found in the log rather than silently matching nothing.
*/
public function getCardDate(Card $card, string $dateField): ?DateTimeInterface {
$property = self::CARD_DATE_PROPERTIES[$dateField] ?? self::CARD_DATE_PROPERTIES[self::DEFAULT_CARD_DATE];
if (!property_exists($card, $property)) {
if (!isset($this->reportedMissingCardProperties[$property])) {
$this->reportedMissingCardProperties[$property] = true;
$this->logger->warning(
'This Deck version has no Card::$' . $property . '; workflows using it cannot match any card',
['app' => 'workflow_deck_automation'],
);
}
return null;
}
$value = $property === 'startdate' ? $card->getStartdate() : $card->getDuedate();
return $value instanceof DateTimeInterface ? $value : null;
}
/** /**
* @return string[] * @return string[]
*/ */
+4 -13
View File
@@ -55,19 +55,10 @@ class NotificationMailer {
$template->setSubject($this->l10n->t('Deck card moved: %s', [$card->getTitle()])); $template->setSubject($this->l10n->t('Deck card moved: %s', [$card->getTitle()]));
$template->addHeader(); $template->addHeader();
$template->addHeading($this->l10n->t('A card was moved automatically'), false); $template->addHeading($this->l10n->t('A card was moved automatically'), false);
// Two whole strings rather than one sentence with an interpolated $template->addBodyText($this->l10n->t(
// reason: the trigger is the point of the mail, and a translator 'The card "%1$s" was moved because it is overdue (workflow "%2$s").',
// needs to see it in the sentence to get the grammar right. [$card->getTitle(), $workflow->getTitle()],
$reason = $workflow->getDateFieldOrDue() === Workflow::DATE_FIELD_START ));
? $this->l10n->t(
'The card "%1$s" was moved because its start date has been reached (workflow "%2$s").',
[$card->getTitle(), $workflow->getTitle()],
)
: $this->l10n->t(
'The card "%1$s" was moved because it is overdue (workflow "%2$s").',
[$card->getTitle(), $workflow->getTitle()],
);
$template->addBodyText($reason);
// Single-argument addBodyText() runs htmlspecialchars() itself, so // Single-argument addBodyText() runs htmlspecialchars() itself, so
// these board- and stack-titles (user input) are safe as-is — // these board- and stack-titles (user input) are safe as-is —
// unlike the description below, which passes both parts. // unlike the description below, which passes both parts.
+7 -15
View File
@@ -17,8 +17,7 @@ use Psr\Log\LoggerInterface;
use Throwable; use Throwable;
/** /**
* Evaluates all enabled workflows and moves the cards whose watched date - * Evaluates all enabled workflows and moves overdue cards.
* Deck's due date or its start date, per workflow - has passed.
* *
* This runner has to evaluate rules for many different users within a * This runner has to evaluate rules for many different users within a
* single background-job process, so workflows are grouped by owner and * single background-job process, so workflows are grouped by owner and
@@ -168,10 +167,9 @@ class WorkflowRunner {
$targets = null; $targets = null;
$now = $this->timeFactory->getDateTime(); $now = $this->timeFactory->getDateTime();
$dateField = $workflow->getDateFieldOrDue();
foreach ($cards as $card) { foreach ($cards as $card) {
if (!self::hasPassed($this->deckService->getCardDate($card, $dateField), $now)) { if (!self::isOverdue($card->getDuedate(), $now)) {
continue; continue;
} }
@@ -181,12 +179,11 @@ class WorkflowRunner {
continue; continue;
} }
$this->logger->info('Card {card} ("{title}") matches workflow {id} on its {dateField} date; moving to stack {target}.', [ $this->logger->info('Card {card} ("{title}") matches workflow {id}; moving to stack {target}.', [
'app' => 'workflow_deck_automation', 'app' => 'workflow_deck_automation',
'card' => $card->getId(), 'card' => $card->getId(),
'title' => $card->getTitle(), 'title' => $card->getTitle(),
'id' => $workflow->getId(), 'id' => $workflow->getId(),
'dateField' => $dateField,
'target' => $workflow->getTargetStackId(), 'target' => $workflow->getTargetStackId(),
]); ]);
@@ -326,21 +323,16 @@ class WorkflowRunner {
} }
/** /**
* Whether the card date a workflow watches - its due date or its start date, * Compares the card's actual due timestamp against $now instead of Deck's own
* see Workflow::DATE_FIELD_* - lies in the past. A card without that date set
* is never picked up.
*
* Compares the card's actual timestamp against $now instead of Deck's own
* Card::getDaysUntilDue(), which truncates both sides to midnight before diffing * Card::getDaysUntilDue(), which truncates both sides to midnight before diffing
* (DateInterval's %a is a whole-day count) and therefore reports 0 - not overdue - * (DateInterval's %a is a whole-day count) and therefore reports 0 - not overdue -
* for any card whose due time has already passed today. That silently held back * for any card whose due time has already passed today. That silently held back
* every card for up to 24h after it actually fell due; comparing the raw datetime * every card for up to 24h after it actually fell due; comparing the raw datetime
* instead matches what Deck's own UI shows (e.g. "vor 2 Stunden") and has no * instead matches what Deck's own UI shows (e.g. "vor 2 Stunden") and has no
* day-rounding step to get wrong. The same reasoning applies unchanged to the * day-rounding step to get wrong.
* start date, which is why this is one function and not two.
*/ */
public static function hasPassed(?DateTimeInterface $cardDate, DateTimeInterface $now): bool { public static function isOverdue(?DateTimeInterface $duedate, DateTimeInterface $now): bool {
return $cardDate !== null && $cardDate < $now; return $duedate !== null && $duedate < $now;
} }
/** /**
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "workflow_deck_automation", "name": "workflow_deck_automation",
"version": "34.1.0", "version": "34.0.2",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Automates moving overdue Deck cards between stacks, configurable per user.", "description": "Automates moving overdue Deck cards between stacks, configurable per user.",
+1 -72
View File
@@ -1,7 +1,7 @@
<template> <template>
<NcSettingsSection <NcSettingsSection
name="Deck Workflow-Automatisierung" name="Deck Workflow-Automatisierung"
description="Verschiebt Karten automatisch aus einem Deck-Stapel in einen anderen, sobald ihr Fälligkeits- oder Startdatum erreicht ist. Ein Hintergrundjob prüft die Regeln alle paar Minuten."> 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"> <NcNoteCard v-if="loadError" type="error">
{{ loadError }} {{ loadError }}
</NcNoteCard> </NcNoteCard>
@@ -17,7 +17,6 @@
<th>Titel</th> <th>Titel</th>
<th>Von Stapel</th> <th>Von Stapel</th>
<th>Nach Stapel</th> <th>Nach Stapel</th>
<th>Auslöser</th>
<th>Filter</th> <th>Filter</th>
<th>E-Mail</th> <th>E-Mail</th>
<th>Aktiv</th> <th>Aktiv</th>
@@ -34,7 +33,6 @@
</td> </td>
<td>{{ stackLabel(workflow.boardId, workflow.sourceStackId) }}</td> <td>{{ stackLabel(workflow.boardId, workflow.sourceStackId) }}</td>
<td>{{ stackLabel(workflow.boardId, workflow.targetStackId) }}</td> <td>{{ stackLabel(workflow.boardId, workflow.targetStackId) }}</td>
<td>{{ dateFieldLabel(workflow.dateField) }}</td>
<td> <td>
<span v-if="!workflow.filterUserIds.length && !workflow.filterLabelIds.length"></span> <span v-if="!workflow.filterUserIds.length && !workflow.filterLabelIds.length"></span>
<span v-else> <span v-else>
@@ -94,34 +92,6 @@
:disabled="!form.boardId" :disabled="!form.boardId"
placeholder="Ziel-Stapel wählen" /> placeholder="Ziel-Stapel wählen" />
<fieldset class="wfda-fieldset">
<legend>Auslösendes Datum</legend>
<!--
v-model + value + type="radio" + a shared name is the
@nextcloud/vue 9 radio API: v-model carries the group's
current value, value the one this button stands for. Do
not reach for :checked.sync here - Vue 3 dropped .sync,
and it would silently never update.
-->
<NcCheckboxRadioSwitch
v-model="form.dateField"
value="due"
name="wfda-date-field"
type="radio">
Fälligkeitsdatum überschritten
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
v-model="form.dateField"
value="start"
name="wfda-date-field"
type="radio">
Startdatum erreicht
</NcCheckboxRadioSwitch>
<p class="wfda-hint">
Karten ohne das gewählte Datum werden nie verschoben.
</p>
</fieldset>
<NcSelect <NcSelect
v-model="form.filterUserIds" v-model="form.filterUserIds"
input-label="Nur für zugewiesene Benutzer (optional)" input-label="Nur für zugewiesene Benutzer (optional)"
@@ -287,16 +257,11 @@ function emptyForm() {
targetStackId: null, targetStackId: null,
filterUserIds: [], filterUserIds: [],
filterLabelIds: [], filterLabelIds: [],
dateField: 'due',
notifyEmail: false, notifyEmail: false,
enabled: true, enabled: true,
} }
} }
function dateFieldLabel(dateField) {
return dateField === 'start' ? 'Startdatum' : 'Fälligkeit'
}
function stackLabel(boardId, stackId) { function stackLabel(boardId, stackId) {
const stacks = stacksByBoard[boardId] const stacks = stacksByBoard[boardId]
if (!stacks) { if (!stacks) {
@@ -421,8 +386,6 @@ async function editWorkflow(workflow) {
targetStackId: workflow.targetStackId, targetStackId: workflow.targetStackId,
filterUserIds: [...workflow.filterUserIds], filterUserIds: [...workflow.filterUserIds],
filterLabelIds: [...workflow.filterLabelIds], filterLabelIds: [...workflow.filterLabelIds],
// Workflows stored before this field existed come back without it.
dateField: workflow.dateField || 'due',
notifyEmail: workflow.notifyEmail, notifyEmail: workflow.notifyEmail,
enabled: workflow.enabled, enabled: workflow.enabled,
}) })
@@ -535,38 +498,4 @@ onMounted(loadAll)
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.wfda-fieldset {
display: flex;
flex-direction: column;
gap: 4px;
/* The form already draws its own frame; the browser default would put a
second box around just this group. */
border: none;
padding: 0;
margin: 0;
}
.wfda-fieldset legend {
font-weight: bold;
margin-bottom: 4px;
}
.wfda-hint {
color: var(--color-text-maxcontrast);
font-size: 0.9em;
}
/*
* NcSelect positions the clear/open icons (.vs__actions) absolutely relative
* to the outer .v-select wrapper, while the dropdown menu itself opens
* relative to .vs__dropdown-toggle (its $refs.toggle) — normally the same
* box, but any width mismatch between the two (e.g. from global Nextcloud
* input styles bleeding into the scoped component) shifts the icons off the
* visible border of single-value selects like Board/Quell-/Ziel-Stapel.
* Anchoring both to .vs__dropdown-toggle keeps them in sync.
*/
:deep(.v-select.select:not(.vs--multiple) .vs__dropdown-toggle) {
position: relative;
}
</style> </style>
-43
View File
@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Tests\Unit\Db;
use OCA\WorkflowDeckAutomation\Db\Workflow;
use PHPUnit\Framework\TestCase;
class WorkflowTest extends TestCase {
public function testDateFieldDefaultsToDueWhenUnset(): void {
// The column default covers rows written before date_field existed,
// but an entity built in PHP has never been near the database.
$this->assertSame(Workflow::DATE_FIELD_DUE, (new Workflow())->getDateFieldOrDue());
}
public function testDateFieldIsKeptWhenKnown(): void {
$workflow = new Workflow();
$workflow->setDateField(Workflow::DATE_FIELD_START);
$this->assertSame(Workflow::DATE_FIELD_START, $workflow->getDateFieldOrDue());
$workflow->setDateField(Workflow::DATE_FIELD_DUE);
$this->assertSame(Workflow::DATE_FIELD_DUE, $workflow->getDateFieldOrDue());
}
public function testUnknownDateFieldFallsBackToDue(): void {
// A hand-edited row must not silently switch a workflow to a trigger
// nobody configured; the due date is what every workflow meant before
// this choice existed.
$workflow = new Workflow();
$workflow->setDateField('whenever');
$this->assertSame(Workflow::DATE_FIELD_DUE, $workflow->getDateFieldOrDue());
}
public function testDateFieldIsSerialisedForTheSettingsUi(): void {
$workflow = new Workflow();
$workflow->setDateField(Workflow::DATE_FIELD_START);
$this->assertSame(Workflow::DATE_FIELD_START, $workflow->jsonSerialize()['dateField']);
}
}
+11 -11
View File
@@ -9,30 +9,30 @@ use OCA\WorkflowDeckAutomation\Service\WorkflowRunner;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class WorkflowRunnerFilterTest extends TestCase { class WorkflowRunnerFilterTest extends TestCase {
public function testHasPassedReturnsFalseWithoutADate(): void { public function testIsOverdueReturnsFalseWithoutDueDate(): void {
$this->assertFalse(WorkflowRunner::hasPassed(null, new DateTimeImmutable('2026-01-15 12:00:00'))); $this->assertFalse(WorkflowRunner::isOverdue(null, new DateTimeImmutable('2026-01-15 12:00:00')));
} }
public function testHasPassedReturnsFalseForAFutureDate(): void { public function testIsOverdueReturnsFalseForFutureDueDate(): void {
$now = new DateTimeImmutable('2026-01-15 12:00:00'); $now = new DateTimeImmutable('2026-01-15 12:00:00');
$this->assertFalse(WorkflowRunner::hasPassed($now->modify('+1 hour'), $now)); $this->assertFalse(WorkflowRunner::isOverdue($now->modify('+1 hour'), $now));
$this->assertFalse(WorkflowRunner::hasPassed($now->modify('+3 days'), $now)); $this->assertFalse(WorkflowRunner::isOverdue($now->modify('+3 days'), $now));
} }
public function testHasPassedReturnsTrueForAPastDate(): void { public function testIsOverdueReturnsTrueForPastDueDate(): void {
$now = new DateTimeImmutable('2026-01-15 12:00:00'); $now = new DateTimeImmutable('2026-01-15 12:00:00');
$this->assertTrue(WorkflowRunner::hasPassed($now->modify('-1 hour'), $now)); $this->assertTrue(WorkflowRunner::isOverdue($now->modify('-1 hour'), $now));
$this->assertTrue(WorkflowRunner::hasPassed($now->modify('-30 days'), $now)); $this->assertTrue(WorkflowRunner::isOverdue($now->modify('-30 days'), $now));
} }
public function testHasPassedReturnsTrueForATimeEarlierTheSameDay(): void { public function testIsOverdueReturnsTrueForADueTimeEarlierTheSameDay(): void {
// Regression: comparing whole calendar days (as Deck's own // Regression: comparing whole calendar days (as Deck's own
// Card::getDaysUntilDue() does) reports a card due earlier today as // Card::getDaysUntilDue() does) reports a card due earlier today as
// "0 days until due", not overdue, until the calendar date rolls // "0 days until due", not overdue, until the calendar date rolls
// over - holding every card back for up to 24h after it actually // over - holding every card back for up to 24h after it actually
// fell due. hasPassed() must compare the real timestamps instead. // fell due. isOverdue() must compare the real timestamps instead.
$now = new DateTimeImmutable('2026-01-15 12:00:00'); $now = new DateTimeImmutable('2026-01-15 12:00:00');
$this->assertTrue(WorkflowRunner::hasPassed(new DateTimeImmutable('2026-01-15 06:00:00'), $now)); $this->assertTrue(WorkflowRunner::isOverdue(new DateTimeImmutable('2026-01-15 06:00:00'), $now));
} }
public function testNoFiltersMatchesAnyCard(): void { public function testNoFiltersMatchesAnyCard(): void {