Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
NotificationDispatcher
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
3 / 3
9
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dispatch
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 resolveRecipients
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Notification\Service;
6
7use App\Domain\Notification\Data\EmailMessage;
8use App\Domain\Notification\Data\NotificationType;
9use App\Domain\Notification\Repository\NotificationPreferenceRepositoryInterface;
10use Psr\Log\LoggerInterface;
11use Throwable;
12
13/**
14 * Resolves a notification type to recipients and sends them an email.
15 *
16 * Recipients come from {@see NotificationPreferenceRepository}. During the
17 * FSC-113 transition the env.php['email']['admin_recipients'] array acts
18 * as an emergency fallback so a fresh deploy doesn't go silent before any
19 * admin has toggled their preferences. Once admins opt in via the
20 * frontend (FSC-113 PR B), the fallback path stops firing and can be
21 * dropped in a future cleanup.
22 *
23 * Transport failures are caught and logged at WARNING — a flaky relay
24 * can't make a successful DB write exit non-zero.
25 */
26final readonly class NotificationDispatcher
27{
28    /**
29     * @param list<string> $fallbackAdminRecipients
30     * @param EmailServiceInterface $email
31     * @param LoggerInterface $logger
32     * @param NotificationPreferenceRepositoryInterface $preferences
33     */
34    public function __construct(
35        private EmailServiceInterface $email,
36        private LoggerInterface $logger,
37        private NotificationPreferenceRepositoryInterface $preferences,
38        private array $fallbackAdminRecipients,
39    ) {}
40
41    /**
42     * Dispatch a notification to all users opted into the given type.
43     * @param string $notificationType
44     * @param string $subject
45     * @param string $bodyText
46     */
47    public function dispatch(string $notificationType, string $subject, string $bodyText): void
48    {
49        $recipients = $this->resolveRecipients($notificationType);
50
51        if ($recipients === []) {
52            return;
53        }
54
55        foreach ($recipients as $to) {
56            try {
57                $this->email->send(new EmailMessage(
58                    to: $to,
59                    subject: $subject,
60                    bodyHtml: nl2br(htmlspecialchars($bodyText, ENT_QUOTES, 'UTF-8')),
61                    bodyText: $bodyText,
62                    // Notifications carry no secrets — store the body so it's
63                    // viewable in the in-app email history (FSC-110).
64                    sensitive: false,
65                ));
66            } catch (Throwable $e) {
67                $this->logger->warning('Failed to send notification', [
68                    'recipient' => $to,
69                    'notificationType' => $notificationType,
70                    'subject' => $subject,
71                    'error' => $e->getMessage(),
72                ]);
73            }
74        }
75    }
76
77    /**
78     * @param string $notificationType
79     * @return list<string>
80     */
81    private function resolveRecipients(string $notificationType): array
82    {
83        if (NotificationType::isAdminType($notificationType)) {
84            $recipients = $this->preferences->getEnabledRecipientsForAdminType($notificationType);
85
86            if ($recipients !== []) {
87                return $recipients;
88            }
89
90            // Transitional fallback: env.php list. Logged at INFO so the
91            // moment-of-use is visible during the migration period.
92            if ($this->fallbackAdminRecipients !== []) {
93                $this->logger->info('No preference recipients; falling back to env.php admin_recipients', [
94                    'notificationType' => $notificationType,
95                ]);
96
97                return $this->fallbackAdminRecipients;
98            }
99
100            return [];
101        }
102
103        // investor.* and other non-admin types not implemented in PR A.
104        return [];
105    }
106}