Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
ConsentService
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
4 / 4
14
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
 applyDecisions
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
5
 getCurrentForUser
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 currentActionMap
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Consent\Service;
6
7use App\Domain\Consent\Data\ConsentCopy;
8use App\Domain\Consent\Data\ConsentRecord;
9use App\Domain\Consent\Data\ConsentType;
10use App\Domain\Consent\Repository\ConsentRepositoryInterface;
11use 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 */
24final 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}