Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.33% covered (success)
93.33%
84 / 90
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserPreferencesApiController
93.26% covered (success)
93.26%
83 / 89
75.00% covered (warning)
75.00%
6 / 8
29.26
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
 list
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 get
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 save
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 executePreferenceSave
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
2.29
 parseJsonPayload
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 reset
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 resolveUserId
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Preference\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Preference\Application\Command\ResetUserPreferencesCommand;
12use App\Core\Preference\Application\Command\ResetUserPreferencesHandler;
13use App\Core\Preference\Application\Command\SaveUserPreferenceCommand;
14use App\Core\Preference\Application\Command\SaveUserPreferenceHandler;
15use App\Core\Preference\Application\DTO\UserPreferenceDTO;
16use App\Core\Preference\Domain\Model\PreferenceScope;
17use App\Core\Preference\Domain\Repository\UserPreferenceRepositoryInterface;
18use App\Core\Preference\Domain\Service\ConfigurationCascadeResolverInterface;
19use App\Shared\Infrastructure\Http\ApiResponseTrait;
20use JsonException;
21use Psr\Http\Message\ResponseFactoryInterface;
22use Psr\Http\Message\ResponseInterface;
23use Psr\Http\Message\ServerRequestInterface;
24use Throwable;
25
26/**
27 * REST API Controller for managing hierarchical user preferences.
28 *
29 * @package App\Core\Preference\Presentation\Api
30 */
31final readonly class UserPreferencesApiController
32{
33    use ApiResponseTrait;
34
35    /**
36     * UserPreferencesApiController constructor.
37     *
38     * @param ResponseFactoryInterface               $responseFactory PSR-7 Response Factory.
39     * @param UserPreferenceRepositoryInterface      $repository      Preferences repository.
40     * @param ConfigurationCascadeResolverInterface  $resolver        Cascade resolver.
41     * @param SaveUserPreferenceHandler              $saveHandler     Save command handler.
42     * @param ResetUserPreferencesHandler            $resetHandler    Reset command handler.
43     */
44    public function __construct(
45        private ResponseFactoryInterface $responseFactory,
46        private UserPreferenceRepositoryInterface $repository,
47        private ConfigurationCascadeResolverInterface $resolver,
48        private SaveUserPreferenceHandler $saveHandler,
49        private ResetUserPreferencesHandler $resetHandler,
50    ) {
51    }
52
53    /**
54     * Handles GET /api/v1/user/preferences list request.
55     *
56     * @param ServerRequestInterface $request HTTP server request.
57     * @return ResponseInterface JSON API response.
58     */
59    public function list(ServerRequestInterface $request): ResponseInterface
60    {
61        $userId = $this->resolveUserId($request);
62        if ($userId <= 0) {
63            return $this->buildJsonResponse($this->responseFactory, [
64                'status'  => false,
65                'message' => 'Unauthorized.',
66            ], 401);
67        }
68
69        $queryParams = $request->getQueryParams();
70        $module = isset($queryParams['module']) ? (string) $queryParams['module'] : null;
71        $device = isset($queryParams['device']) ? (string) $queryParams['device'] : null;
72
73        $preferences = $this->repository->findAllByUser($userId, $module, $device);
74
75        return $this->buildJsonResponse($this->responseFactory, [
76            'status' => true,
77            'data'   => $preferences,
78        ]);
79    }
80
81    /**
82     * Handles GET /api/v1/user/preferences/{key} resolution request.
83     *
84     * @param ServerRequestInterface $request HTTP server request.
85     * @param string                 $key     Preference key.
86     * @return ResponseInterface JSON API response.
87     */
88    public function get(ServerRequestInterface $request, string $key): ResponseInterface
89    {
90        $userId = $this->resolveUserId($request);
91        if ($userId <= 0) {
92            return $this->buildJsonResponse($this->responseFactory, [
93                'status'  => false,
94                'message' => 'Unauthorized.',
95            ], 401);
96        }
97
98        $params = $request->getQueryParams();
99        $scope = new PreferenceScope(
100            userId: $userId,
101            deviceFingerprint: isset($params['device']) ? (string) $params['device'] : null,
102            moduleName: isset($params['module']) ? (string) $params['module'] : null,
103            entityType: isset($params['entity_type']) ? (string) $params['entity_type'] : null,
104            entityId: isset($params['entity_id']) ? (int) $params['entity_id'] : null,
105        );
106
107        $value = $this->resolver->resolve($scope, $key);
108
109        return $this->buildJsonResponse($this->responseFactory, [
110            'status' => true,
111            'key'    => $key,
112            'value'  => $value,
113        ]);
114    }
115
116    /**
117     * Handles POST /api/v1/user/preferences save request.
118     *
119     * @param ServerRequestInterface $request HTTP server request.
120     * @return ResponseInterface JSON API response.
121     */
122    public function save(ServerRequestInterface $request): ResponseInterface
123    {
124        $userId = $this->resolveUserId($request);
125        if ($userId <= 0) {
126            return $this->buildJsonResponse($this->responseFactory, [
127                'status'  => false,
128                'message' => 'Unauthorized.',
129            ], 401);
130        }
131
132        $payload = $this->parseJsonPayload($request);
133        if ($payload === null) {
134            return $this->buildJsonResponse($this->responseFactory, [
135                'status'  => false,
136                'message' => 'Invalid JSON payload.',
137            ], 400);
138        }
139
140        return $this->executePreferenceSave($payload, $userId);
141    }
142
143    /**
144     * Executes preference save and returns response.
145     *
146     * @param array<string, mixed> $payload
147     */
148    private function executePreferenceSave(array $payload, int $userId): ResponseInterface
149    {
150        try {
151            $dto = UserPreferenceDTO::fromArray($payload, $userId);
152            $this->saveHandler->handle(new SaveUserPreferenceCommand($dto));
153
154            return $this->buildJsonResponse($this->responseFactory, [
155                'status'  => true,
156                'message' => 'Preference saved successfully.',
157                'data'    => $dto->toArray(),
158            ]);
159        } catch (Throwable $e) {
160            return $this->buildJsonResponse($this->responseFactory, [
161                'status'  => false,
162                'message' => $e->getMessage(),
163            ], 422);
164        }
165    }
166
167    /**
168     * Safely decodes JSON payload from request body.
169     *
170     * @return array<string, mixed>|null
171     */
172    private function parseJsonPayload(ServerRequestInterface $request): ?array
173    {
174        try {
175            $payload = json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR);
176
177            return is_array($payload) ? $payload : null;
178        } catch (JsonException) {
179            return null;
180        }
181    }
182
183    /**
184     * Handles DELETE /api/v1/user/preferences reset request.
185     *
186     * @param ServerRequestInterface $request HTTP server request.
187     * @return ResponseInterface JSON API response.
188     */
189    public function reset(ServerRequestInterface $request): ResponseInterface
190    {
191        $userId = $this->resolveUserId($request);
192        if ($userId <= 0) {
193            return $this->buildJsonResponse($this->responseFactory, [
194                'status'  => false,
195                'message' => 'Unauthorized.',
196            ], 401);
197        }
198
199        $params = $request->getQueryParams();
200        $scope = new PreferenceScope(
201            userId: $userId,
202            deviceFingerprint: isset($params['device']) ? (string) $params['device'] : null,
203            moduleName: isset($params['module']) ? (string) $params['module'] : null,
204        );
205        $key = isset($params['key']) ? (string) $params['key'] : null;
206
207        $deletedCount = $this->resetHandler->handle(new ResetUserPreferencesCommand($scope, $key));
208
209        return $this->buildJsonResponse($this->responseFactory, [
210            'status'  => true,
211            'message' => 'Preferences reset successfully.',
212            'count'   => $deletedCount,
213        ]);
214    }
215
216    /**
217     * Resolves the authenticated user ID from session or request attributes.
218     *
219     * @param ServerRequestInterface $request HTTP server request.
220     * @return int Resolved user ID or 0 if unauthenticated.
221     */
222    private function resolveUserId(ServerRequestInterface $request): int
223    {
224        $attrId = $request->getAttribute('user_id') ?? $request->getAttribute('actor_user_id');
225        if (is_numeric($attrId) && (int) $attrId > 0) {
226            return (int) $attrId;
227        }
228
229        if (isset($_SESSION['user_id']) && (int) $_SESSION['user_id'] > 0) {
230            return (int) $_SESSION['user_id'];
231        }
232
233        return 0;
234    }
235}