Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4464e5ad3c | ||
|
|
9e1a15bab0 | ||
|
|
8268f1068d |
No files matched your search
@@ -4,7 +4,7 @@ Leitfaden für Claude Code (claude.ai/code) in diesem Repository.
|
|||||||
|
|
||||||
## Worum es geht
|
## Worum es geht
|
||||||
|
|
||||||
Nextcloud-App `workflow_deck_automation` (Namespace `WorkflowDeckAutomation`) für **Nextcloud 35** (`main`, `min-version="35" max-version="35"`). Jeder Nutzer legt in seinen persönlichen Einstellungen „Workflows“ an, die überfällige [Deck](https://github.com/nextcloud/deck)-Karten von einem Stack in einen anderen verschieben, optional mit Mail. Es gibt bewusst **keine Kartenfilter**: ein Workflow verschiebt *jede* überfällige Karte seines Quell-Stacks. Ein Hintergrund-`Job` wertet alle aktiven Workflows bei jedem Cron-Lauf aus.
|
Nextcloud-App `workflow_deck_automation` (Namespace `WorkflowDeckAutomation`) für **Nextcloud 35** (`main`, `min-version="35" max-version="35"`). Jeder Nutzer legt in seinen persönlichen Einstellungen „Workflows“ an, die [Deck](https://github.com/nextcloud/deck)-Karten von einem Stack in einen anderen verschieben, sobald ihr Fälligkeits- oder Startdatum erreicht ist (`trigger_date`, pro Workflow), optional mit Mail. Es gibt bewusst **keine Kartenfilter**: ein Workflow verschiebt *jede* Karte seines Quell-Stacks, deren Datum erreicht ist. Ein Hintergrund-`Job` wertet alle aktiven Workflows bei jedem Cron-Lauf aus.
|
||||||
|
|
||||||
## Harte Vorgabe: keine HTTP/OCS-Aufrufe gegen Deck
|
## Harte Vorgabe: keine HTTP/OCS-Aufrufe gegen Deck
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ Alle Lesezugriffe (Boards, Stacks, Karten) und das Verschieben laufen über Deck
|
|||||||
|
|
||||||
**Regel:** Bevor ein weiterer `OCA\Deck\*`-Dienst im Runner genutzt wird, prüfen, ob er eine eingefrorene `$userId` trägt – der Fehler ist still und nutzerübergreifend. Bevorzugt Mapper (QBMapper ohne Nutzerzustand) oder Methoden mit explizitem uid-Parameter verwenden. Deshalb wird `BoardService::getUserBoards()` nicht verwendet.
|
**Regel:** Bevor ein weiterer `OCA\Deck\*`-Dienst im Runner genutzt wird, prüfen, ob er eine eingefrorene `$userId` trägt – der Fehler ist still und nutzerübergreifend. Bevorzugt Mapper (QBMapper ohne Nutzerzustand) oder Methoden mit explizitem uid-Parameter verwenden. Deshalb wird `BoardService::getUserBoards()` nicht verwendet.
|
||||||
|
|
||||||
Die Fälligkeitslogik `WorkflowRunner::isOverdue()` und die Empfängerregel `NotificationPolicy::shouldNotify()` sind bewusst `static` und seiteneffektfrei, also ohne Deck unit-testbar (`tests/Unit/Service/`). Das sind die einzigen getesteten Teile der App. Neue reine Logik in dieser Form halten.
|
Der Datumsvergleich `WorkflowRunner::hasPassed()`, `TriggerDate`, `LoopDetector` und die Empfängerregel `NotificationPolicy::shouldNotify()` sind bewusst `static` und seiteneffektfrei, also ohne Deck unit-testbar (`tests/Unit/Service/`). Das sind die einzigen getesteten Teile der App. Neue reine Logik in dieser Form halten.
|
||||||
|
|
||||||
## Workflows werden deaktiviert, wenn Board/Stack verschwinden
|
## Workflows werden deaktiviert, wenn Board/Stack verschwinden
|
||||||
|
|
||||||
@@ -36,13 +36,13 @@ Die Fälligkeitslogik `WorkflowRunner::isOverdue()` und die Empfängerregel `Not
|
|||||||
- **`NoPermissionException` beweist nichts** und steht deshalb bewusst nicht in `isMissing()`.
|
- **`NoPermissionException` beweist nichts** und steht deshalb bewusst nicht in `isMissing()`.
|
||||||
- **Die Deaktivierungsmail ignoriert `notifyMode` und `notifyChannel`** (die betreffen verschobene Karten) und geht immer nur an den Owner, zusätzlich als Nextcloud-Benachrichtigung, falls die Notifications-App aktiv ist. Sie bleibt einmalig, weil `findAllEnabled()` deaktivierte Workflows nicht mehr lädt.
|
- **Die Deaktivierungsmail ignoriert `notifyMode` und `notifyChannel`** (die betreffen verschobene Karten) und geht immer nur an den Owner, zusätzlich als Nextcloud-Benachrichtigung, falls die Notifications-App aktiv ist. Sie bleibt einmalig, weil `findAllEnabled()` deaktivierte Workflows nicht mehr lädt.
|
||||||
|
|
||||||
Archivierte Boards lösen das nicht aus (sie bleiben lesbar), werden aber in den Dropdowns ausgeblendet.
|
Archivierte Boards lösen das nicht aus (sie bleiben lesbar). Ihre Workflows bleiben aktiv, werden aber übersprungen: `runWorkflow()` fragt nach `findBrokenTarget()` `DeckIntegrationService::isBoardArchived()` (`BoardMapper`, `false` bei fehlendem Board, sonst wirft es). Im Dropdown erscheinen archivierte Boards nie, auch nicht beim Bearbeiten (dann leert das Formular Board und Stapel und zeigt einen Hinweis). In der Übersicht werden sie markiert.
|
||||||
|
|
||||||
## Ein Workflow gehört dem Board-Owner, nicht seinem Ersteller
|
## Ein Workflow gehört dem Board-Owner, nicht seinem Ersteller
|
||||||
|
|
||||||
Wird ein Board in Deck übertragen, wandern seine Workflows mit.
|
Wird ein Board in Deck übertragen, wandern seine Workflows mit.
|
||||||
|
|
||||||
- **„Meine Boards“** = `listOwnedBoards($uid)` → `BoardMapper::findAllByOwner($uid)` (keine geteilten Boards, keine eingefrorene uid). Diese Mapper-Methode filtert weder Papierkorb noch Archiv: Papierkorb-Boards werden verworfen, archivierte mit `archived: true` behalten (Workflows sichtbar, aber nicht im Dropdown, von `validate()` abgelehnt). Wirft statt `[]` zurückzugeben – `[]` würde alle Workflows des Nutzers verstecken.
|
- **„Meine Boards“** = `listOwnedBoards($uid)` → `BoardMapper::findAllByOwner($uid)` (keine geteilten Boards, keine eingefrorene uid). Diese Mapper-Methode filtert weder Papierkorb noch Archiv: Papierkorb-Boards werden verworfen, archivierte mit `archived: true` behalten (Workflows sichtbar, aber nicht im Dropdown, von `validate()` abgelehnt, vom Runner übersprungen). Wirft statt `[]` zurückzugeben – `[]` würde alle Workflows des Nutzers verstecken.
|
||||||
- **Auflisten/Bearbeiten/Löschen** nur über `board_id IN (listOwnedBoards)` (`WorkflowMapper::findAllForBoards()`, `WorkflowController::findOwnWorkflow()`). `user_id` spielt keine Rolle – keine `user_id`-Abfragen im Controller wieder einführen.
|
- **Auflisten/Bearbeiten/Löschen** nur über `board_id IN (listOwnedBoards)` (`WorkflowMapper::findAllForBoards()`, `WorkflowController::findOwnWorkflow()`). `user_id` spielt keine Rolle – keine `user_id`-Abfragen im Controller wieder einführen.
|
||||||
- **Anlegen/Speichern** validiert gegen dieselbe Liste und setzt `user_id` auf den aktuellen Nutzer.
|
- **Anlegen/Speichern** validiert gegen dieselbe Liste und setzt `user_id` auf den aktuellen Nutzer.
|
||||||
- **Runner:** `followBoardOwner()` → `findBoardOwner()` (`BoardMapper::find()`) schreibt einen abweichenden Owner in `user_id` zurück. Lauf und Mails gehen also an den aktuellen Owner. Ein verschwundenes Board behält den letzten Owner, damit `findBrokenTarget()` ihn anmailen kann.
|
- **Runner:** `followBoardOwner()` → `findBoardOwner()` (`BoardMapper::find()`) schreibt einen abweichenden Owner in `user_id` zurück. Lauf und Mails gehen also an den aktuellen Owner. Ein verschwundenes Board behält den letzten Owner, damit `findBrokenTarget()` ihn anmailen kann.
|
||||||
@@ -71,9 +71,25 @@ Wird ein Board in Deck übertragen, wandern seine Workflows mit.
|
|||||||
|
|
||||||
Die App wird nur intern genutzt und spricht ausschließlich Deutsch. Oberfläche, Controller-Fehlermeldungen (erscheinen im Formular), Sektionsname, Mails und Nextcloud-Benachrichtigungen sind direkt auf Deutsch geschrieben. Es gibt kein `IL10N` und keinen `l10n/`-Ordner. Nextclouds eigene Mail-Fußzeile wird per `addFooter('', 'de')` ebenfalls deutsch, sonst würde sie im Cron der Instanz-Standardsprache folgen. Log-Meldungen bleiben englisch. Neue nutzersichtbare Texte auf Deutsch schreiben, mit „du“ wie im Rest der App.
|
Die App wird nur intern genutzt und spricht ausschließlich Deutsch. Oberfläche, Controller-Fehlermeldungen (erscheinen im Formular), Sektionsname, Mails und Nextcloud-Benachrichtigungen sind direkt auf Deutsch geschrieben. Es gibt kein `IL10N` und keinen `l10n/`-Ordner. Nextclouds eigene Mail-Fußzeile wird per `addFooter('', 'de')` ebenfalls deutsch, sonst würde sie im Cron der Instanz-Standardsprache folgen. Log-Meldungen bleiben englisch. Neue nutzersichtbare Texte auf Deutsch schreiben, mit „du“ wie im Rest der App.
|
||||||
|
|
||||||
|
## Fälligkeits- oder Startdatum löst das Verschieben aus
|
||||||
|
|
||||||
|
`trigger_date` (`TriggerDate::ALL`: `due`/`start`, Default und Migrationswert `due`, `Version1005…`) wählt pro Workflow, welches Kartendatum der Runner mit „jetzt“ vergleicht. Unbekannte Werte zählen als `due`. Karten ohne das gewählte Datum bleiben liegen.
|
||||||
|
|
||||||
|
- **`startdate` gibt es erst seit Deck 1.18.** `Card`-Getter sind magisch (`Entity::__call`), `method_exists()` sieht sie nicht und `getStartdate()` würde erst beim Aufruf werfen. Daher `DeckIntegrationService::supportsStartdate()` (`property_exists`). Der Runner liest `getStartdate()` nur bei `start` und überspringt solche Workflows ohne Deaktivierung, wenn Deck das Feld nicht kennt. Der Controller lehnt `start` dann ab.
|
||||||
|
- **Texte:** `TriggerDate::describeReason()` liefert den Satzteil („ist überfällig“ / „hat ihr Startdatum erreicht“) für Mail und Notifier. `NotificationPusher` speichert `triggerDate` in den Subject-Parametern. Ältere Benachrichtigungen ohne den Schlüssel gelten als `due`.
|
||||||
|
- **Empfänger:** Auch Startdatums-Workflows nutzen bei `notify_mode = board` Decks `notify-due`-Einstellung. Deck hat keine eigene für das Startdatum.
|
||||||
|
|
||||||
|
## Keine Schleifen, kein Quelle = Ziel
|
||||||
|
|
||||||
|
Jeder aktive Workflow ist eine Kante „Quell-Stack → Ziel-Stack“. `LoopDetector` (rein, getestet) findet Kreise. „Quelle = Ziel“ ist ein Kreis der Länge 1. Das Auslöse-Datum wird bewusst ignoriert: Bei S1→S2 per Start und S2→S1 per Fälligkeit pendelt jede Karte, bei der beide Daten erreicht sind. Ohne Prüfung würde der Runner sie sogar im selben Lauf hin- und herschieben (jeder Workflow liest seinen Quell-Stack neu), mit einer Benachrichtigung pro Bewegung. Ketten sind erlaubt.
|
||||||
|
|
||||||
|
- **Controller:** `validate()` → `assertNoLoop()` lehnt einen *aktiven* Workflow ab, dessen Ziel über die anderen aktiven Workflows des Boards zu seiner Quelle zurückführt, und nennt sie. Deaktivierte Workflows zählen nicht. Beim Aktivieren läuft dieselbe Prüfung, weil auch das über `update()` geht.
|
||||||
|
- **Runner und Liste:** Altbestand kann trotzdem Kreise bilden. `run()` überspringt alle Workflows auf einem Kreis (nur Log, keine Deaktivierung, keine Mail), und `index()` liefert `loop: true`, das die Oberfläche rot markiert.
|
||||||
|
- Stack-IDs sind in Deck boardübergreifend eindeutig, deshalb darf der Runner alle Workflows der Instanz in einen Graphen werfen.
|
||||||
|
|
||||||
## Fälligkeit wird als UTC-Zeitpunkt verglichen
|
## Fälligkeit wird als UTC-Zeitpunkt verglichen
|
||||||
|
|
||||||
Decks `duedate` ist eine `DATETIME`-Spalte ohne Zeitzone mit UTC-Uhrzeit (Frontend sendet `toISOString()`). `Entity` baut daraus `new \DateTime($string)` in PHPs Standardzone, die Nextcloud auf UTC setzt. `WorkflowRunner::asStoredUtc()` legt die Uhrzeit trotzdem ausdrücklich auf UTC fest, damit das Ergebnis nicht von dieser Standardzone abhängt. Decks eigene Pfade `Card::getDaysUntilDue()` (tagesgenau) und `CardMapper::findOverdue()` (vergleicht mit dem `NOW()` der Datenbank, also Ortszeit) sind dafür ungeeignet und werden nicht verwendet.
|
Decks `duedate` und `startdate` sind `DATETIME`-Spalten ohne Zeitzone mit UTC-Uhrzeit (Frontend sendet `toISOString()`). `Entity` baut daraus `new \DateTime($string)` in PHPs Standardzone, die Nextcloud auf UTC setzt. `WorkflowRunner::asStoredUtc()` legt die Uhrzeit trotzdem ausdrücklich auf UTC fest, damit das Ergebnis nicht von dieser Standardzone abhängt. Decks eigene Pfade `Card::getDaysUntilDue()` (tagesgenau) und `CardMapper::findOverdue()` (vergleicht mit dem `NOW()` der Datenbank, also Ortszeit) sind dafür ungeeignet und werden nicht verwendet.
|
||||||
|
|
||||||
## Kartenfilter wurden entfernt
|
## Kartenfilter wurden entfernt
|
||||||
|
|
||||||
@@ -166,4 +182,5 @@ Ist ein Tag-Build fehlgeschlagen und **nie ein Artefakt entstanden**, den Tag au
|
|||||||
- **Relationale Entities:** Auf angereicherten Entities liefern `$acl->getParticipant()`, `$board->getOwner()` oder Assignment-Teilnehmer ein `RelationalObject` statt einer uid (uid = `getPrimaryKey()`). Neue Teilnehmer-/Owner-Felder immer über `DeckIntegrationService::unwrapUid()` laufen lassen.
|
- **Relationale Entities:** Auf angereicherten Entities liefern `$acl->getParticipant()`, `$board->getOwner()` oder Assignment-Teilnehmer ein `RelationalObject` statt einer uid (uid = `getPrimaryKey()`). Neue Teilnehmer-/Owner-Felder immer über `DeckIntegrationService::unwrapUid()` laufen lassen.
|
||||||
- Die ACL eines Boards enthält **nicht** den Owner (privates Board = leere ACL). ACL-Einträge können Gruppen (`type === 1`) oder Circles sein.
|
- Die ACL eines Boards enthält **nicht** den Owner (privates Board = leere ACL). ACL-Einträge können Gruppen (`type === 1`) oder Circles sein.
|
||||||
- `StackMapper::findAll()` filtert `deleted_at = 0` selbst. Stacks haben keinen Archivstatus.
|
- `StackMapper::findAll()` filtert `deleted_at = 0` selbst. Stacks haben keinen Archivstatus.
|
||||||
|
- **Entity-Getter sind magisch** (`Entity::__call`, z. B. `getDeletedAt()`, `getArchived()`, `getStartdate()`). `method_exists()` ist für sie immer `false`. Vorhandensein per `property_exists()` prüfen (`DeckIntegrationService::isTrashed()`/`isArchived()`/`supportsStartdate()`). Mit `method_exists()` wurden früher Karten aus dem Papierkorb verschoben, und Papierkorb- wie Archiv-Boards blieben unerkannt.
|
||||||
- Der Deck-Quellcode liegt lokal unter `C:\Users\pani\Documents\git-privat\nextcloud-deck` und dient zum Nachprüfen von Interna.
|
- Der Deck-Quellcode liegt lokal unter `C:\Users\pani\Documents\git-privat\nextcloud-deck` und dient zum Nachprüfen von Interna.
|
||||||
+5
-1
@@ -21,6 +21,7 @@ Die Tabelle `wfda_workflows` enthält eine Zeile pro Workflow:
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `board_id`, `source_stack_id`, `target_stack_id` | Deck-IDs |
|
| `board_id`, `source_stack_id`, `target_stack_id` | Deck-IDs |
|
||||||
| `user_id` | Board-Eigentümer, in dessen Namen der Job läuft. Wird bei jedem Lauf aus Deck aktualisiert. |
|
| `user_id` | Board-Eigentümer, in dessen Namen der Job läuft. Wird bei jedem Lauf aus Deck aktualisiert. |
|
||||||
|
| `trigger_date` | `due` oder `start`: Fälligkeits- oder Startdatum löst das Verschieben aus |
|
||||||
| `notify_mode` | `off`, `owner` oder `board` (wer) |
|
| `notify_mode` | `off`, `owner` oder `board` (wer) |
|
||||||
| `notify_channel` | `mail`, `notification` oder `both` (wie) |
|
| `notify_channel` | `mail`, `notification` oder `both` (wie) |
|
||||||
| `enabled`, `last_run` | aktiv? / letzter Lauf |
|
| `enabled`, `last_run` | aktiv? / letzter Lauf |
|
||||||
@@ -30,6 +31,7 @@ Migrationen in [`lib/Migration/`](lib/Migration/):
|
|||||||
- `Version1001…` entfernt die früheren Filter-Spalten.
|
- `Version1001…` entfernt die früheren Filter-Spalten.
|
||||||
- `Version1002…` und `Version1003…` ersetzen `notify_email` durch `notify_mode`. Ein gesetztes Häkchen wird dabei zu `owner`.
|
- `Version1002…` und `Version1003…` ersetzen `notify_email` durch `notify_mode`. Ein gesetztes Häkchen wird dabei zu `owner`.
|
||||||
- `Version1004…` fügt `notify_channel` hinzu. Bestehende Workflows bekommen `mail`.
|
- `Version1004…` fügt `notify_channel` hinzu. Bestehende Workflows bekommen `mail`.
|
||||||
|
- `Version1005…` fügt `trigger_date` hinzu. Bestehende Workflows bekommen `due`.
|
||||||
|
|
||||||
Migrationen laufen nur, wenn die App-Version steigt.
|
Migrationen laufen nur, wenn die App-Version steigt.
|
||||||
|
|
||||||
@@ -53,7 +55,9 @@ composer run test:unit # PHPUnit
|
|||||||
```
|
```
|
||||||
|
|
||||||
Die Unit-Tests decken nur reine Logik ab, ohne Deck-Installation:
|
Die Unit-Tests decken nur reine Logik ab, ohne Deck-Installation:
|
||||||
- Fälligkeitsprüfung (`WorkflowRunner::isOverdue()`)
|
- Datumsvergleich für Fälligkeits- und Startdatum (`WorkflowRunner::hasPassed()`)
|
||||||
|
- Auslöser und Begründungstext (`TriggerDate`)
|
||||||
|
- Schleifenerkennung (`LoopDetector`)
|
||||||
- Empfängerregel (`NotificationPolicy::shouldNotify()`)
|
- Empfängerregel (`NotificationPolicy::shouldNotify()`)
|
||||||
- Benachrichtigungsweg (`NotificationPolicy::sendsMail()`/`sendsNotification()`)
|
- Benachrichtigungsweg (`NotificationPolicy::sendsMail()`/`sendsNotification()`)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Deck Workflow-Automatisierung
|
# Deck Workflow-Automatisierung
|
||||||
|
|
||||||
Nextcloud-App, die überfällige Karten der [Deck](https://apps.nextcloud.com/apps/deck)-App automatisch von einem Stapel in einen anderen verschiebt und auf Wunsch per E-Mail oder Nextcloud-Benachrichtigung (Push) darüber informiert.
|
Nextcloud-App, die Karten der [Deck](https://apps.nextcloud.com/apps/deck)-App automatisch von einem Stapel in einen anderen verschiebt, sobald ihr Fälligkeits- oder Startdatum erreicht ist, und auf Wunsch per E-Mail oder Nextcloud-Benachrichtigung (Push) darüber informiert.
|
||||||
|
|
||||||
Die Regeln („Workflows“) richtet der **Eigentümer eines Boards** selbst in seinen **persönlichen Einstellungen** ein. Admin-Rechte braucht er dafür nicht. Für geteilte Boards anderer Nutzer lassen sich keine Workflows anlegen.
|
Die Regeln („Workflows“) richtet der **Eigentümer eines Boards** selbst in seinen **persönlichen Einstellungen** ein. Admin-Rechte braucht er dafür nicht. Für geteilte Boards anderer Nutzer lassen sich keine Workflows anlegen.
|
||||||
|
|
||||||
@@ -8,11 +8,16 @@ Die Regeln („Workflows“) richtet der **Eigentümer eines Boards** selbst in
|
|||||||
|
|
||||||
## Was die App macht
|
## Was die App macht
|
||||||
|
|
||||||
Ein Workflow besteht aus einem Board, einem **Quell-Stapel** und einem **Ziel-Stapel**. Bei jedem Durchlauf verschiebt die App **alle überfälligen Karten** des Quell-Stapels oben in den Ziel-Stapel.
|
Ein Workflow besteht aus einem Board, einem **Quell-Stapel**, einem **Ziel-Stapel** und dem **Datum**, das das Verschieben auslöst. Bei jedem Durchlauf verschiebt die App **alle Karten** des Quell-Stapels, bei denen dieses Datum erreicht ist, oben in den Ziel-Stapel.
|
||||||
|
|
||||||
Überfällig ist eine Karte, deren Fälligkeitsdatum samt Uhrzeit überschritten ist. Karten ohne Fälligkeitsdatum sowie erledigte, archivierte oder gelöschte Karten bleiben liegen.
|
- **Fälligkeitsdatum** (Standard): Die Karte wird verschoben, sobald sie überfällig ist, also ihr Fälligkeitsdatum samt Uhrzeit überschritten ist.
|
||||||
|
- **Startdatum**: Die Karte wird verschoben, sobald ihr Startdatum samt Uhrzeit erreicht ist. Das Startdatum setzt du in Deck in den Kartendetails neben dem Fälligkeitsdatum.
|
||||||
|
|
||||||
Beispiel: Ein Workflow „Waiting → ToDo“ holt jede Karte aus „Waiting / Blocked“ nach „Work in Progress / ToDo“, sobald ihr Fälligkeitsdatum erreicht ist.
|
Karten ohne das gewählte Datum sowie erledigte, archivierte oder gelöschte Karten bleiben liegen.
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
- Ein Workflow „Waiting → ToDo“ holt jede Karte aus „Waiting / Blocked“ nach „Work in Progress / ToDo“, sobald ihr Fälligkeitsdatum erreicht ist.
|
||||||
|
- Ein Workflow „Geplant → In Arbeit“ mit Startdatum holt jede Karte aus „Geplant“ nach „In Arbeit“, sobald an ihrem Startdatum die Arbeit beginnen soll.
|
||||||
|
|
||||||
## Einrichtung (für Administratoren)
|
## Einrichtung (für Administratoren)
|
||||||
|
|
||||||
@@ -30,6 +35,8 @@ Die Einstellungen liegen unter **Persönliche Einstellungen → Deck Workflow-Au
|
|||||||
|
|
||||||
- **Board:** Zur Auswahl stehen nur Boards, deren **Eigentümer** du bist. Geteilte, archivierte und gelöschte Boards erscheinen nicht.
|
- **Board:** Zur Auswahl stehen nur Boards, deren **Eigentümer** du bist. Geteilte, archivierte und gelöschte Boards erscheinen nicht.
|
||||||
- **Quell- und Ziel-Stapel:** Das müssen zwei verschiedene Stapel desselben Boards sein.
|
- **Quell- und Ziel-Stapel:** Das müssen zwei verschiedene Stapel desselben Boards sein.
|
||||||
|
- **Karte verschieben, wenn:** das Fälligkeitsdatum überschritten oder das Startdatum erreicht ist.
|
||||||
|
- **Keine Schleifen:** Ein aktiver Workflow darf mit den anderen aktiven Workflows des Boards keinen Kreis bilden, etwa „A → B“ und „B → A“. Sonst würden Karten bei jedem Durchlauf hin- und hergeschoben. Beim Speichern wird das abgelehnt, mit den Namen der beteiligten Workflows. Das gilt unabhängig davon, ob die Workflows nach Fälligkeits- oder Startdatum verschieben. Ketten wie „A → B“ und „B → C“ sind erlaubt.
|
||||||
- **Benachrichtigung bei verschobenen Karten** und **Benachrichtigen per:** siehe unten.
|
- **Benachrichtigung bei verschobenen Karten** und **Benachrichtigen per:** siehe unten.
|
||||||
- **Workflow aktiv:** Deaktivierte Workflows bleiben gespeichert, verschieben aber nichts.
|
- **Workflow aktiv:** Deaktivierte Workflows bleiben gespeichert, verschieben aber nichts.
|
||||||
|
|
||||||
@@ -55,6 +62,8 @@ Bei der dritten Stufe entscheidet jedes Board-Mitglied selbst. Dazu gehören der
|
|||||||
|
|
||||||
Bei einem Board, das mit niemandem geteilt ist, bekommt der Eigentümer jede Karte gemeldet, außer er hat die Erinnerungen ausgeschaltet. So verhält sich auch Deck selbst.
|
Bei einem Board, das mit niemandem geteilt ist, bekommt der Eigentümer jede Karte gemeldet, außer er hat die Erinnerungen ausgeschaltet. So verhält sich auch Deck selbst.
|
||||||
|
|
||||||
|
Das gilt genauso für Workflows mit Startdatum. Deck hat für das Startdatum keine eigene Benachrichtigungseinstellung.
|
||||||
|
|
||||||
### Wie?
|
### Wie?
|
||||||
|
|
||||||
| Einstellung | Was kommt an? |
|
| Einstellung | Was kommt an? |
|
||||||
@@ -69,9 +78,13 @@ Die Empfänger sind in allen drei Fällen dieselben.
|
|||||||
|
|
||||||
> „Essensvorräte prüfen“ (Board „Persönlich“) ist überfällig und wurde von „Waiting / Blocked“ nach „Work in Progress / ToDo“ verschoben.
|
> „Essensvorräte prüfen“ (Board „Persönlich“) ist überfällig und wurde von „Waiting / Blocked“ nach „Work in Progress / ToDo“ verschoben.
|
||||||
|
|
||||||
|
Bei Workflows mit Startdatum heißt es stattdessen:
|
||||||
|
|
||||||
|
> „Essensvorräte prüfen“ (Board „Persönlich“) hat ihr Startdatum erreicht und wurde von „Geplant“ nach „In Arbeit“ verschoben.
|
||||||
|
|
||||||
Der Kartentitel ist ein Link direkt zur Karte in Deck. Die Kartenbeschreibung wird nicht mitgeschickt.
|
Der Kartentitel ist ein Link direkt zur Karte in Deck. Die Kartenbeschreibung wird nicht mitgeschickt.
|
||||||
|
|
||||||
**Inhalt der Nextcloud-Benachrichtigung:** „*Kartentitel* wurde nach *Ziel-Stapel* verschoben“, darunter Board und Quell-Stapel. Ein Klick öffnet die Karte. Wird dieselbe Karte später noch einmal verschoben, ersetzt die neue Benachrichtigung die alte.
|
**Inhalt der Nextcloud-Benachrichtigung:** „*Kartentitel* wurde nach *Ziel-Stapel* verschoben“, darunter Board, Quell-Stapel und ob die Karte überfällig ist oder ihr Startdatum erreicht hat. Ein Klick öffnet die Karte. Wird dieselbe Karte später noch einmal verschoben, ersetzt die neue Benachrichtigung die alte.
|
||||||
|
|
||||||
**Keine E-Mail erhalten?**
|
**Keine E-Mail erhalten?**
|
||||||
- Ist in deinem Nextcloud-Profil eine E-Mail-Adresse hinterlegt? Ohne Adresse wird die E-Mail übersprungen.
|
- Ist in deinem Nextcloud-Profil eine E-Mail-Adresse hinterlegt? Ohne Adresse wird die E-Mail übersprungen.
|
||||||
@@ -88,7 +101,8 @@ Deck schickt zusätzlich seine eigene Erinnerung, wenn eine Karte fällig wird.
|
|||||||
|
|
||||||
- **In der Übersicht** wird der Workflow rot markiert, zum Beispiel „Board nicht mehr vorhanden“ oder „Quell-Stapel nicht mehr vorhanden“.
|
- **In der Übersicht** wird der Workflow rot markiert, zum Beispiel „Board nicht mehr vorhanden“ oder „Quell-Stapel nicht mehr vorhanden“.
|
||||||
- **Beim nächsten Durchlauf** wird der Workflow automatisch deaktiviert. Der Eigentümer bekommt einmalig eine E-Mail und, wenn die App *Benachrichtigungen* aktiv ist, eine Nextcloud-Benachrichtigung. Das gilt unabhängig davon, was für verschobene Karten eingestellt ist. Danach kommt keine weitere Erinnerung.
|
- **Beim nächsten Durchlauf** wird der Workflow automatisch deaktiviert. Der Eigentümer bekommt einmalig eine E-Mail und, wenn die App *Benachrichtigungen* aktiv ist, eine Nextcloud-Benachrichtigung. Das gilt unabhängig davon, was für verschobene Karten eingestellt ist. Danach kommt keine weitere Erinnerung.
|
||||||
- **Archivierte Boards** deaktivieren den Workflow nicht. Sie lassen sich nur nicht mehr neu auswählen.
|
- **Schleifen aus älteren Workflows** (gespeichert vor der Schleifenprüfung) werden in der Übersicht rot markiert und beim Durchlauf übersprungen, bis du einen der Workflows änderst oder deaktivierst.
|
||||||
|
- **Archivierte Boards** deaktivieren den Workflow nicht. Er bleibt gespeichert, wird aber in der Übersicht markiert („Board archiviert, wird übersprungen“) und verschiebt nichts, bis du das Board in Deck aus dem Archiv holst. Archivierte Boards lassen sich auch nicht auswählen. Beim Bearbeiten eines solchen Workflows musst du ein anderes Board wählen.
|
||||||
- **Ist Deck vorübergehend nicht erreichbar**, passiert nichts: Der Workflow wird übersprungen und beim nächsten Durchlauf erneut versucht.
|
- **Ist Deck vorübergehend nicht erreichbar**, passiert nichts: Der Workflow wird übersprungen und beim nächsten Durchlauf erneut versucht.
|
||||||
|
|
||||||
Um einen deaktivierten Workflow wieder zu nutzen, stellst du ein vorhandenes Board und vorhandene Stapel ein und aktivierst ihn wieder.
|
Um einen deaktivierten Workflow wieder zu nutzen, stellst du ein vorhandenes Board und vorhandene Stapel ein und aktivierst ihn wieder.
|
||||||
|
|||||||
+9
-7
@@ -4,27 +4,29 @@
|
|||||||
<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 überfällige Deck-Karten automatisch anhand konfigurierbarer Regeln</summary>
|
<summary lang="de">Verschiebt Deck-Karten automatisch, sobald ihr Fälligkeits- oder Startdatum erreicht ist</summary>
|
||||||
<summary lang="en">Automatically moves overdue Deck cards based on configurable rules</summary>
|
<summary lang="en">Automatically moves Deck cards once their due date or start date is reached</summary>
|
||||||
<description lang="de"><![CDATA[
|
<description lang="de"><![CDATA[
|
||||||
Diese App erlaubt es jedem Nutzer, in den persönlichen Einstellungen Automatisierungs-Workflows für seine eigenen Deck-Boards anzulegen:
|
Diese App erlaubt es jedem Nutzer, in den persönlichen Einstellungen Automatisierungs-Workflows für seine eigenen Deck-Boards anzulegen:
|
||||||
|
|
||||||
* Quell-Board und Quell-Stapel wählen
|
* 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
|
||||||
|
* wählen, ob das Fälligkeitsdatum oder das Startdatum der Karte das Verschieben auslöst
|
||||||
* optional benachrichtigen, per E-Mail und/oder als Nextcloud-Benachrichtigung (Push): nur sich selbst oder alle Board-Mitglieder gemäß ihrer Deck-Benachrichtigungseinstellung
|
* optional benachrichtigen, per E-Mail und/oder als Nextcloud-Benachrichtigung (Push): nur sich selbst oder alle Board-Mitglieder gemäß ihrer Deck-Benachrichtigungseinstellung
|
||||||
|
|
||||||
Ein Hintergrundjob prüft regelmäßig alle aktiven Workflows und verschiebt Karten, deren Fälligkeitsdatum überschritten wurde.
|
Ein Hintergrundjob prüft regelmäßig alle aktiven Workflows und verschiebt Karten, deren Fälligkeits- bzw. Startdatum erreicht wurde.
|
||||||
]]></description>
|
]]></description>
|
||||||
<description lang="en"><![CDATA[
|
<description lang="en"><![CDATA[
|
||||||
This app lets every user configure Deck automation workflows for the boards they own from Personal settings:
|
This app lets every user configure Deck automation workflows for the boards they own from Personal settings:
|
||||||
|
|
||||||
* pick a source board and source stack
|
* 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
|
||||||
|
* choose whether the card's due date or start date triggers the move
|
||||||
* optionally notify by email and/or Nextcloud notification (push): themselves only, or every board member according to their Deck notification setting
|
* optionally notify by email and/or Nextcloud notification (push): themselves only, or every board member according to their Deck notification setting
|
||||||
|
|
||||||
A background job periodically evaluates all enabled workflows and moves cards whose due date has passed.
|
A background job periodically evaluates all enabled workflows and moves cards whose due date or start date has been reached.
|
||||||
]]></description>
|
]]></description>
|
||||||
<version>35.3.0</version>
|
<version>35.4.0</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>
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ use OCA\WorkflowDeckAutomation\Db\Workflow;
|
|||||||
use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
|
use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
|
||||||
use OCA\WorkflowDeckAutomation\Service\DeckIntegrationService;
|
use OCA\WorkflowDeckAutomation\Service\DeckIntegrationService;
|
||||||
use OCA\WorkflowDeckAutomation\Service\DeckUnavailableException;
|
use OCA\WorkflowDeckAutomation\Service\DeckUnavailableException;
|
||||||
|
use OCA\WorkflowDeckAutomation\Service\LoopDetector;
|
||||||
use OCA\WorkflowDeckAutomation\Service\NotificationPolicy;
|
use OCA\WorkflowDeckAutomation\Service\NotificationPolicy;
|
||||||
|
use OCA\WorkflowDeckAutomation\Service\TriggerDate;
|
||||||
use OCP\AppFramework\Db\DoesNotExistException;
|
use OCP\AppFramework\Db\DoesNotExistException;
|
||||||
use OCP\AppFramework\Http;
|
use OCP\AppFramework\Http;
|
||||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||||
@@ -47,7 +49,17 @@ class WorkflowController extends OCSController {
|
|||||||
#[NoAdminRequired]
|
#[NoAdminRequired]
|
||||||
public function index(): DataResponse {
|
public function index(): DataResponse {
|
||||||
$workflows = $this->workflowMapper->findAllForBoards($this->ownedBoardIds());
|
$workflows = $this->workflowMapper->findAllForBoards($this->ownedBoardIds());
|
||||||
return new DataResponse(array_map(static fn (Workflow $w) => $w->jsonSerialize(), $workflows));
|
|
||||||
|
// validate() keeps new loops out, but workflows stored before that
|
||||||
|
// check can still form one; the runner skips them, the list says why.
|
||||||
|
$looping = LoopDetector::findLoopingWorkflows(LoopDetector::edgesOf(
|
||||||
|
array_filter($workflows, static fn (Workflow $w) => (bool)$w->getEnabled()),
|
||||||
|
));
|
||||||
|
|
||||||
|
return new DataResponse(array_map(
|
||||||
|
static fn (Workflow $w) => $w->jsonSerialize() + ['loop' => in_array((int)$w->getId(), $looping, true)],
|
||||||
|
$workflows,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[NoAdminRequired]
|
#[NoAdminRequired]
|
||||||
@@ -56,15 +68,16 @@ class WorkflowController extends OCSController {
|
|||||||
int $boardId,
|
int $boardId,
|
||||||
int $sourceStackId,
|
int $sourceStackId,
|
||||||
int $targetStackId,
|
int $targetStackId,
|
||||||
|
string $triggerDate = TriggerDate::DUE,
|
||||||
string $notifyMode = NotificationPolicy::MODE_OFF,
|
string $notifyMode = NotificationPolicy::MODE_OFF,
|
||||||
string $notifyChannel = NotificationPolicy::CHANNEL_MAIL,
|
string $notifyChannel = NotificationPolicy::CHANNEL_MAIL,
|
||||||
bool $enabled = true,
|
bool $enabled = true,
|
||||||
): DataResponse {
|
): DataResponse {
|
||||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel);
|
$this->validate(null, $title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel, $enabled);
|
||||||
|
|
||||||
$workflow = new Workflow();
|
$workflow = new Workflow();
|
||||||
$workflow->setUserId($this->currentUserId());
|
$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);
|
$workflow = $this->workflowMapper->insert($workflow);
|
||||||
return new DataResponse($workflow->jsonSerialize(), Http::STATUS_CREATED);
|
return new DataResponse($workflow->jsonSerialize(), Http::STATUS_CREATED);
|
||||||
@@ -77,17 +90,18 @@ class WorkflowController extends OCSController {
|
|||||||
int $boardId,
|
int $boardId,
|
||||||
int $sourceStackId,
|
int $sourceStackId,
|
||||||
int $targetStackId,
|
int $targetStackId,
|
||||||
|
string $triggerDate = TriggerDate::DUE,
|
||||||
string $notifyMode = NotificationPolicy::MODE_OFF,
|
string $notifyMode = NotificationPolicy::MODE_OFF,
|
||||||
string $notifyChannel = NotificationPolicy::CHANNEL_MAIL,
|
string $notifyChannel = NotificationPolicy::CHANNEL_MAIL,
|
||||||
bool $enabled = true,
|
bool $enabled = true,
|
||||||
): DataResponse {
|
): DataResponse {
|
||||||
$workflow = $this->findOwnWorkflow($id);
|
$workflow = $this->findOwnWorkflow($id);
|
||||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel);
|
$this->validate($id, $title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel, $enabled);
|
||||||
|
|
||||||
// Kept in step with the board owner so the job runs as, and mails,
|
// 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.
|
// the right user; validate() made sure the editor owns the board.
|
||||||
$workflow->setUserId($this->currentUserId());
|
$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);
|
$workflow = $this->workflowMapper->update($workflow);
|
||||||
|
|
||||||
return new DataResponse($workflow->jsonSerialize());
|
return new DataResponse($workflow->jsonSerialize());
|
||||||
@@ -158,13 +172,19 @@ class WorkflowController extends OCSController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId, string $notifyMode, string $notifyChannel): void {
|
/**
|
||||||
|
* @param ?int $id the workflow being changed, `null` for a new one
|
||||||
|
*/
|
||||||
|
private function validate(?int $id, string $title, int $boardId, int $sourceStackId, int $targetStackId, string $triggerDate, string $notifyMode, string $notifyChannel, bool $enabled): void {
|
||||||
if (trim($title) === '') {
|
if (trim($title) === '') {
|
||||||
throw new OCSBadRequestException('Bitte einen Titel angeben.');
|
throw new OCSBadRequestException('Bitte einen Titel angeben.');
|
||||||
}
|
}
|
||||||
if ($sourceStackId === $targetStackId) {
|
if ($sourceStackId === $targetStackId) {
|
||||||
throw new OCSBadRequestException('Quell- und Ziel-Stapel müssen sich unterscheiden.');
|
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)) {
|
if (!NotificationPolicy::isValidMode($notifyMode)) {
|
||||||
throw new OCSBadRequestException('Ungültige Benachrichtigungs-Einstellung, erlaubt sind: ' . implode(', ', NotificationPolicy::MODES));
|
throw new OCSBadRequestException('Ungültige Benachrichtigungs-Einstellung, erlaubt sind: ' . implode(', ', NotificationPolicy::MODES));
|
||||||
}
|
}
|
||||||
@@ -177,6 +197,9 @@ class WorkflowController extends OCSController {
|
|||||||
// assertDeck() ran first, so an empty list here means "not there",
|
// assertDeck() ran first, so an empty list here means "not there",
|
||||||
// not "Deck is unreachable".
|
// not "Deck is unreachable".
|
||||||
$this->assertDeck();
|
$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
|
// Only boards the user owns are listed, so this also enforces the
|
||||||
// owner rule. Archived boards stay listed (their workflows must stay
|
// owner rule. Archived boards stay listed (their workflows must stay
|
||||||
@@ -199,6 +222,37 @@ class WorkflowController extends OCSController {
|
|||||||
if (!in_array($sourceStackId, $stackIds, true) || !in_array($targetStackId, $stackIds, true)) {
|
if (!in_array($sourceStackId, $stackIds, true) || !in_array($targetStackId, $stackIds, true)) {
|
||||||
throw new OCSBadRequestException('Der gewählte Stapel existiert auf diesem Board nicht.');
|
throw new OCSBadRequestException('Der gewählte Stapel existiert auf diesem Board nicht.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A disabled workflow moves nothing and can close no loop; it is
|
||||||
|
// checked again when it gets switched on, which also goes through here.
|
||||||
|
if ($enabled) {
|
||||||
|
$this->assertNoLoop($id, $boardId, $sourceStackId, $targetStackId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects a workflow whose target stack leads back to its source through
|
||||||
|
* the board's other enabled workflows — cards would circle forever. See
|
||||||
|
* LoopDetector.
|
||||||
|
*/
|
||||||
|
private function assertNoLoop(?int $id, int $boardId, int $sourceStackId, int $targetStackId): void {
|
||||||
|
$others = [];
|
||||||
|
foreach ($this->workflowMapper->findAllForBoards([$boardId]) as $other) {
|
||||||
|
if ($other->getId() !== $id && $other->getEnabled()) {
|
||||||
|
$others[(int)$other->getId()] = $other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = LoopDetector::findPath(LoopDetector::edgesOf($others), $targetStackId, $sourceStackId);
|
||||||
|
if ($path === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$titles = array_map(static fn (int $otherId) => '„' . $others[$otherId]->getTitle() . '“', $path);
|
||||||
|
throw new OCSBadRequestException(sprintf(
|
||||||
|
'Zusammen mit %s würde dieser Workflow eine Schleife bilden, in der Karten immer wieder hin- und hergeschoben werden. Wähle einen anderen Ziel-Stapel oder deaktiviere einen der Workflows.',
|
||||||
|
implode(', ', $titles),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyFields(
|
private function applyFields(
|
||||||
@@ -207,6 +261,7 @@ class WorkflowController extends OCSController {
|
|||||||
int $boardId,
|
int $boardId,
|
||||||
int $sourceStackId,
|
int $sourceStackId,
|
||||||
int $targetStackId,
|
int $targetStackId,
|
||||||
|
string $triggerDate,
|
||||||
string $notifyMode,
|
string $notifyMode,
|
||||||
string $notifyChannel,
|
string $notifyChannel,
|
||||||
bool $enabled,
|
bool $enabled,
|
||||||
@@ -215,6 +270,7 @@ class WorkflowController extends OCSController {
|
|||||||
$workflow->setBoardId($boardId);
|
$workflow->setBoardId($boardId);
|
||||||
$workflow->setSourceStackId($sourceStackId);
|
$workflow->setSourceStackId($sourceStackId);
|
||||||
$workflow->setTargetStackId($targetStackId);
|
$workflow->setTargetStackId($targetStackId);
|
||||||
|
$workflow->setTriggerDate($triggerDate);
|
||||||
$workflow->setNotifyMode($notifyMode);
|
$workflow->setNotifyMode($notifyMode);
|
||||||
$workflow->setNotifyChannel($notifyChannel);
|
$workflow->setNotifyChannel($notifyChannel);
|
||||||
$workflow->setEnabled($enabled);
|
$workflow->setEnabled($enabled);
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ use OCP\DB\Types;
|
|||||||
* @method void setSourceStackId(int $sourceStackId)
|
* @method void setSourceStackId(int $sourceStackId)
|
||||||
* @method int getTargetStackId()
|
* @method int getTargetStackId()
|
||||||
* @method void setTargetStackId(int $targetStackId)
|
* @method void setTargetStackId(int $targetStackId)
|
||||||
|
* @method string getTriggerDate()
|
||||||
|
* @method void setTriggerDate(string $triggerDate)
|
||||||
* @method string getNotifyMode()
|
* @method string getNotifyMode()
|
||||||
* @method void setNotifyMode(string $notifyMode)
|
* @method void setNotifyMode(string $notifyMode)
|
||||||
* @method string getNotifyChannel()
|
* @method string getNotifyChannel()
|
||||||
@@ -33,6 +35,7 @@ class Workflow extends Entity implements \JsonSerializable {
|
|||||||
protected $boardId;
|
protected $boardId;
|
||||||
protected $sourceStackId;
|
protected $sourceStackId;
|
||||||
protected $targetStackId;
|
protected $targetStackId;
|
||||||
|
protected $triggerDate;
|
||||||
protected $notifyMode;
|
protected $notifyMode;
|
||||||
protected $notifyChannel;
|
protected $notifyChannel;
|
||||||
protected $enabled;
|
protected $enabled;
|
||||||
@@ -45,6 +48,7 @@ class Workflow extends Entity implements \JsonSerializable {
|
|||||||
$this->addType('boardId', Types::INTEGER);
|
$this->addType('boardId', Types::INTEGER);
|
||||||
$this->addType('sourceStackId', Types::INTEGER);
|
$this->addType('sourceStackId', Types::INTEGER);
|
||||||
$this->addType('targetStackId', Types::INTEGER);
|
$this->addType('targetStackId', Types::INTEGER);
|
||||||
|
$this->addType('triggerDate', Types::STRING);
|
||||||
$this->addType('notifyMode', Types::STRING);
|
$this->addType('notifyMode', Types::STRING);
|
||||||
$this->addType('notifyChannel', Types::STRING);
|
$this->addType('notifyChannel', Types::STRING);
|
||||||
$this->addType('enabled', Types::BOOLEAN);
|
$this->addType('enabled', Types::BOOLEAN);
|
||||||
@@ -59,6 +63,7 @@ class Workflow extends Entity implements \JsonSerializable {
|
|||||||
'boardId' => $this->getBoardId(),
|
'boardId' => $this->getBoardId(),
|
||||||
'sourceStackId' => $this->getSourceStackId(),
|
'sourceStackId' => $this->getSourceStackId(),
|
||||||
'targetStackId' => $this->getTargetStackId(),
|
'targetStackId' => $this->getTargetStackId(),
|
||||||
|
'triggerDate' => (string)$this->getTriggerDate(),
|
||||||
'notifyMode' => (string)$this->getNotifyMode(),
|
'notifyMode' => (string)$this->getNotifyMode(),
|
||||||
'notifyChannel' => (string)$this->getNotifyChannel(),
|
'notifyChannel' => (string)$this->getNotifyChannel(),
|
||||||
'enabled' => (bool)$this->getEnabled(),
|
'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,6 +7,7 @@ namespace OCA\WorkflowDeckAutomation\Notification;
|
|||||||
use OCA\WorkflowDeckAutomation\AppInfo\Application;
|
use OCA\WorkflowDeckAutomation\AppInfo\Application;
|
||||||
use OCA\WorkflowDeckAutomation\Service\NotificationMailer;
|
use OCA\WorkflowDeckAutomation\Service\NotificationMailer;
|
||||||
use OCA\WorkflowDeckAutomation\Service\NotificationPusher;
|
use OCA\WorkflowDeckAutomation\Service\NotificationPusher;
|
||||||
|
use OCA\WorkflowDeckAutomation\Service\TriggerDate;
|
||||||
use OCP\IURLGenerator;
|
use OCP\IURLGenerator;
|
||||||
use OCP\Notification\INotification;
|
use OCP\Notification\INotification;
|
||||||
use OCP\Notification\INotifier;
|
use OCP\Notification\INotifier;
|
||||||
@@ -95,7 +96,12 @@ class Notifier implements INotifier {
|
|||||||
self::setText(
|
self::setText(
|
||||||
$notification,
|
$notification,
|
||||||
'{deck-card} wurde nach {target-stack} verschoben',
|
'{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,
|
$objects,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,35 @@ 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a Deck board or card sits in Deck's trash (`deleted_at > 0`).
|
||||||
|
*
|
||||||
|
* Read through property_exists(), never method_exists(): Entity getters
|
||||||
|
* are magic (`__call`), so method_exists() is always false for them —
|
||||||
|
* which is how trashed cards used to be moved and trashed boards went
|
||||||
|
* unnoticed.
|
||||||
|
*/
|
||||||
|
private static function isTrashed(object $entity): bool {
|
||||||
|
return property_exists($entity, 'deletedAt') && (int)$entity->getDeletedAt() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a Deck board is archived. Same magic-getter caveat as
|
||||||
|
* isTrashed().
|
||||||
|
*/
|
||||||
|
private static function isArchived(object $entity): bool {
|
||||||
|
return property_exists($entity, 'archived') && (bool)$entity->getArchived();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The boards `$userId` owns — the only boards workflows may live on, and
|
* The boards `$userId` owns — the only boards workflows may live on, and
|
||||||
* the boards whose workflows that user sees and manages.
|
* the boards whose workflows that user sees and manages.
|
||||||
@@ -172,7 +201,8 @@ class DeckIntegrationService {
|
|||||||
* the frozen-uid state of BoardService::getUserBoards(). It does return
|
* the frozen-uid state of BoardService::getUserBoards(). It does return
|
||||||
* archived and trashed boards; trashed ones are dropped here, archived
|
* archived and trashed boards; trashed ones are dropped here, archived
|
||||||
* ones are kept but flagged — their workflows still exist and must stay
|
* ones are kept but flagged — their workflows still exist and must stay
|
||||||
* visible, they just aren't offered as a new target.
|
* visible, but the board is no target for a new or changed workflow, and
|
||||||
|
* the runner skips it.
|
||||||
*
|
*
|
||||||
* Throws instead of degrading to `[]`: an empty list would read as "you
|
* Throws instead of degrading to `[]`: an empty list would read as "you
|
||||||
* own no boards" and hide every workflow the user has.
|
* own no boards" and hide every workflow the user has.
|
||||||
@@ -189,7 +219,7 @@ class DeckIntegrationService {
|
|||||||
|
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach ($boards as $board) {
|
foreach ($boards as $board) {
|
||||||
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
|
if (self::isTrashed($board)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$id = (int)$board->getId();
|
$id = (int)$board->getId();
|
||||||
@@ -197,7 +227,7 @@ class DeckIntegrationService {
|
|||||||
$result[] = [
|
$result[] = [
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'title' => (is_string($title) && $title !== '') ? $title : ('#' . $id),
|
'title' => (is_string($title) && $title !== '') ? $title : ('#' . $id),
|
||||||
'archived' => method_exists($board, 'getArchived') && (bool)$board->getArchived(),
|
'archived' => self::isArchived($board),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +274,7 @@ class DeckIntegrationService {
|
|||||||
public function findStackIds(int $boardId, string $userId): ?array {
|
public function findStackIds(int $boardId, string $userId): ?array {
|
||||||
try {
|
try {
|
||||||
$board = $this->resolve(BoardMapper::class)->find($boardId);
|
$board = $this->resolve(BoardMapper::class)->find($boardId);
|
||||||
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
|
if (self::isTrashed($board)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +304,31 @@ class DeckIntegrationService {
|
|||||||
return array_map(static fn ($stack) => (int)$stack->getId(), $stacks);
|
return array_map(static fn ($stack) => (int)$stack->getId(), $stacks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the board is archived — workflows on archived boards stay
|
||||||
|
* stored and enabled but move nothing until the board is unarchived.
|
||||||
|
*
|
||||||
|
* Read through BoardMapper like findBoardOwner(). `false` if the board
|
||||||
|
* row is gone, so findBrokenTarget() still gets to disable the workflow;
|
||||||
|
* every other failure throws.
|
||||||
|
*
|
||||||
|
* @throws DeckUnavailableException
|
||||||
|
*/
|
||||||
|
public function isBoardArchived(int $boardId): bool {
|
||||||
|
try {
|
||||||
|
$board = $this->resolve(BoardMapper::class)->find($boardId);
|
||||||
|
} catch (DeckUnavailableException $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($this->isMissing($e)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw new DeckUnavailableException('Could not read board ' . $boardId . ': ' . $e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::isArchived($board);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The uid of the board's current owner — the user a workflow on that
|
* The uid of the board's current owner — the user a workflow on that
|
||||||
* board belongs to. Workflows follow the board: when Deck transfers its
|
* board belongs to. Workflows follow the board: when Deck transfers its
|
||||||
@@ -453,7 +508,7 @@ class DeckIntegrationService {
|
|||||||
$cards = $this->resolve(CardMapper::class)->findAllByStack($stackId);
|
$cards = $this->resolve(CardMapper::class)->findAllByStack($stackId);
|
||||||
|
|
||||||
return array_values(array_filter($cards, static function (Card $card) {
|
return array_values(array_filter($cards, static function (Card $card) {
|
||||||
if (method_exists($card, 'getDeletedAt') && (int)$card->getDeletedAt() > 0) {
|
if (self::isTrashed($card)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return !$card->getArchived() && $card->getDone() === null;
|
return !$card->getArchived() && $card->getDone() === null;
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\WorkflowDeckAutomation\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds workflows that would hand cards around in a circle — pure logic, no
|
||||||
|
* Nextcloud or Deck dependencies, so it is unit-testable like
|
||||||
|
* NotificationPolicy.
|
||||||
|
*
|
||||||
|
* Every enabled workflow is an edge "source stack → target stack". Two
|
||||||
|
* workflows S1 → S2 and S2 → S1 move a card back and forth on every run,
|
||||||
|
* and since the runner reads each source stack afresh, even within a single
|
||||||
|
* run — with a notice each time. The trigger date is deliberately ignored:
|
||||||
|
* with S1 → S2 by start date and S2 → S1 by due date, every card whose two
|
||||||
|
* dates have both passed still circles. A workflow whose source is its own
|
||||||
|
* target is the shortest such loop.
|
||||||
|
*
|
||||||
|
* Deck's stack ids are unique across boards, so edges from several boards
|
||||||
|
* can be passed in together without mixing anything up.
|
||||||
|
*/
|
||||||
|
class LoopDetector {
|
||||||
|
/**
|
||||||
|
* The edges for findPath() and findLoopingWorkflows(). Typed only in the
|
||||||
|
* docblock, so the tests need no Nextcloud classes.
|
||||||
|
*
|
||||||
|
* @param iterable<\OCA\WorkflowDeckAutomation\Db\Workflow> $workflows
|
||||||
|
* @return array<int, array{0: int, 1: int}>
|
||||||
|
*/
|
||||||
|
public static function edgesOf(iterable $workflows): array {
|
||||||
|
$edges = [];
|
||||||
|
foreach ($workflows as $workflow) {
|
||||||
|
$edges[(int)$workflow->getId()] = [(int)$workflow->getSourceStackId(), (int)$workflow->getTargetStackId()];
|
||||||
|
}
|
||||||
|
return $edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workflows leading from stack $from to stack $to, in order, or
|
||||||
|
* `null` if there is no way. `[]` if $from already is $to.
|
||||||
|
*
|
||||||
|
* @param array<int, array{0: int, 1: int}> $edges workflow id => [source stack id, target stack id]
|
||||||
|
* @return list<int>|null
|
||||||
|
*/
|
||||||
|
public static function findPath(array $edges, int $from, int $to): ?array {
|
||||||
|
if ($from === $to) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breadth-first, remembering how each stack was reached.
|
||||||
|
$reachedBy = [$from => null];
|
||||||
|
$queue = [$from];
|
||||||
|
while ($queue !== []) {
|
||||||
|
$stack = array_shift($queue);
|
||||||
|
foreach ($edges as $id => [$source, $target]) {
|
||||||
|
if ($source !== $stack || array_key_exists($target, $reachedBy)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$reachedBy[$target] = [$id, $stack];
|
||||||
|
if ($target === $to) {
|
||||||
|
$path = [];
|
||||||
|
for ($at = $to; $reachedBy[$at] !== null; $at = $reachedBy[$at][1]) {
|
||||||
|
array_unshift($path, $reachedBy[$at][0]);
|
||||||
|
}
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
$queue[] = $target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids of the workflows that lie on a loop: those whose target leads back
|
||||||
|
* to their own source.
|
||||||
|
*
|
||||||
|
* @param array<int, array{0: int, 1: int}> $edges workflow id => [source stack id, target stack id]
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public static function findLoopingWorkflows(array $edges): array {
|
||||||
|
$looping = [];
|
||||||
|
foreach ($edges as $id => [$source, $target]) {
|
||||||
|
if (self::findPath($edges, $target, $source) !== null) {
|
||||||
|
$looping[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $looping;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,9 +99,10 @@ class NotificationMailer {
|
|||||||
*/
|
*/
|
||||||
private function addCard(IEMailTemplate $template, Workflow $workflow, Card $card, array $targets): void {
|
private function addCard(IEMailTemplate $template, Workflow $workflow, Card $card, array $targets): void {
|
||||||
// The card description and the workflow's name are deliberately left
|
// The card description and the workflow's name are deliberately left
|
||||||
// out — the mail only says what moved where. The card title itself is
|
// out — the mail only says what moved where, and why. The card title
|
||||||
// the link to the card.
|
// 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.';
|
$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());
|
$url = $this->urlGenerator->getAbsoluteURL('/apps/deck/board/' . $workflow->getBoardId() . '/card/' . $card->getId());
|
||||||
|
|
||||||
// With both parts passed, addBodyText() escapes nothing: every title
|
// With both parts passed, addBodyText() escapes nothing: every title
|
||||||
@@ -112,6 +113,7 @@ class NotificationMailer {
|
|||||||
self::escape($targets['board']),
|
self::escape($targets['board']),
|
||||||
self::escape($targets['sourceStack']),
|
self::escape($targets['sourceStack']),
|
||||||
self::escape($targets['targetStack']),
|
self::escape($targets['targetStack']),
|
||||||
|
self::escape($reason),
|
||||||
);
|
);
|
||||||
$plain = sprintf(
|
$plain = sprintf(
|
||||||
$sentence,
|
$sentence,
|
||||||
@@ -119,6 +121,7 @@ class NotificationMailer {
|
|||||||
$targets['board'],
|
$targets['board'],
|
||||||
$targets['sourceStack'],
|
$targets['sourceStack'],
|
||||||
$targets['targetStack'],
|
$targets['targetStack'],
|
||||||
|
$reason,
|
||||||
) . PHP_EOL . $url;
|
) . PHP_EOL . $url;
|
||||||
|
|
||||||
$template->addBodyText($html, $plain);
|
$template->addBodyText($html, $plain);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace OCA\WorkflowDeckAutomation\Service;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Who hears about a moved card, and how — pure logic, no Nextcloud or Deck
|
* 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 {
|
class NotificationPolicy {
|
||||||
/** No mail at all. */
|
/** No mail at all. */
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class NotificationPusher {
|
|||||||
'sourceStackTitle' => $targets['sourceStack'],
|
'sourceStackTitle' => $targets['sourceStack'],
|
||||||
'targetStackId' => $workflow->getTargetStackId(),
|
'targetStackId' => $workflow->getTargetStackId(),
|
||||||
'targetStackTitle' => $targets['targetStack'],
|
'targetStackTitle' => $targets['targetStack'],
|
||||||
|
'triggerDate' => (string)$workflow->getTriggerDate(),
|
||||||
]);
|
]);
|
||||||
$this->notificationManager->notify($notification);
|
$this->notificationManager->notify($notification);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,8 @@ use Psr\Log\LoggerInterface;
|
|||||||
use Throwable;
|
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
|
* 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
|
||||||
@@ -66,11 +67,25 @@ class WorkflowRunner {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The controller refuses to store a loop, but workflows saved before
|
||||||
|
// that check — including ones whose source is their own target — can
|
||||||
|
// still form one. They would move the same cards on every run, with a
|
||||||
|
// notice each time, so they are left out until the user fixes them;
|
||||||
|
// the settings page marks them.
|
||||||
|
$looping = LoopDetector::findLoopingWorkflows(LoopDetector::edgesOf($workflows));
|
||||||
|
|
||||||
$this->pendingNotices = [];
|
$this->pendingNotices = [];
|
||||||
$byUser = [];
|
$byUser = [];
|
||||||
$owners = [];
|
$owners = [];
|
||||||
foreach ($workflows as $workflow) {
|
foreach ($workflows as $workflow) {
|
||||||
$this->followBoardOwner($workflow, $owners);
|
$this->followBoardOwner($workflow, $owners);
|
||||||
|
if (in_array((int)$workflow->getId(), $looping, true)) {
|
||||||
|
$this->logger->warning('Skipping workflow {id}: it moves cards in a loop together with other workflows', [
|
||||||
|
'app' => 'workflow_deck_automation',
|
||||||
|
'id' => $workflow->getId(),
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$byUser[$workflow->getUserId()][] = $workflow;
|
$byUser[$workflow->getUserId()][] = $workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +229,25 @@ class WorkflowRunner {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An archived board is intact, just shelved: the workflow is skipped,
|
||||||
|
// not disabled, and resumes once the board is unarchived.
|
||||||
|
try {
|
||||||
|
$archived = $this->deckService->isBoardArchived($workflow->getBoardId());
|
||||||
|
} catch (DeckUnavailableException $e) {
|
||||||
|
$this->logger->warning('Could not check whether the board of workflow {id} is archived: ' . $e->getMessage(), [
|
||||||
|
'app' => 'workflow_deck_automation',
|
||||||
|
'id' => $workflow->getId(),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($archived) {
|
||||||
|
$this->logger->debug('Skipping workflow {id}: its board is archived', [
|
||||||
|
'app' => 'workflow_deck_automation',
|
||||||
|
'id' => $workflow->getId(),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$cards = $this->deckService->getActiveCardsInStack($workflow->getSourceStackId());
|
$cards = $this->deckService->getActiveCardsInStack($workflow->getSourceStackId());
|
||||||
} catch (DeckUnavailableException $e) {
|
} catch (DeckUnavailableException $e) {
|
||||||
@@ -224,11 +258,24 @@ class WorkflowRunner {
|
|||||||
return;
|
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 = [];
|
$moved = [];
|
||||||
$now = $this->timeFactory->getDateTime();
|
$now = $this->timeFactory->getDateTime();
|
||||||
|
|
||||||
foreach ($cards as $card) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +471,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
|
* 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
|
||||||
@@ -432,30 +482,31 @@ class WorkflowRunner {
|
|||||||
* 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.
|
* day-rounding step to get wrong.
|
||||||
*
|
*
|
||||||
* Both sides are compared as absolute instants, and the due date is first
|
* Both sides are compared as absolute instants, and the card's date is
|
||||||
* pinned to UTC via asStoredUtc() — see there for why.
|
* first pinned to UTC via asStoredUtc() — see there for why.
|
||||||
*/
|
*/
|
||||||
public static function isOverdue(?DateTimeInterface $duedate, DateTimeInterface $now): bool {
|
public static function hasPassed(?DateTimeInterface $date, DateTimeInterface $now): bool {
|
||||||
return $duedate !== null && self::asStoredUtc($duedate) < $now;
|
return $date !== null && self::asStoredUtc($date) < $now;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deck's `duedate` is a DATETIME column without a zone that holds UTC
|
* Deck's `duedate` and `startdate` are DATETIME columns without a zone
|
||||||
* wall-clock time: Deck's frontend sends `toISOString()`, and the value is
|
* that hold UTC wall-clock time: Deck's frontend sends `toISOString()`,
|
||||||
* written as plain `Y-m-d H:i:s`. Entity hydrates it with
|
* and the value is written as plain `Y-m-d H:i:s`. Entity hydrates it
|
||||||
* `new \DateTime($string)`, i.e. in PHP's *default* timezone — correct only
|
* with `new \DateTime($string)`, i.e. in PHP's *default* timezone —
|
||||||
* because Nextcloud sets that default to UTC during bootstrap. Reading the
|
* correct only because Nextcloud sets that default to UTC during
|
||||||
* wall clock and pinning it to UTC explicitly makes the comparison
|
* bootstrap. Reading the wall clock and pinning it to UTC explicitly
|
||||||
* independent of that default, so a server or CLI running with a local
|
* makes the comparison independent of that default, so a server or CLI
|
||||||
* default zone cannot shift every due date by its UTC offset.
|
* running with a local default zone cannot shift every date by its UTC
|
||||||
|
* offset.
|
||||||
*
|
*
|
||||||
* Deck itself has two timezone-sensitive paths this deliberately avoids:
|
* 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
|
* CardMapper::findOverdue(), which compares the UTC column against the
|
||||||
* database's `NOW()` — the DB session's local time.
|
* database's `NOW()` — the DB session's local time.
|
||||||
*/
|
*/
|
||||||
private static function asStoredUtc(DateTimeInterface $duedate): DateTimeImmutable {
|
private static function asStoredUtc(DateTimeInterface $date): DateTimeImmutable {
|
||||||
return new DateTimeImmutable($duedate->format('Y-m-d H:i:s.u'), new DateTimeZone('UTC'));
|
return new DateTimeImmutable($date->format('Y-m-d H:i:s.u'), new DateTimeZone('UTC'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "workflow_deck_automation",
|
"name": "workflow_deck_automation",
|
||||||
"version": "35.3.0",
|
"version": "35.4.0",
|
||||||
"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.",
|
||||||
|
|||||||
+77
-11
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<NcSettingsSection
|
<NcSettingsSection
|
||||||
name="Deck Workflow-Automatisierung"
|
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 aus einem Deck-Stapel automatisch in einen anderen Stapel, sobald ihr Fälligkeits- oder Startdatum erreicht ist. 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>
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<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>Benachrichtigung</th>
|
<th>Benachrichtigung</th>
|
||||||
<th>Aktiv</th>
|
<th>Aktiv</th>
|
||||||
<th />
|
<th />
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
</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>{{ triggerLabel(workflow) }}</td>
|
||||||
<td>{{ notifyLabel(workflow) }}</td>
|
<td>{{ notifyLabel(workflow) }}</td>
|
||||||
<td>{{ yesNo(workflow.enabled) }}</td>
|
<td>{{ yesNo(workflow.enabled) }}</td>
|
||||||
<td class="wfda-actions">
|
<td class="wfda-actions">
|
||||||
@@ -68,6 +70,9 @@
|
|||||||
:reduce="option => option.value"
|
:reduce="option => option.value"
|
||||||
placeholder="Board wählen"
|
placeholder="Board wählen"
|
||||||
@update:model-value="onBoardChange" />
|
@update:model-value="onBoardChange" />
|
||||||
|
<NcNoteCard v-if="archivedBoardNote" type="warning">
|
||||||
|
{{ archivedBoardNote }}
|
||||||
|
</NcNoteCard>
|
||||||
<NcNoteCard v-if="!boardOptions.length" type="info">
|
<NcNoteCard v-if="!boardOptions.length" type="info">
|
||||||
Workflows können nur für Boards angelegt werden, deren Eigentümer du bist.
|
Workflows können nur für Boards angelegt werden, deren Eigentümer du bist.
|
||||||
</NcNoteCard>
|
</NcNoteCard>
|
||||||
@@ -88,6 +93,22 @@
|
|||||||
:disabled="!form.boardId"
|
:disabled="!form.boardId"
|
||||||
placeholder="Ziel-Stapel wählen" />
|
placeholder="Ziel-Stapel wählen" />
|
||||||
|
|
||||||
|
<fieldset class="wfda-notify">
|
||||||
|
<legend>Karte verschieben, wenn</legend>
|
||||||
|
<NcCheckboxRadioSwitch
|
||||||
|
v-for="option in triggerOptions"
|
||||||
|
:key="option.value"
|
||||||
|
v-model="form.triggerDate"
|
||||||
|
type="radio"
|
||||||
|
name="wfda-trigger-date"
|
||||||
|
:value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</NcCheckboxRadioSwitch>
|
||||||
|
<p class="wfda-hint">
|
||||||
|
Karten ohne das gewählte Datum bleiben liegen.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="wfda-notify">
|
<fieldset class="wfda-notify">
|
||||||
<legend>Benachrichtigung bei verschobenen Karten</legend>
|
<legend>Benachrichtigung bei verschobenen Karten</legend>
|
||||||
<NcCheckboxRadioSwitch
|
<NcCheckboxRadioSwitch
|
||||||
@@ -187,6 +208,8 @@ const showForm = ref(false)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const formError = ref('')
|
const formError = ref('')
|
||||||
const editingId = ref(null)
|
const editingId = ref(null)
|
||||||
|
// Shown when the workflow being edited sits on an archived board.
|
||||||
|
const archivedBoardNote = ref('')
|
||||||
|
|
||||||
const stackOptions = ref([])
|
const stackOptions = ref([])
|
||||||
// Which board the list above currently belongs to.
|
// Which board the list above currently belongs to.
|
||||||
@@ -195,15 +218,16 @@ const loadedBoardId = ref(null)
|
|||||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
|
||||||
// The backend only returns boards the user owns. Archived ones are in there
|
// The backend only returns boards the user owns. Archived ones are in there
|
||||||
// so their workflows keep a board name, but are no target for new ones.
|
// so their workflows keep a board name, but are never offered — not even
|
||||||
|
// when editing a workflow that sits on one, see editWorkflow().
|
||||||
const boardOptions = computed(() => boards.value
|
const boardOptions = computed(() => boards.value
|
||||||
.filter((board) => !board.archived || board.id === form.boardId)
|
.filter((board) => !board.archived)
|
||||||
.map((board) => ({ value: board.id, label: board.title }))
|
.map((board) => ({ value: board.id, label: board.title }))
|
||||||
.sort((a, b) => collator.compare(a.label, b.label)))
|
.sort((a, b) => collator.compare(a.label, b.label)))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alphabetical by the columns as displayed, left to right: board, title,
|
* Alphabetical by the columns as displayed, left to right: board, title,
|
||||||
* source stack, target stack, notification, active.
|
* source stack, target stack, trigger, notification, active.
|
||||||
*/
|
*/
|
||||||
const sortedWorkflows = computed(() => {
|
const sortedWorkflows = computed(() => {
|
||||||
const sortKeys = (workflow) => [
|
const sortKeys = (workflow) => [
|
||||||
@@ -211,6 +235,7 @@ const sortedWorkflows = computed(() => {
|
|||||||
workflow.title,
|
workflow.title,
|
||||||
stackLabel(workflow.boardId, workflow.sourceStackId),
|
stackLabel(workflow.boardId, workflow.sourceStackId),
|
||||||
stackLabel(workflow.boardId, workflow.targetStackId),
|
stackLabel(workflow.boardId, workflow.targetStackId),
|
||||||
|
triggerLabel(workflow),
|
||||||
notifyLabel(workflow),
|
notifyLabel(workflow),
|
||||||
yesNo(workflow.enabled),
|
yesNo(workflow.enabled),
|
||||||
]
|
]
|
||||||
@@ -235,15 +260,17 @@ const sourceStackOptions = computed(() => stackOptions.value.filter((option) =>
|
|||||||
const targetStackOptions = computed(() => stackOptions.value.filter((option) => option.value !== form.sourceStackId))
|
const targetStackOptions = computed(() => stackOptions.value.filter((option) => option.value !== form.sourceStackId))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workflows whose board or stacks were deleted in Deck, keyed by id. The
|
* Workflows that do not run as configured, keyed by id: board or stacks
|
||||||
* background job switches these off by itself, but a row that just stopped
|
* deleted in Deck (the background job switches these off by itself), board
|
||||||
|
* archived, or part of a loop (both skipped). A row that just stopped
|
||||||
* working needs to say so instead of looking healthy.
|
* working needs to say so instead of looking healthy.
|
||||||
*/
|
*/
|
||||||
const workflowIssues = computed(() => {
|
const workflowIssues = computed(() => {
|
||||||
const issues = {}
|
const issues = {}
|
||||||
|
|
||||||
for (const workflow of workflows.value) {
|
for (const workflow of workflows.value) {
|
||||||
if (!boards.value.some((board) => board.id === workflow.boardId)) {
|
const board = boards.value.find((entry) => entry.id === workflow.boardId)
|
||||||
|
if (!board) {
|
||||||
issues[workflow.id] = 'Board nicht mehr vorhanden'
|
issues[workflow.id] = 'Board nicht mehr vorhanden'
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -259,8 +286,24 @@ const workflowIssues = computed(() => {
|
|||||||
}
|
}
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
issues[workflow.id] = `${missing.join(' und ')} nicht mehr vorhanden`
|
issues[workflow.id] = `${missing.join(' und ')} nicht mehr vorhanden`
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not disabled, just skipped by the background job until the board
|
||||||
|
// is taken out of the archive again.
|
||||||
|
if (board.archived) {
|
||||||
|
issues[workflow.id] = 'Board archiviert, wird übersprungen'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set by the server for workflows stored before loops were refused;
|
||||||
|
// the background job skips them.
|
||||||
|
if (workflow.loop) {
|
||||||
|
issues[workflow.id] = workflow.sourceStackId === workflow.targetStackId
|
||||||
|
? 'Quell- und Ziel-Stapel sind gleich, wird übersprungen'
|
||||||
|
: 'Bildet mit anderen Workflows eine Schleife, wird übersprungen'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return issues
|
return issues
|
||||||
@@ -274,12 +317,19 @@ function emptyForm() {
|
|||||||
boardId: null,
|
boardId: null,
|
||||||
sourceStackId: null,
|
sourceStackId: null,
|
||||||
targetStackId: null,
|
targetStackId: null,
|
||||||
|
triggerDate: 'due',
|
||||||
notifyMode: 'off',
|
notifyMode: 'off',
|
||||||
notifyChannel: 'mail',
|
notifyChannel: 'mail',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Values match TriggerDate::ALL on the server.
|
||||||
|
const triggerOptions = [
|
||||||
|
{ value: 'due', label: 'das Fälligkeitsdatum überschritten ist (Karte ist überfällig)', short: 'Fälligkeit' },
|
||||||
|
{ value: 'start', label: 'das Startdatum erreicht ist', short: 'Startdatum' },
|
||||||
|
]
|
||||||
|
|
||||||
// Values match NotificationPolicy::MODES on the server.
|
// Values match NotificationPolicy::MODES on the server.
|
||||||
const notifyOptions = [
|
const notifyOptions = [
|
||||||
{ value: 'off', label: 'Keine Benachrichtigung', short: 'Keine' },
|
{ value: 'off', label: 'Keine Benachrichtigung', short: 'Keine' },
|
||||||
@@ -294,6 +344,10 @@ const channelOptions = [
|
|||||||
{ value: 'both', label: 'E-Mail und Nextcloud-Benachrichtigung', short: 'E-Mail + Push' },
|
{ value: 'both', label: 'E-Mail und Nextcloud-Benachrichtigung', short: 'E-Mail + Push' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function triggerLabel(workflow) {
|
||||||
|
return triggerOptions.find((option) => option.value === workflow.triggerDate)?.short ?? workflow.triggerDate
|
||||||
|
}
|
||||||
|
|
||||||
function notifyLabel(workflow) {
|
function notifyLabel(workflow) {
|
||||||
const mode = notifyOptions.find((option) => option.value === workflow.notifyMode)?.short ?? workflow.notifyMode
|
const mode = notifyOptions.find((option) => option.value === workflow.notifyMode)?.short ?? workflow.notifyMode
|
||||||
if (workflow.notifyMode === 'off') {
|
if (workflow.notifyMode === 'off') {
|
||||||
@@ -380,6 +434,9 @@ async function onBoardChange(boardId) {
|
|||||||
|
|
||||||
form.sourceStackId = null
|
form.sourceStackId = null
|
||||||
form.targetStackId = null
|
form.targetStackId = null
|
||||||
|
if (boardId) {
|
||||||
|
archivedBoardNote.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
await loadBoardOptions(boardId)
|
await loadBoardOptions(boardId)
|
||||||
}
|
}
|
||||||
@@ -388,16 +445,25 @@ function startCreate() {
|
|||||||
Object.assign(form, emptyForm())
|
Object.assign(form, emptyForm())
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
formError.value = ''
|
formError.value = ''
|
||||||
|
archivedBoardNote.value = ''
|
||||||
loadBoardOptions(null)
|
loadBoardOptions(null)
|
||||||
showForm.value = true
|
showForm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editWorkflow(workflow) {
|
async function editWorkflow(workflow) {
|
||||||
|
// An archived board is not offered and would be refused on save, so the
|
||||||
|
// form starts without it rather than holding an id no option matches.
|
||||||
|
const archivedBoard = boards.value.find((board) => board.id === workflow.boardId && board.archived)
|
||||||
|
archivedBoardNote.value = archivedBoard
|
||||||
|
? `Das Board „${archivedBoard.title}“ ist archiviert. Wähle ein anderes Board oder hole es in Deck aus dem Archiv.`
|
||||||
|
: ''
|
||||||
|
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
title: workflow.title,
|
title: workflow.title,
|
||||||
boardId: workflow.boardId,
|
boardId: archivedBoard ? null : workflow.boardId,
|
||||||
sourceStackId: workflow.sourceStackId,
|
sourceStackId: archivedBoard ? null : workflow.sourceStackId,
|
||||||
targetStackId: workflow.targetStackId,
|
targetStackId: archivedBoard ? null : workflow.targetStackId,
|
||||||
|
triggerDate: workflow.triggerDate || 'due',
|
||||||
notifyMode: workflow.notifyMode,
|
notifyMode: workflow.notifyMode,
|
||||||
notifyChannel: workflow.notifyChannel || 'mail',
|
notifyChannel: workflow.notifyChannel || 'mail',
|
||||||
enabled: workflow.enabled,
|
enabled: workflow.enabled,
|
||||||
@@ -405,7 +471,7 @@ async function editWorkflow(workflow) {
|
|||||||
editingId.value = workflow.id
|
editingId.value = workflow.id
|
||||||
formError.value = ''
|
formError.value = ''
|
||||||
showForm.value = true
|
showForm.value = true
|
||||||
await loadBoardOptions(workflow.boardId)
|
await loadBoardOptions(form.boardId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelForm() {
|
function cancelForm() {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\WorkflowDeckAutomation\Tests\Unit\Service;
|
||||||
|
|
||||||
|
use OCA\WorkflowDeckAutomation\Service\LoopDetector;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class LoopDetectorTest extends TestCase {
|
||||||
|
public function testNoWorkflowsHaveNoLoop(): void {
|
||||||
|
$this->assertSame([], LoopDetector::findLoopingWorkflows([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSameSourceAndTargetIsALoop(): void {
|
||||||
|
$this->assertSame([1], LoopDetector::findLoopingWorkflows([1 => [10, 10]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTwoWorkflowsBackAndForthAreALoop(): void {
|
||||||
|
$this->assertSame([1, 2], LoopDetector::findLoopingWorkflows([
|
||||||
|
1 => [10, 20],
|
||||||
|
2 => [20, 10],
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLongerCircleIsALoop(): void {
|
||||||
|
$this->assertSame([1, 2, 3], LoopDetector::findLoopingWorkflows([
|
||||||
|
1 => [10, 20],
|
||||||
|
2 => [20, 30],
|
||||||
|
3 => [30, 10],
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testChainIsNoLoop(): void {
|
||||||
|
$this->assertSame([], LoopDetector::findLoopingWorkflows([
|
||||||
|
1 => [10, 20],
|
||||||
|
2 => [20, 30],
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnlyWorkflowsOnTheCircleAreReported(): void {
|
||||||
|
// 1 feeds into the circle 2 → 3 → 2 and 4 leads out of it; neither
|
||||||
|
// lies on it.
|
||||||
|
$this->assertSame([2, 3], LoopDetector::findLoopingWorkflows([
|
||||||
|
1 => [10, 20],
|
||||||
|
2 => [20, 30],
|
||||||
|
3 => [30, 20],
|
||||||
|
4 => [30, 40],
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTwoWorkflowsFromTheSameSourceAreNoLoop(): void {
|
||||||
|
$this->assertSame([], LoopDetector::findLoopingWorkflows([
|
||||||
|
1 => [10, 20],
|
||||||
|
2 => [10, 30],
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFindPathListsTheWorkflowsInOrder(): void {
|
||||||
|
$edges = [
|
||||||
|
7 => [30, 10],
|
||||||
|
5 => [10, 20],
|
||||||
|
6 => [20, 30],
|
||||||
|
];
|
||||||
|
$this->assertSame([5, 6], LoopDetector::findPath($edges, 10, 30));
|
||||||
|
$this->assertSame([6, 7], LoopDetector::findPath($edges, 20, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFindPathWithoutConnectionIsNull(): void {
|
||||||
|
$this->assertNull(LoopDetector::findPath([1 => [10, 20]], 20, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFindPathToItselfIsEmpty(): void {
|
||||||
|
$this->assertSame([], LoopDetector::findPath([], 10, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\WorkflowDeckAutomation\Tests\Unit\Service;
|
||||||
|
|
||||||
|
use OCA\WorkflowDeckAutomation\Service\TriggerDate;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class TriggerDateTest extends TestCase {
|
||||||
|
public function testKnownTriggersAreValid(): void {
|
||||||
|
$this->assertTrue(TriggerDate::isValid('due'));
|
||||||
|
$this->assertTrue(TriggerDate::isValid('start'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownTriggersAreInvalid(): void {
|
||||||
|
$this->assertFalse(TriggerDate::isValid(''));
|
||||||
|
$this->assertFalse(TriggerDate::isValid('duedate'));
|
||||||
|
$this->assertFalse(TriggerDate::isValid('Start'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReasonNamesTheTriggeringDate(): void {
|
||||||
|
$this->assertSame('ist überfällig', TriggerDate::describeReason('due'));
|
||||||
|
$this->assertSame('hat ihr Startdatum erreicht', TriggerDate::describeReason('start'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownTriggerIsDescribedAsDue(): void {
|
||||||
|
// Notifications stored before the column existed carry no trigger.
|
||||||
|
$this->assertSame('ist überfällig', TriggerDate::describeReason(''));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,54 +14,54 @@ class WorkflowRunnerTest extends TestCase {
|
|||||||
return new DateTimeImmutable($time, new DateTimeZone('UTC'));
|
return new DateTimeImmutable($time, new DateTimeZone('UTC'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIsOverdueReturnsFalseWithoutDueDate(): void {
|
public function testHasPassedReturnsFalseWithoutDate(): void {
|
||||||
$this->assertFalse(WorkflowRunner::isOverdue(null, self::utc('2026-01-15 12:00:00')));
|
$this->assertFalse(WorkflowRunner::hasPassed(null, self::utc('2026-01-15 12:00:00')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIsOverdueReturnsFalseForFutureDueDate(): void {
|
public function testHasPassedReturnsFalseForFutureDate(): void {
|
||||||
$now = self::utc('2026-01-15 12:00:00');
|
$now = self::utc('2026-01-15 12:00:00');
|
||||||
$this->assertFalse(WorkflowRunner::isOverdue($now->modify('+1 hour'), $now));
|
$this->assertFalse(WorkflowRunner::hasPassed($now->modify('+1 hour'), $now));
|
||||||
$this->assertFalse(WorkflowRunner::isOverdue($now->modify('+3 days'), $now));
|
$this->assertFalse(WorkflowRunner::hasPassed($now->modify('+3 days'), $now));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIsOverdueReturnsTrueForPastDueDate(): void {
|
public function testHasPassedReturnsTrueForPastDate(): void {
|
||||||
$now = self::utc('2026-01-15 12:00:00');
|
$now = self::utc('2026-01-15 12:00:00');
|
||||||
$this->assertTrue(WorkflowRunner::isOverdue($now->modify('-1 hour'), $now));
|
$this->assertTrue(WorkflowRunner::hasPassed($now->modify('-1 hour'), $now));
|
||||||
$this->assertTrue(WorkflowRunner::isOverdue($now->modify('-30 days'), $now));
|
$this->assertTrue(WorkflowRunner::hasPassed($now->modify('-30 days'), $now));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIsOverdueReturnsTrueForADueTimeEarlierTheSameDay(): void {
|
public function testHasPassedReturnsTrueForATimeEarlierTheSameDay(): 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. isOverdue() must compare the real timestamps instead.
|
// fell due. hasPassed() must compare the real timestamps instead.
|
||||||
$now = self::utc('2026-01-15 12:00:00');
|
$now = self::utc('2026-01-15 12:00:00');
|
||||||
$this->assertTrue(WorkflowRunner::isOverdue(self::utc('2026-01-15 06:00:00'), $now));
|
$this->assertTrue(WorkflowRunner::hasPassed(self::utc('2026-01-15 06:00:00'), $now));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIsOverdueReadsTheStoredWallClockAsUtc(): void {
|
public function testHasPassedReadsTheStoredWallClockAsUtc(): void {
|
||||||
// What Entity hydration produces if PHP's default zone is not UTC:
|
// What Entity hydration produces if PHP's default zone is not UTC:
|
||||||
// the stored UTC wall clock "12:00", tagged with the local zone.
|
// the stored UTC wall clock "12:00", tagged with the local zone.
|
||||||
$due = new DateTimeImmutable('2026-07-15 12:00:00', new DateTimeZone('Europe/Berlin'));
|
$date = new DateTimeImmutable('2026-07-15 12:00:00', new DateTimeZone('Europe/Berlin'));
|
||||||
|
|
||||||
// Taken at face value that would be 10:00 UTC and already overdue.
|
// Taken at face value that would be 10:00 UTC and already passed.
|
||||||
$this->assertFalse(WorkflowRunner::isOverdue($due, self::utc('2026-07-15 11:30:00')));
|
$this->assertFalse(WorkflowRunner::hasPassed($date, self::utc('2026-07-15 11:30:00')));
|
||||||
$this->assertTrue(WorkflowRunner::isOverdue($due, self::utc('2026-07-15 12:00:01')));
|
$this->assertTrue(WorkflowRunner::hasPassed($date, self::utc('2026-07-15 12:00:01')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIsOverdueComparesNowAsAnAbsoluteInstant(): void {
|
public function testHasPassedComparesNowAsAnAbsoluteInstant(): void {
|
||||||
$due = self::utc('2026-07-15 12:00:00');
|
$date = self::utc('2026-07-15 12:00:00');
|
||||||
$berlin = new DateTimeZone('Europe/Berlin');
|
$berlin = new DateTimeZone('Europe/Berlin');
|
||||||
|
|
||||||
// 13:30 in Berlin (CEST) is 11:30 UTC - not yet due.
|
// 13:30 in Berlin (CEST) is 11:30 UTC - not yet passed.
|
||||||
$this->assertFalse(WorkflowRunner::isOverdue($due, new DateTimeImmutable('2026-07-15 13:30:00', $berlin)));
|
$this->assertFalse(WorkflowRunner::hasPassed($date, new DateTimeImmutable('2026-07-15 13:30:00', $berlin)));
|
||||||
// 14:30 in Berlin is 12:30 UTC - due.
|
// 14:30 in Berlin is 12:30 UTC - passed.
|
||||||
$this->assertTrue(WorkflowRunner::isOverdue($due, new DateTimeImmutable('2026-07-15 14:30:00', $berlin)));
|
$this->assertTrue(WorkflowRunner::hasPassed($date, new DateTimeImmutable('2026-07-15 14:30:00', $berlin)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIsOverdueIsFalseAtTheExactDueInstant(): void {
|
public function testHasPassedIsFalseAtTheExactInstant(): void {
|
||||||
$now = self::utc('2026-01-15 12:00:00');
|
$now = self::utc('2026-01-15 12:00:00');
|
||||||
$this->assertFalse(WorkflowRunner::isOverdue($now, $now));
|
$this->assertFalse(WorkflowRunner::hasPassed($now, $now));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user