Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e66319a06 | ||
|
|
43648f3728 | ||
|
|
ce749e63a4 | ||
|
|
b203ba35f8 | ||
|
|
ef973af4b5 | ||
|
|
a95d5e2d8a | ||
|
|
2d2f3ca19c |
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: gitea-runner-server03
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3', '8.4', '8.5']
|
||||
php-version: ['8.4', '8.5']
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
php-version: '8.4'
|
||||
extensions: sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 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 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.
|
||||
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.
|
||||
|
||||
## Non-negotiable architectural constraint
|
||||
|
||||
@@ -12,6 +12,27 @@ 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.
|
||||
|
||||
## `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
|
||||
|
||||
`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:**
|
||||
@@ -27,7 +48,19 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -109,6 +142,8 @@ There is no meaningful way to test the Deck integration itself outside a real Ne
|
||||
|
||||
### Release process
|
||||
|
||||
**Versioning scheme (from 34.0.0 on): `<nextcloud-major>.<minor>.<patch>`.** The leading number tracks the minimum/maximum Nextcloud major version declared in `appinfo/info.xml`'s `<dependencies><nextcloud .../>` (currently `min-version="34" max-version="34"`), not this app's own feature history - so it only changes when the supported Nextcloud major changes (e.g. dropping/adding NC 34 support bumps to `35.0.0`), and resets `minor`/`patch` to `0.0` at that point. Ordinary fixes/features within the same supported NC major bump `minor`/`patch` as usual (semver-ish, without a leading `0.`). Versions before `34.0.0` (`0.1.x`/`0.2.x`) predate this scheme.
|
||||
|
||||
1. Bump `<version>` in `appinfo/info.xml` and `version` in `package.json` (keep them in sync).
|
||||
2. Commit, push to `main`, confirm the run of `.gitea/workflows/build-main.yml` is green.
|
||||
3. `git tag vX.Y.Z && git push origin vX.Y.Z` — this triggers the tag branch of the same workflow, which re-runs the checks, builds the frontend, runs `make appstore`, and attaches `workflow_deck_automation.tar.gz` as the asset on a normal (non-pre-) release for that tag.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -9,7 +11,8 @@ Ersetzt das früher genutzte eigenständige PHP-Cron-Skript durch eine vollwerti
|
||||
Pro Workflow lässt sich konfigurieren:
|
||||
|
||||
- Quell-Board und Quell-Stapel
|
||||
- Ziel-Stapel, in den überfällige Karten verschoben werden
|
||||
- Ziel-Stapel, in den die 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 Labels/Tags (Karte muss **mindestens eines** der gewählten Labels haben)
|
||||
- Checkbox: E-Mail-Benachrichtigung an den Workflow-Besitzer, sobald eine Karte verschoben wurde
|
||||
|
||||
+14
-8
@@ -4,31 +4,37 @@
|
||||
<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>
|
||||
<summary lang="de">Verschiebt Deck-Karten automatisch anhand konfigurierbarer Regeln</summary>
|
||||
<summary lang="en">Automatically moves 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
|
||||
* Ziel-Stapel wählen, in den die 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 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).
|
||||
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).
|
||||
|
||||
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 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
|
||||
* pick a destination stack the 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 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).
|
||||
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).
|
||||
|
||||
Requires Nextcloud 34 and Deck 1.18.0 or newer - the only Deck release line supported on Nextcloud 34.
|
||||
]]></description>
|
||||
<version>0.2.2</version>
|
||||
<version>34.1.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="patrick@niebel.ing">Patrick Niebeling</author>
|
||||
<namespace>WorkflowDeckAutomation</namespace>
|
||||
@@ -36,7 +42,7 @@ A background job periodically evaluates all enabled workflows and moves cards wh
|
||||
<category>tools</category>
|
||||
<bugs>https://github.com/pniebeling/workflow_deck_automation/issues</bugs>
|
||||
<dependencies>
|
||||
<php min-version="8.2" max-version="8.5"/>
|
||||
<php min-version="8.4" max-version="8.5"/>
|
||||
<database min-version="9.4">pgsql</database>
|
||||
<database>sqlite</database>
|
||||
<database min-version="8.0">mysql</database>
|
||||
|
||||
@@ -56,14 +56,15 @@ class WorkflowController extends OCSController {
|
||||
int $targetStackId,
|
||||
array $filterUserIds = [],
|
||||
array $filterLabelIds = [],
|
||||
string $dateField = Workflow::DATE_FIELD_DUE,
|
||||
bool $notifyEmail = false,
|
||||
bool $enabled = true,
|
||||
): DataResponse {
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId);
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $dateField);
|
||||
|
||||
$workflow = new Workflow();
|
||||
$workflow->setUserId($this->currentUserId());
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $notifyEmail, $enabled);
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $dateField, $notifyEmail, $enabled);
|
||||
|
||||
$workflow = $this->workflowMapper->insert($workflow);
|
||||
return new DataResponse($workflow->jsonSerialize(), Http::STATUS_CREATED);
|
||||
@@ -82,10 +83,11 @@ class WorkflowController extends OCSController {
|
||||
int $targetStackId,
|
||||
array $filterUserIds = [],
|
||||
array $filterLabelIds = [],
|
||||
string $dateField = Workflow::DATE_FIELD_DUE,
|
||||
bool $notifyEmail = false,
|
||||
bool $enabled = true,
|
||||
): DataResponse {
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId);
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $dateField);
|
||||
|
||||
try {
|
||||
$workflow = $this->workflowMapper->findForUser($id, $this->currentUserId());
|
||||
@@ -93,7 +95,7 @@ class WorkflowController extends OCSController {
|
||||
throw new OCSNotFoundException('Workflow not found');
|
||||
}
|
||||
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $notifyEmail, $enabled);
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $dateField, $notifyEmail, $enabled);
|
||||
$workflow = $this->workflowMapper->update($workflow);
|
||||
|
||||
return new DataResponse($workflow->jsonSerialize());
|
||||
@@ -147,13 +149,19 @@ class WorkflowController extends OCSController {
|
||||
}
|
||||
}
|
||||
|
||||
private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId): void {
|
||||
private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId, string $dateField): void {
|
||||
if (trim($title) === '') {
|
||||
throw new OCSBadRequestException('Title must not be empty');
|
||||
}
|
||||
if ($sourceStackId === $targetStackId) {
|
||||
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
|
||||
// the user's) instead of storing a workflow that can only fail later.
|
||||
@@ -190,6 +198,7 @@ class WorkflowController extends OCSController {
|
||||
int $targetStackId,
|
||||
array $filterUserIds,
|
||||
array $filterLabelIds,
|
||||
string $dateField,
|
||||
bool $notifyEmail,
|
||||
bool $enabled,
|
||||
): void {
|
||||
@@ -199,6 +208,7 @@ class WorkflowController extends OCSController {
|
||||
$workflow->setTargetStackId($targetStackId);
|
||||
$workflow->setFilterUserIds($filterUserIds === [] ? null : json_encode(array_values($filterUserIds)));
|
||||
$workflow->setFilterLabelIds($filterLabelIds === [] ? null : json_encode(array_values($filterLabelIds)));
|
||||
$workflow->setDateField($dateField);
|
||||
$workflow->setNotifyEmail($notifyEmail);
|
||||
$workflow->setEnabled($enabled);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ use OCP\DB\Types;
|
||||
* @method void setFilterUserIds(?string $filterUserIds)
|
||||
* @method string|null getFilterLabelIds()
|
||||
* @method void setFilterLabelIds(?string $filterLabelIds)
|
||||
* @method string|null getDateField()
|
||||
* @method void setDateField(string $dateField)
|
||||
* @method bool getNotifyEmail()
|
||||
* @method void setNotifyEmail(bool $notifyEmail)
|
||||
* @method bool getEnabled()
|
||||
@@ -30,6 +32,14 @@ use OCP\DB\Types;
|
||||
* @method void setLastRun(?\DateTime $lastRun)
|
||||
*/
|
||||
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 $title;
|
||||
protected $boardId;
|
||||
@@ -37,6 +47,7 @@ class Workflow extends Entity implements \JsonSerializable {
|
||||
protected $targetStackId;
|
||||
protected $filterUserIds;
|
||||
protected $filterLabelIds;
|
||||
protected $dateField;
|
||||
protected $notifyEmail;
|
||||
protected $enabled;
|
||||
protected $lastRun;
|
||||
@@ -50,11 +61,29 @@ class Workflow extends Entity implements \JsonSerializable {
|
||||
$this->addType('targetStackId', Types::INTEGER);
|
||||
$this->addType('filterUserIds', Types::STRING);
|
||||
$this->addType('filterLabelIds', Types::STRING);
|
||||
$this->addType('dateField', Types::STRING);
|
||||
$this->addType('notifyEmail', Types::BOOLEAN);
|
||||
$this->addType('enabled', Types::BOOLEAN);
|
||||
$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[]
|
||||
*/
|
||||
@@ -90,6 +119,7 @@ class Workflow extends Entity implements \JsonSerializable {
|
||||
'targetStackId' => $this->getTargetStackId(),
|
||||
'filterUserIds' => $this->getFilterUserIdsArray(),
|
||||
'filterLabelIds' => $this->getFilterLabelIdsArray(),
|
||||
'dateField' => $this->getDateFieldOrDue(),
|
||||
'notifyEmail' => (bool)$this->getNotifyEmail(),
|
||||
'enabled' => (bool)$this->getEnabled(),
|
||||
'lastRun' => $this->getLastRun()?->format(\DateTimeInterface::ATOM),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Service;
|
||||
|
||||
use DateTimeInterface;
|
||||
use OCA\Deck\Db\BoardMapper;
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\Deck\Db\CardMapper;
|
||||
@@ -35,6 +36,25 @@ use Throwable;
|
||||
class DeckIntegrationService {
|
||||
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
|
||||
* working (degrading to "treat it as a user") if Deck ever moves or
|
||||
@@ -68,11 +88,31 @@ class DeckIntegrationService {
|
||||
'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}>
|
||||
*/
|
||||
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(
|
||||
private IAppManager $appManager,
|
||||
private IUserManager $userManager,
|
||||
@@ -166,6 +206,16 @@ class DeckIntegrationService {
|
||||
if (!$this->appManager->isEnabledForUser(self::DECK_APP_ID, $user)) {
|
||||
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)) {
|
||||
throw new DeckUnavailableException('Deck internal classes are not available');
|
||||
}
|
||||
@@ -483,7 +533,13 @@ class DeckIntegrationService {
|
||||
$cardService = $this->resolve(CardService::class);
|
||||
|
||||
$cards = $cardMapper->findAllByStack($stackId);
|
||||
$cards = $cardService->enrichCards($cards);
|
||||
// enrichCards() mutates each $cards entry in place (labels, assigned users, ...) but
|
||||
// *returns* CardDetails wrappers whose own fields (duedate, stackId, ...) were never
|
||||
// copied from the wrapped card - only its overridden jsonSerialize() reads through to
|
||||
// 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.
|
||||
// Keep using the original, now-enriched $cards instead.
|
||||
$cardService->enrichCards($cards);
|
||||
|
||||
return array_values(array_filter($cards, static function (Card $card) {
|
||||
if (method_exists($card, 'getDeletedAt') && (int)$card->getDeletedAt() > 0) {
|
||||
@@ -494,6 +550,45 @@ 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[]
|
||||
*/
|
||||
|
||||
@@ -55,10 +55,19 @@ class NotificationMailer {
|
||||
$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()],
|
||||
));
|
||||
// Two whole strings rather than one sentence with an interpolated
|
||||
// reason: the trigger is the point of the mail, and a translator
|
||||
// needs to see it in the sentence to get the grammar right.
|
||||
$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
|
||||
// these board- and stack-titles (user input) are safe as-is —
|
||||
// unlike the description below, which passes both parts.
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Service;
|
||||
|
||||
use DateTimeInterface;
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\WorkflowDeckAutomation\Db\Workflow;
|
||||
use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
|
||||
@@ -16,7 +17,8 @@ use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Evaluates all enabled workflows and moves overdue cards.
|
||||
* Evaluates all enabled workflows and moves the cards whose watched date -
|
||||
* Deck's due date or its start date, per workflow - has passed.
|
||||
*
|
||||
* This runner has to evaluate rules for many different users within a
|
||||
* single background-job process, so workflows are grouped by owner and
|
||||
@@ -165,8 +167,11 @@ class WorkflowRunner {
|
||||
// and the workflow wants a mail — this is decoration, not a check.
|
||||
$targets = null;
|
||||
|
||||
$now = $this->timeFactory->getDateTime();
|
||||
$dateField = $workflow->getDateFieldOrDue();
|
||||
|
||||
foreach ($cards as $card) {
|
||||
if (!self::isOverdue($card->getDaysUntilDue())) {
|
||||
if (!self::hasPassed($this->deckService->getCardDate($card, $dateField), $now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -176,11 +181,12 @@ class WorkflowRunner {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->logger->info('Card {card} ("{title}") matches workflow {id}; moving to stack {target}.', [
|
||||
$this->logger->info('Card {card} ("{title}") matches workflow {id} on its {dateField} date; moving to stack {target}.', [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'card' => $card->getId(),
|
||||
'title' => $card->getTitle(),
|
||||
'id' => $workflow->getId(),
|
||||
'dateField' => $dateField,
|
||||
'target' => $workflow->getTargetStackId(),
|
||||
]);
|
||||
|
||||
@@ -319,8 +325,22 @@ class WorkflowRunner {
|
||||
}
|
||||
}
|
||||
|
||||
public static function isOverdue(?int $daysUntilDue): bool {
|
||||
return $daysUntilDue !== null && $daysUntilDue < 0;
|
||||
/**
|
||||
* Whether the card date a workflow watches - its due date or its start date,
|
||||
* 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
|
||||
* (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
|
||||
* 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
|
||||
* day-rounding step to get wrong. The same reasoning applies unchanged to the
|
||||
* start date, which is why this is one function and not two.
|
||||
*/
|
||||
public static function hasPassed(?DateTimeInterface $cardDate, DateTimeInterface $now): bool {
|
||||
return $cardDate !== null && $cardDate < $now;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,6 +29,6 @@ class PersonalSection implements IIconSection {
|
||||
}
|
||||
|
||||
public function getIcon(): string {
|
||||
return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
|
||||
return $this->urlGenerator->imagePath(Application::APP_ID, 'app.svg');
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "workflow_deck_automation",
|
||||
"version": "0.2.2",
|
||||
"version": "34.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Automates moving overdue Deck cards between stacks, configurable per user.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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.">
|
||||
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.">
|
||||
<NcNoteCard v-if="loadError" type="error">
|
||||
{{ loadError }}
|
||||
</NcNoteCard>
|
||||
@@ -17,6 +17,7 @@
|
||||
<th>Titel</th>
|
||||
<th>Von Stapel</th>
|
||||
<th>Nach Stapel</th>
|
||||
<th>Auslöser</th>
|
||||
<th>Filter</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Aktiv</th>
|
||||
@@ -33,6 +34,7 @@
|
||||
</td>
|
||||
<td>{{ stackLabel(workflow.boardId, workflow.sourceStackId) }}</td>
|
||||
<td>{{ stackLabel(workflow.boardId, workflow.targetStackId) }}</td>
|
||||
<td>{{ dateFieldLabel(workflow.dateField) }}</td>
|
||||
<td>
|
||||
<span v-if="!workflow.filterUserIds.length && !workflow.filterLabelIds.length">–</span>
|
||||
<span v-else>
|
||||
@@ -92,6 +94,34 @@
|
||||
:disabled="!form.boardId"
|
||||
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
|
||||
v-model="form.filterUserIds"
|
||||
input-label="Nur für zugewiesene Benutzer (optional)"
|
||||
@@ -257,11 +287,16 @@ function emptyForm() {
|
||||
targetStackId: null,
|
||||
filterUserIds: [],
|
||||
filterLabelIds: [],
|
||||
dateField: 'due',
|
||||
notifyEmail: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
function dateFieldLabel(dateField) {
|
||||
return dateField === 'start' ? 'Startdatum' : 'Fälligkeit'
|
||||
}
|
||||
|
||||
function stackLabel(boardId, stackId) {
|
||||
const stacks = stacksByBoard[boardId]
|
||||
if (!stacks) {
|
||||
@@ -386,6 +421,8 @@ async function editWorkflow(workflow) {
|
||||
targetStackId: workflow.targetStackId,
|
||||
filterUserIds: [...workflow.filterUserIds],
|
||||
filterLabelIds: [...workflow.filterLabelIds],
|
||||
// Workflows stored before this field existed come back without it.
|
||||
dateField: workflow.dateField || 'due',
|
||||
notifyEmail: workflow.notifyEmail,
|
||||
enabled: workflow.enabled,
|
||||
})
|
||||
@@ -498,4 +535,38 @@ onMounted(loadAll)
|
||||
display: flex;
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?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']);
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,35 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Tests\Unit\Service;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use OCA\WorkflowDeckAutomation\Service\WorkflowRunner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class WorkflowRunnerFilterTest extends TestCase {
|
||||
public function testIsOverdueReturnsFalseWithoutDueDate(): void {
|
||||
$this->assertFalse(WorkflowRunner::isOverdue(null));
|
||||
public function testHasPassedReturnsFalseWithoutADate(): void {
|
||||
$this->assertFalse(WorkflowRunner::hasPassed(null, new DateTimeImmutable('2026-01-15 12:00:00')));
|
||||
}
|
||||
|
||||
public function testIsOverdueReturnsFalseForFutureDueDate(): void {
|
||||
$this->assertFalse(WorkflowRunner::isOverdue(0));
|
||||
$this->assertFalse(WorkflowRunner::isOverdue(3));
|
||||
public function testHasPassedReturnsFalseForAFutureDate(): void {
|
||||
$now = new DateTimeImmutable('2026-01-15 12:00:00');
|
||||
$this->assertFalse(WorkflowRunner::hasPassed($now->modify('+1 hour'), $now));
|
||||
$this->assertFalse(WorkflowRunner::hasPassed($now->modify('+3 days'), $now));
|
||||
}
|
||||
|
||||
public function testIsOverdueReturnsTrueForPastDueDate(): void {
|
||||
$this->assertTrue(WorkflowRunner::isOverdue(-1));
|
||||
$this->assertTrue(WorkflowRunner::isOverdue(-30));
|
||||
public function testHasPassedReturnsTrueForAPastDate(): void {
|
||||
$now = new DateTimeImmutable('2026-01-15 12:00:00');
|
||||
$this->assertTrue(WorkflowRunner::hasPassed($now->modify('-1 hour'), $now));
|
||||
$this->assertTrue(WorkflowRunner::hasPassed($now->modify('-30 days'), $now));
|
||||
}
|
||||
|
||||
public function testHasPassedReturnsTrueForATimeEarlierTheSameDay(): void {
|
||||
// Regression: comparing whole calendar days (as Deck's own
|
||||
// Card::getDaysUntilDue() does) reports a card due earlier today as
|
||||
// "0 days until due", not overdue, until the calendar date rolls
|
||||
// over - holding every card back for up to 24h after it actually
|
||||
// fell due. hasPassed() must compare the real timestamps instead.
|
||||
$now = new DateTimeImmutable('2026-01-15 12:00:00');
|
||||
$this->assertTrue(WorkflowRunner::hasPassed(new DateTimeImmutable('2026-01-15 06:00:00'), $now));
|
||||
}
|
||||
|
||||
public function testNoFiltersMatchesAnyCard(): void {
|
||||
|
||||
Reference in New Issue
Block a user