Let workflows move cards by start date instead of due date
Build package / xml-lint (push) Successful in 3m32s
Build package / unit-tests (push) Successful in 4m49s
Build package / php-lint (8.5) (push) Successful in 4m52s
Build package / php-lint (8.4) (push) Successful in 4m56s
Build package / package (push) Successful in 2m28s

Each workflow now has a trigger date (due or start): the runner compares
either the card's due date or its start date against now, read straight
from Deck's Card entity. Mails and Nextcloud notifications say whether
the card was overdue or reached its start date. Cards without the chosen
date stay put.

Deck only has a start date since 1.18 and Entity getters are magic, so
DeckIntegrationService::supportsStartdate() checks the property; without
it the form rejects start-date workflows and the runner skips them
without disabling them.

WorkflowRunner::isOverdue() becomes hasPassed(), as it now covers both
dates. Existing workflows keep the due date (migration Version1005).
Bumps to 35.4.0 so the migration runs.
This commit is contained in:
Patrick Niebeling committed 2026-10-01 13:19:32 +02:00
1 parent 5bd3181f10
commit 8268f1068d
18 files changed
+286 -71

No files matched your search

+9
View File
@@ -163,6 +163,15 @@ class DeckIntegrationService {
}
}
/**
* Whether Deck's cards carry a start date (`deck_cards.startdate`, added
* in Deck 1.18). Checked on the property, not with method_exists():
* Entity getters are magic, so getStartdate() would only fail when called.
*/
public function supportsStartdate(): bool {
return class_exists(Card::class) && property_exists(Card::class, 'startdate');
}
/**
* The boards `$userId` owns — the only boards workflows may live on, and
* the boards whose workflows that user sees and manages.
+6 -3
View File
@@ -99,9 +99,10 @@ class NotificationMailer {
*/
private function addCard(IEMailTemplate $template, Workflow $workflow, Card $card, array $targets): void {
// The card description and the workflow's name are deliberately left
// out — the mail only says what moved where. The card title itself is
// the link to the card.
$sentence = '„%1$s“ (Board „%2$s“) ist überfällig und wurde von „%3$s“ nach „%4$s“ verschoben.';
// out — the mail only says what moved where, and why. The card title
// itself is the link to the card.
$sentence = '„%1$s“ (Board „%2$s“) %5$s und wurde von „%3$s“ nach „%4$s“ verschoben.';
$reason = TriggerDate::describeReason((string)$workflow->getTriggerDate());
$url = $this->urlGenerator->getAbsoluteURL('/apps/deck/board/' . $workflow->getBoardId() . '/card/' . $card->getId());
// With both parts passed, addBodyText() escapes nothing: every title
@@ -112,6 +113,7 @@ class NotificationMailer {
self::escape($targets['board']),
self::escape($targets['sourceStack']),
self::escape($targets['targetStack']),
self::escape($reason),
);
$plain = sprintf(
$sentence,
@@ -119,6 +121,7 @@ class NotificationMailer {
$targets['board'],
$targets['sourceStack'],
$targets['targetStack'],
$reason,
) . PHP_EOL . $url;
$template->addBodyText($html, $plain);
+1 -1
View File
@@ -6,7 +6,7 @@ namespace OCA\WorkflowDeckAutomation\Service;
/**
* Who hears about a moved card, and how — pure logic, no Nextcloud or Deck
* dependencies, so it is unit-testable like WorkflowRunner::isOverdue().
* dependencies, so it is unit-testable like WorkflowRunner::hasPassed().
*/
class NotificationPolicy {
/** No mail at all. */
+1
View File
@@ -82,6 +82,7 @@ class NotificationPusher {
'sourceStackTitle' => $targets['sourceStack'],
'targetStackId' => $workflow->getTargetStackId(),
'targetStackTitle' => $targets['targetStack'],
'triggerDate' => (string)$workflow->getTriggerDate(),
]);
$this->notificationManager->notify($notification);
} catch (Throwable $e) {
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Service;
/**
* Which of a card's dates moves it — pure logic, no Nextcloud or Deck
* dependencies, so it is unit-testable like NotificationPolicy.
*/
class TriggerDate {
/** Deck's `duedate`: the card is moved once it is overdue. */
public const DUE = 'due';
/** Deck's `startdate`: the card is moved once its start date is reached. */
public const START = 'start';
public const ALL = [self::DUE, self::START];
public static function isValid(string $trigger): bool {
return in_array($trigger, self::ALL, true);
}
/**
* Completes "„<card>“ … %s und wurde … verschoben" — shared by
* NotificationMailer and Notification\Notifier so mail and notification
* give the same reason. An unknown value counts as `due`, the column
* default — the due date was the only trigger before the column existed.
*/
public static function describeReason(string $trigger): string {
return $trigger === self::START
? 'hat ihr Startdatum erreicht'
: 'ist überfällig';
}
}
+36 -18
View File
@@ -19,7 +19,8 @@ use Psr\Log\LoggerInterface;
use Throwable;
/**
* Evaluates all enabled workflows and moves overdue cards.
* Evaluates all enabled workflows and moves every card whose due date or
* start date — whichever the workflow uses, see TriggerDate — 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
@@ -224,11 +225,24 @@ class WorkflowRunner {
return;
}
$trigger = (string)$workflow->getTriggerDate();
if ($trigger === TriggerDate::START && !$this->deckService->supportsStartdate()) {
// Not disabled: the workflow is intact, Deck is just too old for
// it, and comes back to life with a Deck update.
$this->logger->warning('Skipping workflow {id}: this Deck version has no start date', [
'app' => 'workflow_deck_automation',
'id' => $workflow->getId(),
]);
return;
}
$moved = [];
$now = $this->timeFactory->getDateTime();
foreach ($cards as $card) {
if (!self::isOverdue($card->getDuedate(), $now)) {
// Anything but `start` counts as `due`, the column default.
$date = $trigger === TriggerDate::START ? $card->getStartdate() : $card->getDuedate();
if (!self::hasPassed($date, $now)) {
continue;
}
@@ -424,7 +438,10 @@ class WorkflowRunner {
}
/**
* Compares the card's actual due timestamp against $now instead of Deck's own
* Whether the card's trigger date (due or start date, see TriggerDate)
* lies before $now.
*
* 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
@@ -432,30 +449,31 @@ class WorkflowRunner {
* instead matches what Deck's own UI shows (e.g. "vor 2 Stunden") and has no
* day-rounding step to get wrong.
*
* Both sides are compared as absolute instants, and the due date is first
* pinned to UTC via asStoredUtc() — see there for why.
* Both sides are compared as absolute instants, and the card's date is
* first pinned to UTC via asStoredUtc() — see there for why.
*/
public static function isOverdue(?DateTimeInterface $duedate, DateTimeInterface $now): bool {
return $duedate !== null && self::asStoredUtc($duedate) < $now;
public static function hasPassed(?DateTimeInterface $date, DateTimeInterface $now): bool {
return $date !== null && self::asStoredUtc($date) < $now;
}
/**
* Deck's `duedate` is a DATETIME column without a zone that holds UTC
* wall-clock time: Deck's frontend sends `toISOString()`, and the value is
* written as plain `Y-m-d H:i:s`. Entity hydrates it with
* `new \DateTime($string)`, i.e. in PHP's *default* timezone — correct only
* because Nextcloud sets that default to UTC during bootstrap. Reading the
* wall clock and pinning it to UTC explicitly makes the comparison
* independent of that default, so a server or CLI running with a local
* default zone cannot shift every due date by its UTC offset.
* Deck's `duedate` and `startdate` are DATETIME columns without a zone
* that hold UTC wall-clock time: Deck's frontend sends `toISOString()`,
* and the value is written as plain `Y-m-d H:i:s`. Entity hydrates it
* with `new \DateTime($string)`, i.e. in PHP's *default* timezone —
* correct only because Nextcloud sets that default to UTC during
* bootstrap. Reading the wall clock and pinning it to UTC explicitly
* makes the comparison independent of that default, so a server or CLI
* running with a local default zone cannot shift every date by its UTC
* offset.
*
* Deck itself has two timezone-sensitive paths this deliberately avoids:
* Card::getDaysUntilDue() (day-truncated, see isOverdue()) and
* Card::getDaysUntilDue() (day-truncated, see hasPassed()) and
* CardMapper::findOverdue(), which compares the UTC column against the
* database's `NOW()` — the DB session's local time.
*/
private static function asStoredUtc(DateTimeInterface $duedate): DateTimeImmutable {
return new DateTimeImmutable($duedate->format('Y-m-d H:i:s.u'), new DateTimeZone('UTC'));
private static function asStoredUtc(DateTimeInterface $date): DateTimeImmutable {
return new DateTimeImmutable($date->format('Y-m-d H:i:s.u'), new DateTimeZone('UTC'));
}
/**