Deck 1.18 added a start date to cards (Card::$startdate), so a workflow
no longer has to mean "overdue". Each rule now carries a date_field
('due' | 'start'), chosen in the settings form next to board and stacks.
Existing rules keep the due date: the new column's default supplies it
for every stored row, so there is no backfill step, and
Workflow::getDateFieldOrDue() covers entities that were never near the
database as well as hand-edited values. The controller is the one place
that rejects an unknown value instead of normalising it - a client
asking for a rule it would not get should hear about it.
The probe for the field is property_exists(), not method_exists():
Deck's entities declare no getter as real code - getStartdate(),
getDuedate(), even getId() all go through Entity::__call(), which
method_exists() ignores by definition. A method_exists() guard would
have been false on every Deck version and would have turned every
start-date rule into a silent no-op.
assertDeckAvailable() now also refuses Deck older than 1.18.0. That
floor cannot live in appinfo/info.xml - neither the server's nor the app
store's schema has an app-to-app dependency element - but
<nextcloud min-version="34"> already implies it, since 1.18.x is the
only Deck release line published for Nextcloud 34.
isOverdue() becomes hasPassed(): one function for both dates, since the
comparison and the argument against Deck's day-truncating
getDaysUntilDue() are identical for either.
Also carries a pending NcSelect icon-alignment fix that was already in
the working tree.
Bump to 34.1.0.
189 lines
7.2 KiB
PHP
189 lines
7.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\WorkflowDeckAutomation\Service;
|
|
|
|
use OCA\Deck\Db\Card;
|
|
use OCA\WorkflowDeckAutomation\Db\Workflow;
|
|
use OCP\IL10N;
|
|
use OCP\IURLGenerator;
|
|
use OCP\IUser;
|
|
use OCP\Mail\IEMailTemplate;
|
|
use OCP\Mail\IMailer;
|
|
use OCP\Util;
|
|
use Psr\Log\LoggerInterface;
|
|
use Throwable;
|
|
|
|
class NotificationMailer {
|
|
/**
|
|
* Descriptions are unbounded in Deck; keep the mail readable and well
|
|
* clear of any MTA size limits.
|
|
*/
|
|
private const MAX_DESCRIPTION_LENGTH = 2000;
|
|
|
|
public function __construct(
|
|
private IMailer $mailer,
|
|
private IURLGenerator $urlGenerator,
|
|
private IL10N $l10n,
|
|
private LoggerInterface $logger,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @param array{board: string, sourceStack: string, targetStack: string} $targets
|
|
* titles resolved by DeckIntegrationService::describeTargets()
|
|
*/
|
|
public function sendCardMovedNotification(IUser $user, Workflow $workflow, Card $card, array $targets): void {
|
|
$email = $user->getEMailAddress();
|
|
if ($email === null || $email === '') {
|
|
$this->logger->info('Skipping notification for workflow ' . $workflow->getId() . ': user ' . $user->getUID() . ' has no email address', [
|
|
'app' => 'workflow_deck_automation',
|
|
]);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$cardLink = $this->urlGenerator->getAbsoluteURL(
|
|
'/apps/deck/board/' . $workflow->getBoardId() . '/card/' . $card->getId(),
|
|
);
|
|
|
|
$template = $this->mailer->createEMailTemplate('workflow_deck_automation.CardMoved', [
|
|
'cardTitle' => $card->getTitle(),
|
|
'workflowTitle' => $workflow->getTitle(),
|
|
]);
|
|
$template->setSubject($this->l10n->t('Deck card moved: %s', [$card->getTitle()]));
|
|
$template->addHeader();
|
|
$template->addHeading($this->l10n->t('A card was moved automatically'), false);
|
|
// Two whole strings rather than one sentence with an interpolated
|
|
// reason: the trigger is the point of the mail, and a translator
|
|
// needs to see it in the sentence to get the grammar right.
|
|
$reason = $workflow->getDateFieldOrDue() === Workflow::DATE_FIELD_START
|
|
? $this->l10n->t(
|
|
'The card "%1$s" was moved because its start date has been reached (workflow "%2$s").',
|
|
[$card->getTitle(), $workflow->getTitle()],
|
|
)
|
|
: $this->l10n->t(
|
|
'The card "%1$s" was moved because it is overdue (workflow "%2$s").',
|
|
[$card->getTitle(), $workflow->getTitle()],
|
|
);
|
|
$template->addBodyText($reason);
|
|
// Single-argument addBodyText() runs htmlspecialchars() itself, so
|
|
// these board- and stack-titles (user input) are safe as-is —
|
|
// unlike the description below, which passes both parts.
|
|
$template->addBodyText($this->l10n->t(
|
|
'Board "%1$s": moved from "%2$s" to "%3$s".',
|
|
[$targets['board'], $targets['sourceStack'], $targets['targetStack']],
|
|
));
|
|
$this->addCardDescription($template, $card);
|
|
$template->addBodyButton($this->l10n->t('Open card'), $cardLink);
|
|
$template->addFooter();
|
|
|
|
$message = $this->mailer->createMessage();
|
|
$message->setTo([$email => $user->getDisplayName()]);
|
|
$message->setFrom([Util::getDefaultEmailAddress('noreply') => $this->l10n->t('Deck Workflow Automation')]);
|
|
$message->useTemplate($template);
|
|
|
|
$failedRecipients = $this->mailer->send($message);
|
|
if (!empty($failedRecipients)) {
|
|
$this->logger->error('Notification mail for card ' . $card->getId() . ' failed for: ' . implode(', ', $failedRecipients), [
|
|
'app' => 'workflow_deck_automation',
|
|
]);
|
|
}
|
|
} catch (Throwable $e) {
|
|
$this->logger->error('Could not send notification mail for card ' . $card->getId() . ': ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'exception' => $e,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One-off notice that a workflow was switched off because the board or
|
|
* stack it points at is gone.
|
|
*
|
|
* @param WorkflowRunner::TARGET_* $brokenTarget
|
|
*/
|
|
public function sendWorkflowDisabledNotification(IUser $user, Workflow $workflow, string $brokenTarget): void {
|
|
$email = $user->getEMailAddress();
|
|
if ($email === null || $email === '') {
|
|
$this->logger->info('Cannot notify about disabled workflow ' . $workflow->getId() . ': user ' . $user->getUID() . ' has no email address', [
|
|
'app' => 'workflow_deck_automation',
|
|
]);
|
|
return;
|
|
}
|
|
|
|
$reason = match ($brokenTarget) {
|
|
WorkflowRunner::TARGET_SOURCE_STACK => $this->l10n->t('its source stack no longer exists'),
|
|
WorkflowRunner::TARGET_TARGET_STACK => $this->l10n->t('its target stack no longer exists'),
|
|
default => $this->l10n->t('its board no longer exists or is no longer available to you'),
|
|
};
|
|
|
|
try {
|
|
$settingsLink = $this->urlGenerator->getAbsoluteURL('/settings/user/workflow_deck_automation');
|
|
|
|
$template = $this->mailer->createEMailTemplate('workflow_deck_automation.WorkflowDisabled', [
|
|
'workflowTitle' => $workflow->getTitle(),
|
|
]);
|
|
$template->setSubject($this->l10n->t('Deck workflow deactivated: %s', [$workflow->getTitle()]));
|
|
$template->addHeader();
|
|
$template->addHeading($this->l10n->t('A workflow was deactivated'), false);
|
|
$template->addBodyText($this->l10n->t(
|
|
'The workflow "%1$s" was switched off automatically because %2$s. No cards are being moved by it any more.',
|
|
[$workflow->getTitle(), $reason],
|
|
));
|
|
$template->addBodyText($this->l10n->t(
|
|
'Delete the workflow or point it at an existing board and stack to reactivate it. You will not be reminded about this workflow again.',
|
|
));
|
|
$template->addBodyButton($this->l10n->t('Open settings'), $settingsLink);
|
|
$template->addFooter();
|
|
|
|
$message = $this->mailer->createMessage();
|
|
$message->setTo([$email => $user->getDisplayName()]);
|
|
$message->setFrom([Util::getDefaultEmailAddress('noreply') => $this->l10n->t('Deck Workflow Automation')]);
|
|
$message->useTemplate($template);
|
|
|
|
$failedRecipients = $this->mailer->send($message);
|
|
if (!empty($failedRecipients)) {
|
|
$this->logger->error('Deactivation mail for workflow ' . $workflow->getId() . ' failed for: ' . implode(', ', $failedRecipients), [
|
|
'app' => 'workflow_deck_automation',
|
|
]);
|
|
}
|
|
} catch (Throwable $e) {
|
|
$this->logger->error('Could not send deactivation mail for workflow ' . $workflow->getId() . ': ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'exception' => $e,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Appends the card's description, if it has one.
|
|
*
|
|
* Deck stores the description as Markdown. It is sent as-is rather than
|
|
* rendered: pulling a Markdown parser in just for the mail would be a
|
|
* lot of machinery, and unrendered Markdown still reads fine.
|
|
*/
|
|
private function addCardDescription(IEMailTemplate $template, Card $card): void {
|
|
$description = trim((string)$card->getDescription());
|
|
if ($description === '') {
|
|
return;
|
|
}
|
|
|
|
if (mb_strlen($description) > self::MAX_DESCRIPTION_LENGTH) {
|
|
$description = mb_substr($description, 0, self::MAX_DESCRIPTION_LENGTH) . ' […]';
|
|
}
|
|
|
|
$template->addBodyText($this->l10n->t('Card content:'));
|
|
|
|
// addBodyText() only runs htmlspecialchars() when it has to build the
|
|
// plain-text part itself. Passing both parts means escaping is on us
|
|
// -- and it has to be, because a card description is user input that
|
|
// would otherwise land unescaped in the HTML mail.
|
|
$template->addBodyText(
|
|
nl2br(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), false),
|
|
$description,
|
|
);
|
|
}
|
|
}
|