Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.29% covered (warning)
89.29%
50 / 56
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
UpdateMyConsentsAction
89.29% covered (warning)
89.29%
50 / 56
33.33% covered (danger)
33.33%
1 / 3
16.31
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __invoke
91.67% covered (success)
91.67%
33 / 36
0.00% covered (danger)
0.00%
0 / 1
7.03
 validateConsents
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
8.25
1<?php
2
3declare(strict_types=1);
4
5namespace App\Action\Consent;
6
7use App\Domain\Audit\AuditService;
8use App\Domain\Auth\Data\UserAuthData;
9use App\Domain\Consent\Data\ConsentType;
10use App\Domain\Consent\Service\ConsentService;
11use App\Domain\Exception\AuthenticationException;
12use App\Domain\Exception\BadRequestException;
13use App\Renderer\JsonRenderer;
14use InvalidArgumentException;
15use Psr\Http\Message\ResponseInterface;
16use Psr\Http\Message\ServerRequestInterface;
17
18use function array_key_exists;
19use function is_array;
20use function is_bool;
21use function is_string;
22use function sprintf;
23
24/**
25 * PUT /api/me/consents
26 *
27 * Body: { "consents": [ {"consentType": string, "granted": bool}, ... ] }
28 *
29 * Lets an investor grant or revoke the OPTIONAL communication consents (SMS,
30 * automated calls) after registration. The required transactional consent is a
31 * condition of holding an account and cannot be changed here — attempting to
32 * include it is a 400. Each change appends a row to the consent ledger
33 * (source = settings) and is mirrored to the audit trail (FSC-87).
34 */
35final readonly class UpdateMyConsentsAction
36{
37    public function __construct(
38        private ConsentService $consentService,
39        private AuditService $auditService,
40        private JsonRenderer $renderer,
41    ) {}
42
43    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
44    {
45        $user = $request->getAttribute('user');
46        if (!$user instanceof UserAuthData) {
47            throw new AuthenticationException('User not authenticated');
48        }
49
50        $body = (array)$request->getParsedBody();
51        $raw = $body['consents'] ?? null;
52        if (!is_array($raw)) {
53            throw new BadRequestException('Body must include a "consents" array.');
54        }
55
56        $decisions = $this->validateConsents($raw);
57
58        $clientIp = $request->getAttribute('clientIp');
59        $ipAddress = is_string($clientIp) ? $clientIp : null;
60        $userAgent = $request->getHeaderLine('User-Agent') ?: null;
61
62        try {
63            $records = $this->consentService->applyDecisions(
64                $user->userId,
65                $decisions,
66                ConsentType::SOURCE_SETTINGS,
67                $ipAddress,
68                $userAgent,
69            );
70        } catch (InvalidArgumentException $e) {
71            throw new BadRequestException($e->getMessage());
72        }
73
74        foreach ($records as $record) {
75            $this->auditService->logConsent(
76                userId: $user->userId,
77                consentRecordId: $record->consentId,
78                consentType: $record->consentType,
79                granted: $record->isGranted(),
80                version: $record->version,
81                source: ConsentType::SOURCE_SETTINGS,
82                changedByUsername: $user->username,
83                ipAddress: $ipAddress,
84                userAgent: $userAgent,
85            );
86        }
87
88        // Return the freshly-updated state so clients don't need a follow-up GET.
89        return $this->renderer->json($response, [
90            'success' => true,
91            'data' => ['consents' => $this->consentService->getCurrentForUser($user->userId)],
92        ]);
93    }
94
95    /**
96     * Coerce raw input into typed decisions, rejecting anything but the
97     * optional consent types — the required transactional consent is read-only
98     * once an account exists.
99     *
100     * @param array<int|string, mixed> $raw
101     *
102     * @return list<array{consentType: string, granted: bool}>
103     */
104    private function validateConsents(array $raw): array
105    {
106        $out = [];
107        foreach (array_values($raw) as $i => $entry) {
108            if (!is_array($entry)) {
109                throw new BadRequestException(sprintf('consents[%d] must be an object.', $i));
110            }
111            if (!isset($entry['consentType']) || !is_string($entry['consentType'])) {
112                throw new BadRequestException(sprintf('consents[%d].consentType must be a string.', $i));
113            }
114            if (!array_key_exists('granted', $entry) || !is_bool($entry['granted'])) {
115                throw new BadRequestException(sprintf('consents[%d].granted must be a boolean.', $i));
116            }
117            if (!ConsentType::isOptional($entry['consentType'])) {
118                throw new BadRequestException(sprintf(
119                    'consents[%d].consentType "%s" cannot be changed here.',
120                    $i,
121                    $entry['consentType'],
122                ));
123            }
124
125            $out[] = [
126                'consentType' => $entry['consentType'],
127                'granted' => $entry['granted'],
128            ];
129        }
130
131        return $out;
132    }
133}