Send moved-card notices as Nextcloud notifications too
Build package / xml-lint (push) Successful in 33s
Build package / php-lint (8.5) (push) Successful in 1m48s
Build package / php-lint (8.4) (push) Successful in 1m50s
Build package / unit-tests (push) Successful in 1m52s
Build package / package (push) Successful in 1m38s
Build package / xml-lint (push) Successful in 33s
Build package / php-lint (8.5) (push) Successful in 1m48s
Build package / php-lint (8.4) (push) Successful in 1m50s
Build package / unit-tests (push) Successful in 1m52s
Build package / package (push) Successful in 1m38s
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.
This commit is contained in:
1 parent
35903772eb
commit
7612344726
19 files changed
+585
-70
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Adds `notify_channel` (mail / notification / both, see
|
||||
* NotificationPolicy::CHANNELS): how the recipients chosen by `notify_mode`
|
||||
* hear about moved cards. Existing workflows get `mail`, the only channel
|
||||
* there was, so nothing changes for them.
|
||||
*/
|
||||
class Version1004Date20261002120000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('wfda_workflows')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $schema->getTable('wfda_workflows');
|
||||
if ($table->hasColumn('notify_channel')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table->addColumn('notify_channel', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 16,
|
||||
'default' => 'mail',
|
||||
]);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Notification;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\AppInfo\Application;
|
||||
use OCA\WorkflowDeckAutomation\Service\NotificationMailer;
|
||||
use OCA\WorkflowDeckAutomation\Service\NotificationPusher;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Notification\INotification;
|
||||
use OCP\Notification\INotifier;
|
||||
use OCP\Notification\UnknownNotificationException;
|
||||
|
||||
/**
|
||||
* Turns the values NotificationPusher stored into text. German only, like
|
||||
* the rest of the app — the language Nextcloud asks for is ignored.
|
||||
*
|
||||
* Deliberately asks Deck nothing: the stored titles are the ones from the
|
||||
* moment the card moved, and a Deck lookup here would run as whoever
|
||||
* happens to fetch notifications (see CLAUDE.md on Deck's frozen uid).
|
||||
*/
|
||||
class Notifier implements INotifier {
|
||||
public function __construct(
|
||||
private IURLGenerator $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getID(): string {
|
||||
return Application::APP_ID;
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
return 'Deck Workflow-Automatisierung';
|
||||
}
|
||||
|
||||
public function prepare(INotification $notification, string $languageCode): INotification {
|
||||
if ($notification->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<string, array{name: string}> $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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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:<boardId>: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():
|
||||
*
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Service;
|
||||
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\WorkflowDeckAutomation\AppInfo\Application;
|
||||
use OCA\WorkflowDeckAutomation\Db\Workflow;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\IUser;
|
||||
use OCP\Notification\IManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Nextcloud notifications (the bell, and from there push to the mobile and
|
||||
* desktop clients) — the counterpart to NotificationMailer. Only the raw
|
||||
* values are stored here; Notification\Notifier turns them into text when a
|
||||
* client fetches the notification.
|
||||
*
|
||||
* Without the notifications app nothing is sent and nothing is logged as an
|
||||
* error: the app is optional, and IManager would just drop the notification.
|
||||
*/
|
||||
class NotificationPusher {
|
||||
public const SUBJECT_CARD_MOVED = 'card_moved';
|
||||
public const SUBJECT_WORKFLOW_DISABLED = 'workflow_disabled';
|
||||
|
||||
public const OBJECT_CARD = 'card';
|
||||
public const OBJECT_WORKFLOW = 'workflow';
|
||||
|
||||
private const NOTIFICATIONS_APP = 'notifications';
|
||||
|
||||
public function __construct(
|
||||
private IManager $notificationManager,
|
||||
private IAppManager $appManager,
|
||||
private ITimeFactory $timeFactory,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isAvailableFor(IUser $user): bool {
|
||||
try {
|
||||
return $this->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<array{workflow: Workflow, card: Card, targets: array{board: string, sourceStack: string, targetStack: string}}> $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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, list<array{workflow: Workflow, card: Card, targets: array{board: string, sourceStack: string, targetStack: string}}>>
|
||||
*/
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user