Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28b4edc811 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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[]
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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,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>
|
||||||
|
|||||||
@@ -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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user