From 76123447262027ee8b84217447c12a8dff1f720c Mon Sep 17 00:00:00 2001 From: Patrick Niebeling Date: Wed, 30 Sep 2026 12:31:01 +0200 Subject: [PATCH] Send moved-card notices as Nextcloud notifications too Each workflow now has a notify channel (mail, notification or both) next to its recipient mode. Notifications go out per card through OCP\Notification\IManager, so they show up in the bell and as push on the mobile and desktop clients. Without the notifications app nothing is sent. The one-off "workflow disabled" notice is sent as a notification as well. Existing workflows keep mail (migration Version1004). Bumps to 35.3.0 so the migration runs. --- CLAUDE.md | 19 ++- DEVELOPMENT.md | 6 +- README.md | 43 ++++-- appinfo/info.xml | 6 +- lib/AppInfo/Application.php | 7 + lib/Controller/WorkflowController.php | 19 ++- lib/Db/Workflow.php | 5 + .../Version1004Date20261002120000.php | 41 +++++ lib/Notification/Notifier.php | 146 ++++++++++++++++++ lib/Service/NotificationMailer.php | 20 ++- lib/Service/NotificationPolicy.php | 26 +++- lib/Service/NotificationPusher.php | 133 ++++++++++++++++ lib/Service/WorkflowRunner.php | 67 +++++--- lib/Settings/Personal.php | 16 +- package.json | 2 +- src/PersonalSettings.vue | 66 ++++++-- src/main.js | 4 +- templates/settings/personal.php | 3 +- tests/Unit/Service/NotificationPolicyTest.php | 26 ++++ 19 files changed, 585 insertions(+), 70 deletions(-) create mode 100644 lib/Migration/Version1004Date20261002120000.php create mode 100644 lib/Notification/Notifier.php create mode 100644 lib/Service/NotificationPusher.php 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')); + } }