Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
55 / 55 |
|
100.00% |
4 / 4 |
CRAP | |
100.00% |
1 / 1 |
| ConsentService | |
100.00% |
55 / 55 |
|
100.00% |
4 / 4 |
14 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| applyDecisions | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
5 | |||
| getCurrentForUser | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
6 | |||
| currentActionMap | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Domain\Consent\Service; |
| 6 | |
| 7 | use App\Domain\Consent\Data\ConsentCopy; |
| 8 | use App\Domain\Consent\Data\ConsentRecord; |
| 9 | use App\Domain\Consent\Data\ConsentType; |
| 10 | use App\Domain\Consent\Repository\ConsentRepositoryInterface; |
| 11 | use InvalidArgumentException; |
| 12 | |
| 13 | /** |
| 14 | * Application logic for capturing and reading TCPA consent (FSC-87). |
| 15 | * |
| 16 | * The ledger is append-only, so {@see applyDecisions()} writes a new row only |
| 17 | * when a decision actually changes the current state (or there is no prior |
| 18 | * state, e.g. at registration). The exact language + version recorded come from |
| 19 | * the server-owned {@see ConsentCopy} registry, never from the caller. |
| 20 | * |
| 21 | * "Transactional consent is required" is a registration-flow rule enforced at |
| 22 | * the action boundary; this service simply records the decisions it is given. |
| 23 | */ |
| 24 | final readonly class ConsentService |
| 25 | { |
| 26 | public function __construct( |
| 27 | private ConsentRepositoryInterface $repository, |
| 28 | ) {} |
| 29 | |
| 30 | /** |
| 31 | * Record a batch of consent decisions, appending a ledger row for each one |
| 32 | * that changes the user's current state. Returns the rows that were |
| 33 | * appended (empty if nothing changed). |
| 34 | * |
| 35 | * @param list<array{consentType: string, granted: bool}> $decisions |
| 36 | * @param int $userId |
| 37 | * @param string $source |
| 38 | * @param ?string $ipAddress |
| 39 | * @param ?string $userAgent |
| 40 | * @return list<ConsentRecord> |
| 41 | */ |
| 42 | public function applyDecisions( |
| 43 | int $userId, |
| 44 | array $decisions, |
| 45 | string $source, |
| 46 | ?string $ipAddress, |
| 47 | ?string $userAgent, |
| 48 | ): array { |
| 49 | if (!ConsentType::isValidSource($source)) { |
| 50 | throw new InvalidArgumentException(sprintf('Unknown consent source "%s".', $source)); |
| 51 | } |
| 52 | |
| 53 | $currentActions = $this->currentActionMap($userId); |
| 54 | |
| 55 | $appended = []; |
| 56 | foreach ($decisions as $decision) { |
| 57 | $type = $decision['consentType']; |
| 58 | if (!ConsentType::isValid($type)) { |
| 59 | throw new InvalidArgumentException(sprintf('Unknown consent type "%s".', $type)); |
| 60 | } |
| 61 | |
| 62 | $action = ConsentType::actionFor($decision['granted']); |
| 63 | |
| 64 | // Append only when the state actually changes — keeps the ledger |
| 65 | // meaningful and avoids duplicate rows on no-op settings saves. |
| 66 | if (($currentActions[$type] ?? null) === $action) { |
| 67 | continue; |
| 68 | } |
| 69 | |
| 70 | $copy = ConsentCopy::current($type); |
| 71 | $consentId = $this->repository->append( |
| 72 | $userId, |
| 73 | $type, |
| 74 | $action, |
| 75 | $copy['version'], |
| 76 | $copy['language'], |
| 77 | $ipAddress, |
| 78 | $userAgent, |
| 79 | $source, |
| 80 | ); |
| 81 | |
| 82 | $appended[] = new ConsentRecord([ |
| 83 | 'consentId' => $consentId, |
| 84 | 'userId' => $userId, |
| 85 | 'consentType' => $type, |
| 86 | 'action' => $action, |
| 87 | 'version' => $copy['version'], |
| 88 | 'languageShown' => $copy['language'], |
| 89 | 'ipAddress' => $ipAddress, |
| 90 | 'userAgent' => $userAgent, |
| 91 | 'source' => $source, |
| 92 | 'createdAt' => 'now', |
| 93 | ]); |
| 94 | } |
| 95 | |
| 96 | return $appended; |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * Current consent state for every known type, defaulting unset types to |
| 101 | * not-granted. Shaped for the API / settings UI. |
| 102 | * |
| 103 | * @param int $userId |
| 104 | * @return list<array{consentType: string, granted: bool, required: bool, version: string, updatedAt: ?string}> |
| 105 | */ |
| 106 | public function getCurrentForUser(int $userId): array |
| 107 | { |
| 108 | $current = []; |
| 109 | foreach ($this->repository->getCurrentForUser($userId) as $record) { |
| 110 | $current[$record->consentType] = $record; |
| 111 | } |
| 112 | |
| 113 | $required = array_flip(ConsentType::requiredTypes()); |
| 114 | |
| 115 | $result = []; |
| 116 | foreach (ConsentType::allTypes() as $type) { |
| 117 | $record = $current[$type] ?? null; |
| 118 | $result[] = [ |
| 119 | 'consentType' => $type, |
| 120 | 'granted' => $record !== null && $record->isGranted(), |
| 121 | 'required' => isset($required[$type]), |
| 122 | 'version' => $record !== null ? $record->version : ConsentCopy::current($type)['version'], |
| 123 | 'updatedAt' => $record !== null ? $record->createdAt : null, |
| 124 | ]; |
| 125 | } |
| 126 | |
| 127 | return $result; |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * @param int $userId |
| 132 | * @return array<string, string> consentType => latest action |
| 133 | */ |
| 134 | private function currentActionMap(int $userId): array |
| 135 | { |
| 136 | $map = []; |
| 137 | foreach ($this->repository->getCurrentForUser($userId) as $record) { |
| 138 | $map[$record->consentType] = $record->action; |
| 139 | } |
| 140 | |
| 141 | return $map; |
| 142 | } |
| 143 | } |