Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
NotificationDispatcher
100.00% covered (success)
100.00%
29 / 29
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%
17 / 17
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                ));
63            } catch (Throwable $e) {
64                $this->logger->warning('Failed to send notification', [
65                    'recipient' => $to,
66                    'notificationType' => $notificationType,
67                    'subject' => $subject,
68                    'error' => $e->getMessage(),
69                ]);
70            }
71        }
72    }
73
74    /**
75     * @param string $notificationType
76     * @return list<string>
77     */
78    private function resolveRecipients(string $notificationType): array
79    {
80        if (NotificationType::isAdminType($notificationType)) {
81            $recipients = $this->preferences->getEnabledRecipientsForAdminType($notificationType);
82
83            if ($recipients !== []) {
84                return $recipients;
85            }
86
87            // Transitional fallback: env.php list. Logged at INFO so the
88            // moment-of-use is visible during the migration period.
89            if ($this->fallbackAdminRecipients !== []) {
90                $this->logger->info('No preference recipients; falling back to env.php admin_recipients', [
91                    'notificationType' => $notificationType,
92                ]);
93
94                return $this->fallbackAdminRecipients;
95            }
96
97            return [];
98        }
99
100        // investor.* and other non-admin types not implemented in PR A.
101        return [];
102    }
103}