Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
CronEmailNotifier
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
5 / 5
6
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
 notifyJobSuccess
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 notifyJobAnomaly
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 notifyJobFailure
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 typeFor
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Notification\Service;
6
7use App\Domain\Notification\Data\NotificationType;
8use InvalidArgumentException;
9use Throwable;
10
11/**
12 * Cron-summary subject/body formatter that delegates delivery to the
13 * shared {@see NotificationDispatcher}.
14 *
15 * Each scheduled job has a matching `admin.cron.*` notification type;
16 * recipients are resolved by the dispatcher (preferences first, env.php
17 * fallback during the FSC-113 transition). The three call sites in the
18 * console commands stay unchanged — this class is the bridge between the
19 * job-name strings they already use and the typed notification system.
20 */
21final readonly class CronEmailNotifier
22{
23    /**
24     * Map of human-readable job name (used in subject lines) to the
25     * notification type that controls its recipient list. Adding a new
26     * scheduled job means adding an entry here AND a CHECK constraint
27     * value in a migration.
28     *
29     * @var array<string, string>
30     */
31    private const array NOTIFICATION_TYPE_MAP = [
32        'Daily interest accrual' => NotificationType::ADMIN_CRON_DAILY_ACCRUAL,
33        'Monthly interest post' => NotificationType::ADMIN_CRON_MONTHLY_POST,
34        'Daily balance snapshot' => NotificationType::ADMIN_CRON_BALANCE_SNAPSHOT,
35    ];
36
37    public function __construct(
38        private NotificationDispatcher $dispatcher,
39        private string $environment,
40    ) {}
41
42    public function notifyJobSuccess(string $jobName, string $summary): void
43    {
44        $this->dispatcher->dispatch(
45            $this->typeFor($jobName),
46            sprintf('[FlowState %s] %s — %s', $this->environment, $jobName, $summary),
47            $summary,
48        );
49    }
50
51    public function notifyJobAnomaly(string $jobName, string $summary): void
52    {
53        $this->dispatcher->dispatch(
54            $this->typeFor($jobName),
55            sprintf('[FlowState %s] ANOMALY: %s — %s', $this->environment, $jobName, $summary),
56            $summary,
57        );
58    }
59
60    public function notifyJobFailure(string $jobName, Throwable $error): void
61    {
62        $body = sprintf(
63            "Job: %s\nEnvironment: %s\nError: %s\nFile: %s:%d\n\n%s",
64            $jobName,
65            $this->environment,
66            $error->getMessage(),
67            $error->getFile(),
68            $error->getLine(),
69            $error->getTraceAsString(),
70        );
71
72        $this->dispatcher->dispatch(
73            $this->typeFor($jobName),
74            sprintf('[FlowState %s] FAILED: %s', $this->environment, $jobName),
75            $body,
76        );
77    }
78
79    private function typeFor(string $jobName): string
80    {
81        if (!isset(self::NOTIFICATION_TYPE_MAP[$jobName])) {
82            throw new InvalidArgumentException(sprintf(
83                'No notification type registered for job "%s" — add an entry to CronEmailNotifier::NOTIFICATION_TYPE_MAP.',
84                $jobName,
85            ));
86        }
87
88        return self::NOTIFICATION_TYPE_MAP[$jobName];
89    }
90}