Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
NotificationPreferenceService
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
4 / 4
10
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
 getPreferencesForUser
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 updatePreferences
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 applicableTypesForRole
100.00% covered (success)
100.00%
3 / 3
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 App\Domain\Notification\Repository\NotificationPreferenceRepositoryInterface;
9use InvalidArgumentException;
10
11/**
12 * Application logic for reading and writing a user's notification preferences.
13 *
14 * Hides two things from callers: (1) which types apply to a given role
15 * (admins see admin.* only — investor.* not yet implemented anyway), and
16 * (2) the "default OFF for unset types" view, so the GET endpoint always
17 * returns the full set of toggles for the role.
18 */
19final readonly class NotificationPreferenceService
20{
21    public function __construct(
22        private NotificationPreferenceRepositoryInterface $repository,
23    ) {}
24
25    /**
26     * Return the user's preferences with every applicable type included —
27     * unset types are reported as `enabled: false`. Frontend renders this
28     * as a complete toggle list without having to know what's "missing".
29     *
30     * @param int $userId
31     * @param string $userRole
32     * @return list<array{notificationType: string, channel: string, enabled: bool}>
33     */
34    public function getPreferencesForUser(int $userId, string $userRole): array
35    {
36        $applicableTypes = $this->applicableTypesForRole($userRole);
37        $stored = $this->repository->getByUserId($userId);
38
39        // Map of "type|channel" => enabled, for quick lookup
40        $storedMap = [];
41        foreach ($stored as $pref) {
42            $storedMap[$pref->notificationType . '|' . $pref->channel] = $pref->enabled;
43        }
44
45        $result = [];
46        foreach ($applicableTypes as $type) {
47            $key = $type . '|' . NotificationType::CHANNEL_EMAIL;
48            $result[] = [
49                'notificationType' => $type,
50                'channel' => NotificationType::CHANNEL_EMAIL,
51                'enabled' => $storedMap[$key] ?? false,
52            ];
53        }
54
55        return $result;
56    }
57
58    /**
59     * Bulk-update a user's preferences. Types outside the user's applicable
60     * set are rejected to prevent privilege escalation (e.g., an investor
61     * toggling admin.* preferences). Shape of each entry is enforced at
62     * the action boundary; this method trusts the typed array.
63     *
64     * @param list<array{notificationType: string, channel: string, enabled: bool}> $preferences
65     * @param int $userId
66     * @param string $userRole
67     */
68    public function updatePreferences(int $userId, string $userRole, array $preferences): void
69    {
70        $applicableTypes = $this->applicableTypesForRole($userRole);
71        $applicableSet = array_flip($applicableTypes);
72
73        foreach ($preferences as $pref) {
74            if (!isset($applicableSet[$pref['notificationType']])) {
75                throw new InvalidArgumentException(sprintf(
76                    'Notification type "%s" is not available for role "%s".',
77                    $pref['notificationType'],
78                    $userRole,
79                ));
80            }
81
82            if ($pref['channel'] !== NotificationType::CHANNEL_EMAIL) {
83                throw new InvalidArgumentException(sprintf('Channel "%s" is not supported.', $pref['channel']));
84            }
85
86            $this->repository->upsert($userId, $pref['notificationType'], $pref['channel'], $pref['enabled']);
87        }
88    }
89
90    /**
91     * @param string $userRole
92     * @return list<string>
93     */
94    private function applicableTypesForRole(string $userRole): array
95    {
96        if (in_array($userRole, ['admin', 'super_admin'], true)) {
97            return NotificationType::adminTypes();
98        }
99
100        // Investor role has no applicable types in PR A — investor.* will
101        // be added in a later iteration.
102        return [];
103    }
104}