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

+16 -5
View File
@@ -9,6 +9,7 @@ use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
use OCA\WorkflowDeckAutomation\Service\DeckIntegrationService;
use OCA\WorkflowDeckAutomation\Service\DeckUnavailableException;
use OCA\WorkflowDeckAutomation\Service\NotificationPolicy;
use OCA\WorkflowDeckAutomation\Service\TriggerDate;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
@@ -56,15 +57,16 @@ class WorkflowController extends OCSController {
int $boardId,
int $sourceStackId,
int $targetStackId,
string $triggerDate = TriggerDate::DUE,
string $notifyMode = NotificationPolicy::MODE_OFF,
string $notifyChannel = NotificationPolicy::CHANNEL_MAIL,
bool $enabled = true,
): DataResponse {
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel);
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel);
$workflow = new Workflow();
$workflow->setUserId($this->currentUserId());
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel, $enabled);
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel, $enabled);
$workflow = $this->workflowMapper->insert($workflow);
return new DataResponse($workflow->jsonSerialize(), Http::STATUS_CREATED);
@@ -77,17 +79,18 @@ class WorkflowController extends OCSController {
int $boardId,
int $sourceStackId,
int $targetStackId,
string $triggerDate = TriggerDate::DUE,
string $notifyMode = NotificationPolicy::MODE_OFF,
string $notifyChannel = NotificationPolicy::CHANNEL_MAIL,
bool $enabled = true,
): DataResponse {
$workflow = $this->findOwnWorkflow($id);
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel);
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel);
// Kept in step with the board owner so the job runs as, and mails,
// the right user; validate() made sure the editor owns the board.
$workflow->setUserId($this->currentUserId());
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel, $enabled);
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel, $enabled);
$workflow = $this->workflowMapper->update($workflow);
return new DataResponse($workflow->jsonSerialize());
@@ -158,13 +161,16 @@ class WorkflowController extends OCSController {
}
}
private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId, string $notifyMode, string $notifyChannel): void {
private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId, string $triggerDate, string $notifyMode, string $notifyChannel): void {
if (trim($title) === '') {
throw new OCSBadRequestException('Bitte einen Titel angeben.');
}
if ($sourceStackId === $targetStackId) {
throw new OCSBadRequestException('Quell- und Ziel-Stapel müssen sich unterscheiden.');
}
if (!TriggerDate::isValid($triggerDate)) {
throw new OCSBadRequestException('Ungültiges Datum für die Verschiebung, erlaubt sind: ' . implode(', ', TriggerDate::ALL));
}
if (!NotificationPolicy::isValidMode($notifyMode)) {
throw new OCSBadRequestException('Ungültige Benachrichtigungs-Einstellung, erlaubt sind: ' . implode(', ', NotificationPolicy::MODES));
}
@@ -177,6 +183,9 @@ class WorkflowController extends OCSController {
// assertDeck() ran first, so an empty list here means "not there",
// not "Deck is unreachable".
$this->assertDeck();
if ($triggerDate === TriggerDate::START && !$this->deckService->supportsStartdate()) {
throw new OCSBadRequestException('Die installierte Deck-Version kennt kein Startdatum.');
}
// Only boards the user owns are listed, so this also enforces the
// owner rule. Archived boards stay listed (their workflows must stay
@@ -207,6 +216,7 @@ class WorkflowController extends OCSController {
int $boardId,
int $sourceStackId,
int $targetStackId,
string $triggerDate,
string $notifyMode,
string $notifyChannel,
bool $enabled,
@@ -215,6 +225,7 @@ class WorkflowController extends OCSController {
$workflow->setBoardId($boardId);
$workflow->setSourceStackId($sourceStackId);
$workflow->setTargetStackId($targetStackId);
$workflow->setTriggerDate($triggerDate);
$workflow->setNotifyMode($notifyMode);
$workflow->setNotifyChannel($notifyChannel);
$workflow->setEnabled($enabled);
+5
View File
@@ -18,6 +18,8 @@ use OCP\DB\Types;
* @method void setSourceStackId(int $sourceStackId)
* @method int getTargetStackId()
* @method void setTargetStackId(int $targetStackId)
* @method string getTriggerDate()
* @method void setTriggerDate(string $triggerDate)
* @method string getNotifyMode()
* @method void setNotifyMode(string $notifyMode)
* @method string getNotifyChannel()
@@ -33,6 +35,7 @@ class Workflow extends Entity implements \JsonSerializable {
protected $boardId;
protected $sourceStackId;
protected $targetStackId;
protected $triggerDate;
protected $notifyMode;
protected $notifyChannel;
protected $enabled;
@@ -45,6 +48,7 @@ class Workflow extends Entity implements \JsonSerializable {
$this->addType('boardId', Types::INTEGER);
$this->addType('sourceStackId', Types::INTEGER);
$this->addType('targetStackId', Types::INTEGER);
$this->addType('triggerDate', Types::STRING);
$this->addType('notifyMode', Types::STRING);
$this->addType('notifyChannel', Types::STRING);
$this->addType('enabled', Types::BOOLEAN);
@@ -59,6 +63,7 @@ class Workflow extends Entity implements \JsonSerializable {
'boardId' => $this->getBoardId(),
'sourceStackId' => $this->getSourceStackId(),
'targetStackId' => $this->getTargetStackId(),
'triggerDate' => (string)$this->getTriggerDate(),
'notifyMode' => (string)$this->getNotifyMode(),
'notifyChannel' => (string)$this->getNotifyChannel(),
'enabled' => (bool)$this->getEnabled(),
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Adds `trigger_date` (due / start, see TriggerDate::ALL): whether a card
* moves once its due date or once its start date is reached. Existing
* workflows get `due`, the only trigger there was, so nothing changes for
* them.
*/
class Version1005Date20261003120000 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('trigger_date')) {
return null;
}
$table->addColumn('trigger_date', Types::STRING, [
'notnull' => true,
'length' => 16,
'default' => 'due',
]);
return $schema;
}
}
+7 -1
View File
@@ -7,6 +7,7 @@ namespace OCA\WorkflowDeckAutomation\Notification;
use OCA\WorkflowDeckAutomation\AppInfo\Application;
use OCA\WorkflowDeckAutomation\Service\NotificationMailer;
use OCA\WorkflowDeckAutomation\Service\NotificationPusher;
use OCA\WorkflowDeckAutomation\Service\TriggerDate;
use OCP\IURLGenerator;
use OCP\Notification\INotification;
use OCP\Notification\INotifier;
@@ -95,7 +96,12 @@ class Notifier implements INotifier {
self::setText(
$notification,
'{deck-card} wurde nach {target-stack} verschoben',
'Die Karte auf {deck-board} ist überfällig und wurde automatisch von {source-stack} nach {target-stack} verschoben.',
sprintf(
'Die Karte auf {deck-board} %s und wurde automatisch von {source-stack} nach {target-stack} verschoben.',
// Missing on notifications from before the start date existed,
// which were all about due dates.
TriggerDate::describeReason((string)($params['triggerDate'] ?? TriggerDate::DUE)),
),
$objects,
);
}
+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'));
}
/**