diff --git a/CLAUDE.md b/CLAUDE.md index 23903e3..d6effa7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ Die Fälligkeitslogik `WorkflowRunner::isOverdue()` und die Empfängerregel `Not - **Nur positives Wissen deaktiviert.** `findStackIds()` liefert `null` nur bei „Board-Zeile weg“ (`DoesNotExistException`, Decks `NotFoundException`), „Board im Papierkorb“ (`deleted_at > 0`) oder „uid hat keine Leserechte“ – alles andere *wirft*, und der Runner überspringt den Workflow. Degradiert die Methode auf `[]`/`null`, deaktiviert ein Deck-Ausfall alle Workflows der Instanz und mailt alle Nutzer an. - **`findStackIds()` fragt nie einen Deck-Dienst, *wer* fragt.** Sie bekommt die uid als Argument und nutzt nur `BoardMapper`/`StackMapper` plus `PermissionService::getPermissions($boardId, $userId)` mit expliziter uid. Nicht zu `StackService::findAll($boardId)` „vereinfachen“ – genau das prüfte gegen die eingefrorene `null`-uid, warf `NoPermissionException`, wurde als „Board weg“ gelesen und verschickte Deaktivierungsmails für intakte Boards. - **`NoPermissionException` beweist nichts** und steht deshalb bewusst nicht in `isMissing()`. -- **Die Deaktivierungsmail ignoriert `notifyMode`** (das betrifft verschobene Karten) und geht immer nur an den Owner. 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. @@ -56,11 +56,20 @@ Wird ein Board in Deck übertragen, wandern seine Workflows mit. - `off`: keine Mail. `owner`: nur der Board-Owner, ohne Rücksicht auf seine Deck-Einstellung (altes Verhalten). - `board`: alle Board-Berechtigten, gefiltert nach deren Deck-Nutzereinstellung `deck` / `board::notify-due` (`off`/`assigned`/`all`, Default `assigned`). `NotificationPolicy::shouldNotify()` bildet Decks Regel aus `NotificationHelper::sendCardDuedate()` nach und ist reine, getestete Logik. Dazu gehört: Der Owner eines ungeteilten Boards wird außer bei `off` immer benachrichtigt. - `DeckIntegrationService::findNotificationRecipients()` nutzt nur uid-explizite Bausteine: `PermissionService::findUsers($boardId, true)` (Owner + ACL, löst Gruppen/Circles auf, `true` umgeht den Prozess-Cache), `AssignmentMapper::isUserAssigned()` (auch Gruppen-/Circle-Zuweisungen) und `IConfig`. `FederatedUser` wird übersprungen. Bei jedem Fehler wirft die Methode, und der Runner verschickt dann **keine** Mail, statt an eine falsch aufgelöste Empfängerliste zu schicken. -- Der Runner sammelt verschobene Karten in `$pendingMails` und verschickt **nach** der Nutzer-Schleife (also ohne Impersonation) eine Sammelmail pro Empfänger und Lauf. +- Der Runner sammelt verschobene Karten in `$pendingNotices` und verschickt **nach** der Nutzer-Schleife (also ohne Impersonation) je nach `notify_channel` des Workflows eine Sammelmail pro Empfänger und Lauf und/oder eine Nextcloud-Benachrichtigung pro Karte. + +## Nextcloud-Benachrichtigungen sind ein zweiter Kanal, keine zweite Empfängerregel + +`notify_channel` (`NotificationPolicy::CHANNELS`: `mail`/`notification`/`both`, Default und Migrationswert `mail`, `Version1004…`) sagt nur, *wie* die über `notify_mode` ermittelten Empfänger informiert werden. `NotificationPolicy::sendsMail()`/`sendsNotification()` sind reine, getestete Logik. Unbekannte Werte zählen als `mail`. + +- **`NotificationPusher`** legt über `OCP\Notification\IManager` eine Benachrichtigung **pro Karte** an (Objekt `card`/Karten-ID, Subject `card_moved`) und räumt vorher per `markProcessed()` eine ältere zur selben Karte weg. Gespeichert werden nur Rohwerte (Titel, IDs), keine Texte. +- **`Notification\Notifier`** (per `registerNotifierService()` in `Application::register()`) baut daraus deutsche Texte und ignoriert `$languageCode`. Titel sind Nutzereingaben und gehen nur als Rich-Objekte (`deck-card`, `deck-board`, `highlight`) in den Text, nie ins Template, sonst würde ein `{…}` im Titel als Platzhalter gelesen. Der Notifier fragt **Deck nicht**: Er läuft beim Abruf durch irgendeinen Nutzer, und Deck-Dienste hätten dort die eingefrorene uid. +- **Ohne Notifications-App passiert nichts:** `NotificationPusher::isAvailableFor()` prüft `IAppManager::isEnabledForUser('notifications', $user)` pro Empfänger und kehrt stumm zurück. Kein Fallback auf Mail, kein Fehler-Log. Die Einstellungsseite bekommt denselben Check als `data-notifications-available` und zeigt dann einen Hinweis. +- Push aufs Handy/Desktop ist Sache der Notifications-App (Push-Proxy bzw. `notify_push`), nicht dieser App. ## Nur Deutsch, keine Übersetzungen -Die App wird nur intern genutzt und spricht ausschließlich Deutsch. Oberfläche, Controller-Fehlermeldungen (erscheinen im Formular), Sektionsname und Mails 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älligkeit wird als UTC-Zeitpunkt verglichen @@ -89,13 +98,13 @@ Debuggen: `occ background-job:list --class ''` (ID, letzter Lauf), dann ## Registrierung deklarativ statt über Bootstrap -Hintergrundjob und persönliche Einstellungen stehen in `appinfo/info.xml` (``, `/`). `IRegistrationContext` hat dafür keine Methoden. `lib/AppInfo/Application.php` ist absichtlich fast leer. +Hintergrundjob und persönliche Einstellungen stehen in `appinfo/info.xml` (``, `/`). `IRegistrationContext` hat dafür keine Methoden. `lib/AppInfo/Application.php` ist absichtlich fast leer und registriert nur den `Notifier` (dafür gibt es keinen `info.xml`-Eintrag). ## Architektur - **UI:** `src/PersonalSettings.vue` (Vue 3), gemountet aus `templates/settings/personal.php` (Sektion: `lib/Settings/PersonalSection.php` + `Personal.php`) → `src/api.js` → `lib/Controller/WorkflowController.php` (`OCSController`, Routen in `appinfo/routes.php`, `/ocs/v2.php/apps/workflow_deck_automation/api/v1/...`). - **Speicher:** `lib/Db/Workflow.php` / `WorkflowMapper.php`, Tabelle `wfda_workflows` (Migrationen in `lib/Migration/`). -- **Automation:** `RunWorkflowsJob` → `WorkflowRunner` → `DeckIntegrationService` (alle Deck-Aufrufe) und `NotificationMailer` (`IMailer`, an die Account-Adressen der Empfänger, nie an eine eingegebene Adresse). +- **Automation:** `RunWorkflowsJob` → `WorkflowRunner` → `DeckIntegrationService` (alle Deck-Aufrufe), `NotificationMailer` (`IMailer`, an die Account-Adressen der Empfänger, nie an eine eingegebene Adresse) und `NotificationPusher` (`IManager`, Texte aus `lib/Notification/Notifier.php`). - Controller (Dropdowns) und Runner (Karten lesen/verschieben) nutzen denselben `DeckIntegrationService`. - **Doku:** `README.md` richtet sich an Anwender und Admins und enthält keine Klassennamen. `DEVELOPMENT.md` ist für Entwickler (Aufbau, Datenmodell, Tests, CI, Release, Fehlersuche). Neue Nutzer-Funktionen in der README beschreiben, technische Details in `DEVELOPMENT.md`. Beide Dateien sind per `Makefile` vom Paket ausgeschlossen. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index fb3e8c7..79741f4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -10,6 +10,7 @@ Informationen für Entwickler und Admins, die tiefer einsteigen. Die Nutzer-Doku - **Hintergrundjob:** [`RunWorkflowsJob`](lib/BackgroundJob/RunWorkflowsJob.php) ist ein einfacher `Job` ohne Intervall. Er läuft bei jedem Cron-Durchlauf und ruft [`WorkflowRunner`](lib/Service/WorkflowRunner.php) auf. - **Rechte-Kontext:** Der Runner arbeitet die Workflows nach Eigentümer gruppiert ab und setzt Deck jeweils auf diesen Nutzer, per `DeckIntegrationService::beginUserContext()`. Decks Berechtigungsprüfungen lesen nämlich nicht die Session, sondern eine pro Prozess festgelegte Nutzer-ID. - **E-Mail:** [`NotificationMailer`](lib/Service/NotificationMailer.php) verschickt über Nextclouds `IMailer`. Die Empfänger ermittelt `DeckIntegrationService::findNotificationRecipients()`, die Entscheidungsregel steckt in [`NotificationPolicy`](lib/Service/NotificationPolicy.php). +- **Nextcloud-Benachrichtigungen:** [`NotificationPusher`](lib/Service/NotificationPusher.php) legt sie über `OCP\Notification\IManager` an, eine pro Karte, an dieselben Empfänger wie die Mail. Er speichert nur Rohwerte. Den deutschen Text baut [`Notifier`](lib/Notification/Notifier.php), registriert in `Application::register()`. Ist die App `notifications` für einen Empfänger nicht aktiv, wird nichts angelegt. Push aufs Handy übernimmt die Notifications-App selbst. - **Oberfläche:** [`src/PersonalSettings.vue`](src/PersonalSettings.vue) (Vue 3, `@nextcloud/vue` 9) spricht über [`src/api.js`](src/api.js) mit dem OCS-Controller [`WorkflowController`](lib/Controller/WorkflowController.php). ## Datenmodell @@ -20,13 +21,15 @@ Die Tabelle `wfda_workflows` enthält eine Zeile pro Workflow: | --- | --- | | `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. | -| `notify_mode` | `off`, `owner` oder `board` | +| `notify_mode` | `off`, `owner` oder `board` (wer) | +| `notify_channel` | `mail`, `notification` oder `both` (wie) | | `enabled`, `last_run` | aktiv? / letzter Lauf | Migrationen in [`lib/Migration/`](lib/Migration/): - `Version1000…` legt die Tabelle an. - `Version1001…` entfernt die früheren Filter-Spalten. - `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`. Migrationen laufen nur, wenn die App-Version steigt. @@ -52,6 +55,7 @@ composer run test:unit # PHPUnit Die Unit-Tests decken nur reine Logik ab, ohne Deck-Installation: - Fälligkeitsprüfung (`WorkflowRunner::isOverdue()`) - Empfängerregel (`NotificationPolicy::shouldNotify()`) +- Benachrichtigungsweg (`NotificationPolicy::sendsMail()`/`sendsNotification()`) Die Deck-Anbindung lässt sich nur auf einer echten Nextcloud-Instanz mit Deck testen: diff --git a/README.md b/README.md index dae5709..6341f93 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 darüber informiert. +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. 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. @@ -20,6 +20,7 @@ Beispiel: Ein Workflow „Waiting → ToDo“ holt jede Karte aus „Waiting / B 2. Die [Deck-App](https://apps.nextcloud.com/apps/deck) muss installiert und für die Nutzer aktiviert sein. 3. Die [Hintergrundjobs](https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/background_jobs_configuration.html) müssen auf *Cron* stehen. 4. Für E-Mails muss in der Nextcloud-Administration ein Mailserver eingerichtet sein. +5. Für Nextcloud-Benachrichtigungen muss die App **Benachrichtigungen** (`notifications`) aktiviert sein, was sie standardmäßig ist. Fehlt sie, verschickt die App einfach keine Benachrichtigungen. E-Mails kommen trotzdem. Die Workflows laufen bei **jedem** Cron-Durchlauf von Nextcloud, üblicherweise alle 5 Minuten. Ein eigenes Intervall gibt es nicht. @@ -29,46 +30,64 @@ 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. - **Quell- und Ziel-Stapel:** Das müssen zwei verschiedene Stapel desselben Boards sein. -- **E-Mail bei verschobenen Karten:** siehe unten. +- **Benachrichtigung bei verschobenen Karten** und **Benachrichtigen per:** siehe unten. - **Workflow aktiv:** Deaktivierte Workflows bleiben gespeichert, verschieben aber nichts. Ein Workflow gehört immer dem Eigentümer des Boards. Überträgst du ein Board in Deck an jemand anderen, wandern seine Workflows mit: Der neue Eigentümer sieht und verwaltet sie ab sofort, und die Karten werden in seinem Namen verschoben. -## E-Mail-Benachrichtigung +## Benachrichtigungen -Pro Workflow gibt es drei Stufen: +Pro Workflow stellst du ein, **wer** über verschobene Karten informiert wird und **wie**. -| Einstellung | Wer bekommt eine E-Mail? | +### Wer? + +| Einstellung | Wer wird informiert? | | --- | --- | -| **Keine E-Mail** | niemand | +| **Keine Benachrichtigung** | niemand | | **Nur an mich** | der Eigentümer des Boards, unabhängig von seinen Deck-Einstellungen | | **An Board-Mitglieder laut ihrer Deck-Einstellung** | alle, die das Board sehen dürfen, je nach ihrer eigenen Deck-Einstellung (siehe unten) | Bei der dritten Stufe entscheidet jedes Board-Mitglied selbst. Dazu gehören der Eigentümer, Personen mit Freigabe sowie Mitglieder freigegebener Gruppen und Teams. Die Einstellung liegt in Deck im Menü **…** neben dem Board unter **Fälligkeitserinnerungen**: -- **Alle Karten:** eine E-Mail für jede verschobene Karte +- **Alle Karten:** für jede verschobene Karte - **Zugewiesene Karten** (Deck-Standard): nur für Karten, denen man zugewiesen ist, direkt oder über eine Gruppe oder ein Team -- **Keine Benachrichtigungen:** keine E-Mail +- **Keine Benachrichtigungen:** nie 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. +### Wie? + +| Einstellung | Was kommt an? | +| --- | --- | +| **E-Mail** (Standard) | eine Sammel-E-Mail pro Durchlauf | +| **Nextcloud-Benachrichtigung (Push)** | eine Benachrichtigung pro Karte in der Glocke oben rechts und als Push-Nachricht in den Nextcloud-Apps auf Handy und Desktop | +| **E-Mail und Nextcloud-Benachrichtigung** | beides | + +Die Empfänger sind in allen drei Fällen dieselben. + **Inhalt der E-Mail:** Jeder Empfänger bekommt pro Durchlauf höchstens **eine** E-Mail, auch wenn mehrere Karten verschoben wurden. Pro Karte steht darin ein Satz wie: > „Essensvorräte prüfen“ (Board „Persönlich“) ist überfällig und wurde von „Waiting / Blocked“ nach „Work in Progress / ToDo“ verschoben. 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. + **Keine E-Mail erhalten?** - Ist in deinem Nextcloud-Profil eine E-Mail-Adresse hinterlegt? Ohne Adresse wird die E-Mail übersprungen. -- Welche Stufe ist im Workflow eingestellt? +- Welche Stufe und welcher Weg sind im Workflow eingestellt? - Bei „An Board-Mitglieder“: Was steht in Deck unter *Fälligkeitserinnerungen* für dieses Board? -Deck schickt zusätzlich seine eigene Erinnerung, wenn eine Karte fällig wird. Diese Glocken-Benachrichtigung kommt von Deck, nicht von dieser App. +**Keine Push-Nachricht erhalten?** +- Ist die App *Benachrichtigungen* aktiviert? Die Einstellungsseite weist darauf hin, wenn nicht. +- Für Push aufs Handy muss dort die Nextcloud-App angemeldet sein und Benachrichtigungen erlauben. + +Deck schickt zusätzlich seine eigene Erinnerung, wenn eine Karte fällig wird. Sie kommt von Deck, nicht von dieser App, und lässt sich in Deck unter *Fälligkeitserinnerungen* abschalten. ## Wenn Board oder Stapel gelöscht werden - **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, auch wenn für den Workflow „Keine E-Mail“ 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. - **Ist Deck vorübergehend nicht erreichbar**, passiert nichts: Der Workflow wird übersprungen und beim nächsten Durchlauf erneut versucht. @@ -76,7 +95,7 @@ Um einen deaktivierten Workflow wieder zu nutzen, stellst du ein vorhandenes Boa ## Sprache -Die App ist für den internen Einsatz gedacht und durchgehend **deutsch**: Oberfläche, Fehlermeldungen und E-Mails. +Die App ist für den internen Einsatz gedacht und durchgehend **deutsch**: Oberfläche, Fehlermeldungen, E-Mails und Benachrichtigungen. ## Lizenz diff --git a/appinfo/info.xml b/appinfo/info.xml index 0632728..34ec78f 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -11,7 +11,7 @@ Diese App erlaubt es jedem Nutzer, in den persönlichen Einstellungen Automatisi * Quell-Board und Quell-Stapel wählen * Ziel-Stapel wählen, in den überfällige Karten verschoben werden -* optional per E-Mail benachrichtigen: 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, ausschließlich über die internen PHP-Klassen der Deck-App (keine HTTP/OCS-Aufrufe). ]]> @@ -20,11 +20,11 @@ This app lets every user configure Deck automation workflows for the boards they * pick a source board and source stack * pick a destination stack overdue cards get moved to -* optionally notify by email: 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, using Deck's own internal PHP classes only (no HTTP/OCS calls). ]]> - 35.2.2 + 35.3.0 agpl Patrick Niebeling WorkflowDeckAutomation diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index db04d2c..7c96997 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace OCA\WorkflowDeckAutomation\AppInfo; +use OCA\WorkflowDeckAutomation\Notification\Notifier; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -16,7 +17,13 @@ class Application extends App implements IBootstrap { parent::__construct(self::APP_ID); } + /** + * Background job and settings are declared in info.xml; the notifier is + * the one thing that has to be registered here. Without the + * notifications app it is simply never asked. + */ public function register(IRegistrationContext $context): void { + $context->registerNotifierService(Notifier::class); } public function boot(IBootContext $context): void { diff --git a/lib/Controller/WorkflowController.php b/lib/Controller/WorkflowController.php index f5e2d9e..899fa11 100644 --- a/lib/Controller/WorkflowController.php +++ b/lib/Controller/WorkflowController.php @@ -57,13 +57,14 @@ class WorkflowController extends OCSController { int $sourceStackId, int $targetStackId, string $notifyMode = NotificationPolicy::MODE_OFF, + string $notifyChannel = NotificationPolicy::CHANNEL_MAIL, bool $enabled = true, ): DataResponse { - $this->validate($title, $boardId, $sourceStackId, $targetStackId, $notifyMode); + $this->validate($title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel); $workflow = new Workflow(); $workflow->setUserId($this->currentUserId()); - $this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $enabled); + $this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel, $enabled); $workflow = $this->workflowMapper->insert($workflow); return new DataResponse($workflow->jsonSerialize(), Http::STATUS_CREATED); @@ -77,15 +78,16 @@ class WorkflowController extends OCSController { int $sourceStackId, int $targetStackId, string $notifyMode = NotificationPolicy::MODE_OFF, + string $notifyChannel = NotificationPolicy::CHANNEL_MAIL, bool $enabled = true, ): DataResponse { $workflow = $this->findOwnWorkflow($id); - $this->validate($title, $boardId, $sourceStackId, $targetStackId, $notifyMode); + $this->validate($title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel); // Kept in step with the board owner so the job runs as, and mails, // the right user; validate() made sure the editor owns the board. $workflow->setUserId($this->currentUserId()); - $this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $enabled); + $this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel, $enabled); $workflow = $this->workflowMapper->update($workflow); return new DataResponse($workflow->jsonSerialize()); @@ -156,7 +158,7 @@ class WorkflowController extends OCSController { } } - private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId, string $notifyMode): void { + private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId, string $notifyMode, string $notifyChannel): void { if (trim($title) === '') { throw new OCSBadRequestException('Bitte einen Titel angeben.'); } @@ -164,7 +166,10 @@ class WorkflowController extends OCSController { throw new OCSBadRequestException('Quell- und Ziel-Stapel müssen sich unterscheiden.'); } if (!NotificationPolicy::isValidMode($notifyMode)) { - throw new OCSBadRequestException('Ungültige E-Mail-Einstellung, erlaubt sind: ' . implode(', ', NotificationPolicy::MODES)); + throw new OCSBadRequestException('Ungültige Benachrichtigungs-Einstellung, erlaubt sind: ' . implode(', ', NotificationPolicy::MODES)); + } + if (!NotificationPolicy::isValidChannel($notifyChannel)) { + throw new OCSBadRequestException('Ungültiger Benachrichtigungsweg, erlaubt sind: ' . implode(', ', NotificationPolicy::CHANNELS)); } // Reject references to boards or stacks that are gone (or were never @@ -203,6 +208,7 @@ class WorkflowController extends OCSController { int $sourceStackId, int $targetStackId, string $notifyMode, + string $notifyChannel, bool $enabled, ): void { $workflow->setTitle($title); @@ -210,6 +216,7 @@ class WorkflowController extends OCSController { $workflow->setSourceStackId($sourceStackId); $workflow->setTargetStackId($targetStackId); $workflow->setNotifyMode($notifyMode); + $workflow->setNotifyChannel($notifyChannel); $workflow->setEnabled($enabled); } } diff --git a/lib/Db/Workflow.php b/lib/Db/Workflow.php index 9aa71db..adfcf05 100644 --- a/lib/Db/Workflow.php +++ b/lib/Db/Workflow.php @@ -20,6 +20,8 @@ use OCP\DB\Types; * @method void setTargetStackId(int $targetStackId) * @method string getNotifyMode() * @method void setNotifyMode(string $notifyMode) + * @method string getNotifyChannel() + * @method void setNotifyChannel(string $notifyChannel) * @method bool getEnabled() * @method void setEnabled(bool $enabled) * @method \DateTime|null getLastRun() @@ -32,6 +34,7 @@ class Workflow extends Entity implements \JsonSerializable { protected $sourceStackId; protected $targetStackId; protected $notifyMode; + protected $notifyChannel; protected $enabled; protected $lastRun; @@ -43,6 +46,7 @@ class Workflow extends Entity implements \JsonSerializable { $this->addType('sourceStackId', Types::INTEGER); $this->addType('targetStackId', Types::INTEGER); $this->addType('notifyMode', Types::STRING); + $this->addType('notifyChannel', Types::STRING); $this->addType('enabled', Types::BOOLEAN); $this->addType('lastRun', Types::DATETIME); } @@ -56,6 +60,7 @@ class Workflow extends Entity implements \JsonSerializable { 'sourceStackId' => $this->getSourceStackId(), 'targetStackId' => $this->getTargetStackId(), 'notifyMode' => (string)$this->getNotifyMode(), + 'notifyChannel' => (string)$this->getNotifyChannel(), 'enabled' => (bool)$this->getEnabled(), 'lastRun' => $this->getLastRun()?->format(\DateTimeInterface::ATOM), ]; diff --git a/lib/Migration/Version1004Date20261002120000.php b/lib/Migration/Version1004Date20261002120000.php new file mode 100644 index 0000000..c4318d3 --- /dev/null +++ b/lib/Migration/Version1004Date20261002120000.php @@ -0,0 +1,41 @@ +hasTable('wfda_workflows')) { + return null; + } + + $table = $schema->getTable('wfda_workflows'); + if ($table->hasColumn('notify_channel')) { + return null; + } + + $table->addColumn('notify_channel', Types::STRING, [ + 'notnull' => true, + 'length' => 16, + 'default' => 'mail', + ]); + + return $schema; + } +} diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php new file mode 100644 index 0000000..8340125 --- /dev/null +++ b/lib/Notification/Notifier.php @@ -0,0 +1,146 @@ +getApp() !== Application::APP_ID) { + throw new UnknownNotificationException(); + } + + $notification->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg'))); + $params = $notification->getSubjectParameters(); + + switch ($notification->getSubject()) { + case NotificationPusher::SUBJECT_CARD_MOVED: + $this->prepareCardMoved($notification, $params); + return $notification; + case NotificationPusher::SUBJECT_WORKFLOW_DISABLED: + $this->prepareWorkflowDisabled($notification, $params); + return $notification; + default: + throw new UnknownNotificationException(); + } + } + + private function prepareCardMoved(INotification $notification, array $params): void { + $cardId = $notification->getObjectId(); + $boardId = (string)($params['boardId'] ?? ''); + $boardTitle = (string)($params['boardTitle'] ?? ''); + $targetStackTitle = (string)($params['targetStackTitle'] ?? ''); + $cardUrl = $this->urlGenerator->getAbsoluteURL('/apps/deck/board/' . $boardId . '/card/' . $cardId); + + // Every title is user input and goes in as a rich object, never into + // the template itself — a title containing "{…}" would otherwise be + // read as a placeholder. + $objects = [ + 'deck-card' => [ + 'type' => 'deck-card', + 'id' => $cardId, + 'name' => (string)($params['cardTitle'] ?? ''), + 'boardname' => $boardTitle, + 'stackname' => $targetStackTitle, + 'link' => $cardUrl, + ], + 'deck-board' => [ + 'type' => 'deck-board', + 'id' => $boardId, + 'name' => $boardTitle, + 'link' => $this->urlGenerator->getAbsoluteURL('/apps/deck/board/' . $boardId), + ], + 'source-stack' => [ + 'type' => 'highlight', + 'id' => (string)($params['sourceStackId'] ?? ''), + 'name' => (string)($params['sourceStackTitle'] ?? ''), + ], + 'target-stack' => [ + 'type' => 'highlight', + 'id' => (string)($params['targetStackId'] ?? ''), + 'name' => $targetStackTitle, + ], + ]; + + $notification->setLink($cardUrl); + self::setText( + $notification, + '{deck-card} wurde nach {target-stack} verschoben', + 'Die Karte auf {deck-board} ist überfällig und wurde automatisch von {source-stack} nach {target-stack} verschoben.', + $objects, + ); + } + + private function prepareWorkflowDisabled(INotification $notification, array $params): void { + $settingsUrl = $this->urlGenerator->getAbsoluteURL('/settings/user/' . Application::APP_ID); + + $objects = [ + 'workflow' => [ + 'type' => 'highlight', + 'id' => $notification->getObjectId(), + 'name' => (string)($params['workflowTitle'] ?? ''), + 'link' => $settingsUrl, + ], + ]; + + $notification->setLink($settingsUrl); + self::setText( + $notification, + 'Workflow {workflow} wurde deaktiviert', + sprintf( + 'Er wurde automatisch ausgeschaltet, weil %s, und verschiebt keine Karten mehr. Lösche ihn oder stelle ein vorhandenes Board und vorhandene Stapel ein, um ihn wieder zu aktivieren.', + NotificationMailer::describeBrokenTarget((string)($params['brokenTarget'] ?? '')), + ), + $objects, + ); + } + + /** + * Sets the rich texts and their plain fallback (used by clients without + * rich text support), with titles in German quotes there. + * + * @param array $objects + */ + private static function setText(INotification $notification, string $subject, string $message, array $objects): void { + $plain = []; + foreach ($objects as $key => $object) { + $plain['{' . $key . '}'] = '„' . $object['name'] . '“'; + } + + // strtr() replaces in a single pass, so a title that itself contains + // a placeholder is left as it is. + $notification->setParsedSubject(strtr($subject, $plain)) + ->setRichSubject($subject, $objects) + ->setParsedMessage(strtr($message, $plain)) + ->setRichMessage($message, $objects); + } +} diff --git a/lib/Service/NotificationMailer.php b/lib/Service/NotificationMailer.php index ea3e790..80f5a06 100644 --- a/lib/Service/NotificationMailer.php +++ b/lib/Service/NotificationMailer.php @@ -128,6 +128,20 @@ class NotificationMailer { return htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); } + /** + * Completes "… wurde ausgeschaltet, weil %s." — shared with + * Notification\Notifier so mail and notification give the same reason. + * + * @param WorkflowRunner::TARGET_* $brokenTarget + */ + public static function describeBrokenTarget(string $brokenTarget): string { + return match ($brokenTarget) { + WorkflowRunner::TARGET_SOURCE_STACK => 'sein Quell-Stapel nicht mehr existiert', + WorkflowRunner::TARGET_TARGET_STACK => 'sein Ziel-Stapel nicht mehr existiert', + default => 'sein Board nicht mehr existiert oder für dich nicht mehr verfügbar ist', + }; + } + /** * One-off notice that a workflow was switched off because the board or * stack it points at is gone. @@ -143,11 +157,7 @@ class NotificationMailer { return; } - $reason = match ($brokenTarget) { - WorkflowRunner::TARGET_SOURCE_STACK => 'sein Quell-Stapel nicht mehr existiert', - WorkflowRunner::TARGET_TARGET_STACK => 'sein Ziel-Stapel nicht mehr existiert', - default => 'sein Board nicht mehr existiert oder für dich nicht mehr verfügbar ist', - }; + $reason = self::describeBrokenTarget($brokenTarget); try { $settingsLink = $this->urlGenerator->getAbsoluteURL('/settings/user/' . self::APP_ID); diff --git a/lib/Service/NotificationPolicy.php b/lib/Service/NotificationPolicy.php index b6a3cc0..9e489e8 100644 --- a/lib/Service/NotificationPolicy.php +++ b/lib/Service/NotificationPolicy.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace OCA\WorkflowDeckAutomation\Service; /** - * Who gets a mail about a moved card — 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(). */ class NotificationPolicy { @@ -18,6 +18,14 @@ class NotificationPolicy { public const MODES = [self::MODE_OFF, self::MODE_OWNER, self::MODE_BOARD]; + /** One mail per recipient and run. */ + public const CHANNEL_MAIL = 'mail'; + /** One Nextcloud notification (bell, push to the mobile apps) per card. */ + public const CHANNEL_NOTIFICATION = 'notification'; + public const CHANNEL_BOTH = 'both'; + + public const CHANNELS = [self::CHANNEL_MAIL, self::CHANNEL_NOTIFICATION, self::CHANNEL_BOTH]; + /** * Values of Deck's per-user, per-board preference * `deck` / `board::notify-due` (Deck's @@ -36,6 +44,22 @@ class NotificationPolicy { return in_array($mode, self::MODES, true); } + public static function isValidChannel(string $channel): bool { + return in_array($channel, self::CHANNELS, true); + } + + /** + * An unknown channel counts as `mail`, the column default — mail was the + * only channel before the column existed. + */ + public static function sendsMail(string $channel): bool { + return $channel !== self::CHANNEL_NOTIFICATION; + } + + public static function sendsNotification(string $channel): bool { + return $channel === self::CHANNEL_NOTIFICATION || $channel === self::CHANNEL_BOTH; + } + /** * Mirrors Deck's own decision in NotificationHelper::sendCardDuedate(): * diff --git a/lib/Service/NotificationPusher.php b/lib/Service/NotificationPusher.php new file mode 100644 index 0000000..ff753d5 --- /dev/null +++ b/lib/Service/NotificationPusher.php @@ -0,0 +1,133 @@ +appManager->isEnabledForUser(self::NOTIFICATIONS_APP, $user); + } catch (Throwable $e) { + return false; + } + } + + /** + * One notification per card, unlike the mail: each one links its card + * and can be dismissed on its own. An older notification about the same + * card is removed first, so a card that comes back and falls due again + * is not listed twice. + * + * @param non-empty-list $entries + */ + public function sendCardsMovedNotification(IUser $recipient, array $entries): void { + if (!$this->isAvailableFor($recipient)) { + return; + } + + foreach ($entries as $entry) { + $workflow = $entry['workflow']; + $card = $entry['card']; + $targets = $entry['targets']; + + try { + $this->dismiss($recipient, self::OBJECT_CARD, (string)$card->getId(), self::SUBJECT_CARD_MOVED); + + $notification = $this->notificationManager->createNotification(); + $notification->setApp(Application::APP_ID) + ->setUser($recipient->getUID()) + ->setDateTime($this->timeFactory->getDateTime()) + ->setObject(self::OBJECT_CARD, (string)$card->getId()) + ->setSubject(self::SUBJECT_CARD_MOVED, [ + 'cardTitle' => (string)$card->getTitle(), + 'boardId' => $workflow->getBoardId(), + 'boardTitle' => $targets['board'], + 'sourceStackId' => $workflow->getSourceStackId(), + 'sourceStackTitle' => $targets['sourceStack'], + 'targetStackId' => $workflow->getTargetStackId(), + 'targetStackTitle' => $targets['targetStack'], + ]); + $this->notificationManager->notify($notification); + } catch (Throwable $e) { + $this->logger->error('Could not notify ' . $recipient->getUID() . ' about moved card ' . $card->getId() . ': ' . $e->getMessage(), [ + 'app' => Application::APP_ID, + 'exception' => $e, + ]); + } + } + } + + /** + * Companion to NotificationMailer::sendWorkflowDisabledNotification(). + * + * @param WorkflowRunner::TARGET_* $brokenTarget + */ + public function sendWorkflowDisabledNotification(IUser $user, Workflow $workflow, string $brokenTarget): void { + if (!$this->isAvailableFor($user)) { + return; + } + + try { + $notification = $this->notificationManager->createNotification(); + $notification->setApp(Application::APP_ID) + ->setUser($user->getUID()) + ->setDateTime($this->timeFactory->getDateTime()) + ->setObject(self::OBJECT_WORKFLOW, (string)$workflow->getId()) + ->setSubject(self::SUBJECT_WORKFLOW_DISABLED, [ + 'workflowTitle' => (string)$workflow->getTitle(), + 'brokenTarget' => $brokenTarget, + ]); + $this->notificationManager->notify($notification); + } catch (Throwable $e) { + $this->logger->error('Could not notify ' . $user->getUID() . ' about disabled workflow ' . $workflow->getId() . ': ' . $e->getMessage(), [ + 'app' => Application::APP_ID, + 'exception' => $e, + ]); + } + } + + private function dismiss(IUser $user, string $objectType, string $objectId, string $subject): void { + $notification = $this->notificationManager->createNotification(); + $notification->setApp(Application::APP_ID) + ->setUser($user->getUID()) + ->setObject($objectType, $objectId) + ->setSubject($subject); + $this->notificationManager->markProcessed($notification); + } +} diff --git a/lib/Service/WorkflowRunner.php b/lib/Service/WorkflowRunner.php index f437057..7e98ffc 100644 --- a/lib/Service/WorkflowRunner.php +++ b/lib/Service/WorkflowRunner.php @@ -39,16 +39,19 @@ class WorkflowRunner { private ?IUser $impersonationRestore = null; /** - * Moved cards per recipient uid, collected over the whole run. + * Moved cards per recipient uid, collected over the whole run. Each + * entry's workflow says whether it goes out by mail, as a Nextcloud + * notification, or both. * * @var array> */ - private array $pendingMails = []; + private array $pendingNotices = []; public function __construct( private WorkflowMapper $workflowMapper, private DeckIntegrationService $deckService, private NotificationMailer $mailer, + private NotificationPusher $pusher, private IUserManager $userManager, private IUserSession $userSession, private IRootFolder $rootFolder, @@ -63,7 +66,7 @@ class WorkflowRunner { return; } - $this->pendingMails = []; + $this->pendingNotices = []; $byUser = []; $owners = []; foreach ($workflows as $workflow) { @@ -89,7 +92,7 @@ class WorkflowRunner { } } - $this->sendPendingMails(); + $this->sendPendingNotices(); } /** @@ -246,22 +249,22 @@ class WorkflowRunner { $this->workflowMapper->update($workflow); if ($moved !== []) { - $this->queueMails($user, $workflow, $moved); + $this->queueNotices($user, $workflow, $moved); } } /** * Works out who hears about the moved cards and parks them in - * $pendingMails; sendPendingMails() delivers them once per recipient at - * the end of the run. + * $pendingNotices; sendPendingNotices() delivers them at the end of the + * run. The recipients are the same for mail and notification. * - * Mails are decoration, not part of the move: nothing in here may fail - * the workflow, and if Deck cannot say who wants a mail, nobody gets one + * Notices are decoration, not part of the move: nothing in here may fail + * the workflow, and if Deck cannot say who wants one, nobody gets one * rather than people who asked Deck for silence. * * @param Card[] $cards */ - private function queueMails(IUser $owner, Workflow $workflow, array $cards): void { + private function queueNotices(IUser $owner, Workflow $workflow, array $cards): void { $cardIds = array_map(static fn (Card $card): int => (int)$card->getId(), $cards); switch ($workflow->getNotifyMode()) { @@ -272,7 +275,7 @@ class WorkflowRunner { try { $recipients = $this->deckService->findNotificationRecipients($workflow->getBoardId(), $cardIds); } catch (DeckUnavailableException $e) { - $this->logger->warning('No mails for workflow {id}: ' . $e->getMessage(), [ + $this->logger->warning('No mails or notifications for workflow {id}: ' . $e->getMessage(), [ 'app' => 'workflow_deck_automation', 'id' => $workflow->getId(), ]); @@ -291,7 +294,7 @@ class WorkflowRunner { foreach ($cards as $card) { foreach ($recipients[(int)$card->getId()] ?? [] as $uid) { - $this->pendingMails[$uid][] = [ + $this->pendingNotices[$uid][] = [ 'workflow' => $workflow, 'card' => $card, 'targets' => $targets, @@ -303,13 +306,15 @@ class WorkflowRunner { /** * One mail per recipient and run, however many cards and workflows it * covers — a board with twenty overdue cards and fifteen members would - * otherwise send three hundred mails at once. + * otherwise send three hundred mails at once. Nextcloud notifications + * go out per card instead: they are grouped in the bell anyway, and each + * one links its card. * * Runs after the per-user loop, so no impersonation is active any more. */ - private function sendPendingMails(): void { - $pending = $this->pendingMails; - $this->pendingMails = []; + private function sendPendingNotices(): void { + $pending = $this->pendingNotices; + $this->pendingNotices = []; foreach ($pending as $uid => $entries) { // Same reasoning as in run(): nothing may escape. @@ -318,9 +323,24 @@ class WorkflowRunner { if ($recipient === null || !$recipient->isEnabled()) { continue; } - $this->mailer->sendCardsMovedNotification($recipient, $entries); + + $mailEntries = array_values(array_filter( + $entries, + static fn (array $entry): bool => NotificationPolicy::sendsMail((string)$entry['workflow']->getNotifyChannel()), + )); + $notificationEntries = array_values(array_filter( + $entries, + static fn (array $entry): bool => NotificationPolicy::sendsNotification((string)$entry['workflow']->getNotifyChannel()), + )); + + if ($mailEntries !== []) { + $this->mailer->sendCardsMovedNotification($recipient, $mailEntries); + } + if ($notificationEntries !== []) { + $this->pusher->sendCardsMovedNotification($recipient, $notificationEntries); + } } catch (Throwable $e) { - $this->logger->error('Could not send the moved-cards mail to {user}: ' . $e->getMessage(), [ + $this->logger->error('Could not notify {user} about moved cards: ' . $e->getMessage(), [ 'app' => 'workflow_deck_automation', 'user' => $uid, 'exception' => $e, @@ -370,11 +390,14 @@ class WorkflowRunner { 'target' => $brokenTarget, ]); - // Sent to the owner regardless of the workflow's notifyMode: that is - // about moved cards, this is a one-off notice that the automation the - // user configured has been switched off. It stays one-off because a - // disabled workflow is not picked up again. + // Sent to the owner regardless of the workflow's notifyMode and + // notifyChannel: those are about moved cards, this is a one-off notice + // that the automation the user configured has been switched off — by + // mail as always, and in the bell too if the notifications app is + // there. It stays one-off because a disabled workflow is not picked + // up again. $this->mailer->sendWorkflowDisabledNotification($user, $workflow, $brokenTarget); + $this->pusher->sendWorkflowDisabledNotification($user, $workflow, $brokenTarget); } private function moveCard(Workflow $workflow, Card $card): bool { diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php index e25bae4..318fc0e 100644 --- a/lib/Settings/Personal.php +++ b/lib/Settings/Personal.php @@ -5,17 +5,31 @@ declare(strict_types=1); namespace OCA\WorkflowDeckAutomation\Settings; use OCA\WorkflowDeckAutomation\AppInfo\Application; +use OCA\WorkflowDeckAutomation\Service\NotificationPusher; use OCP\AppFramework\Http\TemplateResponse; +use OCP\IUserSession; use OCP\Settings\ISettings; use OCP\Util; class Personal implements ISettings { + public function __construct( + private NotificationPusher $pusher, + private IUserSession $userSession, + ) { + } + public function getForm(): TemplateResponse { // The bundle names carry the app-id prefix that @nextcloud/vite-config adds. Util::addScript(Application::APP_ID, Application::APP_ID . '-personal-settings'); Util::addStyle(Application::APP_ID, Application::APP_ID . '-personal-settings'); - return new TemplateResponse(Application::APP_ID, 'settings/personal', [], ''); + // Only used for a hint in the form: without the notifications app the + // job silently sends no Nextcloud notifications. + $user = $this->userSession->getUser(); + + return new TemplateResponse(Application::APP_ID, 'settings/personal', [ + 'notificationsAvailable' => $user !== null && $this->pusher->isAvailableFor($user), + ], ''); } public function getSection(): string { diff --git a/package.json b/package.json index b1d9db5..49a8b13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "workflow_deck_automation", - "version": "35.2.2", + "version": "35.3.0", "private": true, "type": "module", "description": "Automates moving overdue Deck cards between stacks, configurable per user.", diff --git a/src/PersonalSettings.vue b/src/PersonalSettings.vue index b20d00c..e16ec64 100644 --- a/src/PersonalSettings.vue +++ b/src/PersonalSettings.vue @@ -18,7 +18,7 @@ Titel Von Stapel Nach Stapel - E-Mail + Benachrichtigung Aktiv @@ -34,7 +34,7 @@ {{ stackLabel(workflow.boardId, workflow.sourceStackId) }} {{ stackLabel(workflow.boardId, workflow.targetStackId) }} - {{ notifyLabel(workflow.notifyMode) }} + {{ notifyLabel(workflow) }} {{ yesNo(workflow.enabled) }} @@ -89,7 +89,7 @@ placeholder="Ziel-Stapel wählen" />
- E-Mail bei verschobenen Karten + Benachrichtigung bei verschobenen Karten

- Jedes Board-Mitglied bekommt die E-Mail entsprechend seiner eigenen - Deck-Einstellung „Benachrichtigungen“ für dieses Board: bei „Alle Karten“ immer, + Jedes Board-Mitglied wird entsprechend seiner eigenen + Deck-Einstellung „Benachrichtigungen“ für dieses Board informiert: bei „Alle Karten“ immer, bei „Zugewiesene Karten“ nur für Karten, denen es zugewiesen ist, bei „Keine“ nie. - Mehrere Karten eines Laufs werden zu einer E-Mail zusammengefasst.

+
+ Benachrichtigen per + + {{ option.label }} + +

+ Mehrere Karten eines Laufs werden zu einer E-Mail zusammengefasst. + Nextcloud-Benachrichtigungen erscheinen pro Karte in der Glocke und als Push-Nachricht + in den Nextcloud-Apps auf Handy und Desktop. +

+ + Die App „Benachrichtigungen“ ist auf dieser Nextcloud nicht aktiviert. + Nextcloud-Benachrichtigungen werden deshalb nicht verschickt. + +
+ Workflow aktiv @@ -146,6 +167,15 @@ import { updateWorkflow, } from './api.js' +const props = defineProps({ + // Whether the notifications app is enabled for the current user, from + // the data attribute set in templates/settings/personal.php. + notificationsAvailable: { + type: Boolean, + default: true, + }, +}) + const loading = ref(true) const loadError = ref('') const workflows = ref([]) @@ -173,7 +203,7 @@ const boardOptions = computed(() => boards.value /** * Alphabetical by the columns as displayed, left to right: board, title, - * source stack, target stack, e-mail, active. + * source stack, target stack, notification, active. */ const sortedWorkflows = computed(() => { const sortKeys = (workflow) => [ @@ -181,7 +211,7 @@ const sortedWorkflows = computed(() => { workflow.title, stackLabel(workflow.boardId, workflow.sourceStackId), stackLabel(workflow.boardId, workflow.targetStackId), - notifyLabel(workflow.notifyMode), + notifyLabel(workflow), yesNo(workflow.enabled), ] return workflows.value @@ -245,19 +275,32 @@ function emptyForm() { sourceStackId: null, targetStackId: null, notifyMode: 'off', + notifyChannel: 'mail', enabled: true, } } // Values match NotificationPolicy::MODES on the server. const notifyOptions = [ - { value: 'off', label: 'Keine E-Mail', short: 'Keine' }, + { value: 'off', label: 'Keine Benachrichtigung', short: 'Keine' }, { value: 'owner', label: 'Nur an mich', short: 'Nur ich' }, { value: 'board', label: 'An Board-Mitglieder laut ihrer Deck-Einstellung', short: 'Board-Mitglieder' }, ] -function notifyLabel(mode) { - return notifyOptions.find((option) => option.value === mode)?.short ?? mode +// Values match NotificationPolicy::CHANNELS on the server. +const channelOptions = [ + { value: 'mail', label: 'E-Mail', short: 'E-Mail' }, + { value: 'notification', label: 'Nextcloud-Benachrichtigung (Push)', short: 'Push' }, + { value: 'both', label: 'E-Mail und Nextcloud-Benachrichtigung', short: 'E-Mail + Push' }, +] + +function notifyLabel(workflow) { + const mode = notifyOptions.find((option) => option.value === workflow.notifyMode)?.short ?? workflow.notifyMode + if (workflow.notifyMode === 'off') { + return mode + } + const channel = channelOptions.find((option) => option.value === workflow.notifyChannel)?.short ?? workflow.notifyChannel + return `${mode} (${channel})` } function boardLabel(boardId) { @@ -356,6 +399,7 @@ async function editWorkflow(workflow) { sourceStackId: workflow.sourceStackId, targetStackId: workflow.targetStackId, notifyMode: workflow.notifyMode, + notifyChannel: workflow.notifyChannel || 'mail', enabled: workflow.enabled, }) editingId.value = workflow.id diff --git a/src/main.js b/src/main.js index ba3b9d0..2a1c5f3 100644 --- a/src/main.js +++ b/src/main.js @@ -3,5 +3,7 @@ import PersonalSettings from './PersonalSettings.vue' const mountPoint = document.getElementById('workflow-deck-automation-personal-settings') if (mountPoint) { - createApp(PersonalSettings).mount(mountPoint) + createApp(PersonalSettings, { + notificationsAvailable: mountPoint.dataset.notificationsAvailable === '1', + }).mount(mountPoint) } diff --git a/templates/settings/personal.php b/templates/settings/personal.php index 1ee1517..969f4f9 100644 --- a/templates/settings/personal.php +++ b/templates/settings/personal.php @@ -1,4 +1,5 @@ -
+
diff --git a/tests/Unit/Service/NotificationPolicyTest.php b/tests/Unit/Service/NotificationPolicyTest.php index bed40fb..a6008da 100644 --- a/tests/Unit/Service/NotificationPolicyTest.php +++ b/tests/Unit/Service/NotificationPolicyTest.php @@ -53,4 +53,30 @@ class NotificationPolicyTest extends TestCase { $this->assertFalse(NotificationPolicy::isValidMode('')); $this->assertFalse(NotificationPolicy::isValidMode('all')); } + + public function testChannels(): void { + foreach (['mail', 'notification', 'both'] as $channel) { + $this->assertTrue(NotificationPolicy::isValidChannel($channel)); + } + $this->assertFalse(NotificationPolicy::isValidChannel('')); + $this->assertFalse(NotificationPolicy::isValidChannel('push')); + } + + public function testChannelDecidesMailAndNotification(): void { + $this->assertTrue(NotificationPolicy::sendsMail('mail')); + $this->assertFalse(NotificationPolicy::sendsNotification('mail')); + + $this->assertFalse(NotificationPolicy::sendsMail('notification')); + $this->assertTrue(NotificationPolicy::sendsNotification('notification')); + + $this->assertTrue(NotificationPolicy::sendsMail('both')); + $this->assertTrue(NotificationPolicy::sendsNotification('both')); + } + + public function testUnknownChannelFallsBackToMail(): void { + $this->assertTrue(NotificationPolicy::sendsMail('')); + $this->assertFalse(NotificationPolicy::sendsNotification('')); + $this->assertTrue(NotificationPolicy::sendsMail('bogus')); + $this->assertFalse(NotificationPolicy::sendsNotification('bogus')); + } }