Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.40% covered (success)
93.40%
99 / 106
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
MapHtmxController
93.33% covered (success)
93.33%
98 / 105
50.00% covered (danger)
50.00%
3 / 6
35.36
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
 recordModal
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
11
 batchModal
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 fetchBatchRecords
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 fetchBatchModuleRecords
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
10.37
 fetchLogAuthRecords
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
7
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\Modules\Map\Presentation\Htmx;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Map\Domain\Repository\IpGeolocationServiceInterface;
12use App\Modules\Map\Domain\Repository\MapApiClientInterface;
13use App\Modules\Map\Infrastructure\Provider\SmartMapLoadBalancer;
14use PDO;
15use Psr\Http\Message\ResponseFactoryInterface;
16use Psr\Http\Message\ResponseInterface;
17use Psr\Http\Message\ServerRequestInterface;
18use Twig\Environment as TwigEnvironment;
19
20/**
21 * Map HTMX Partial Controller.
22 *
23 * Provides dynamic HTML partials and modal windows for single-entity and batch-record map views.
24 *
25 * @package App\Modules\Map\Presentation\Htmx
26 */
27final readonly class MapHtmxController
28{
29    private const ADDRESS_COLUMNS =
30        'address_street, address_building_number, address_apartment_number, ' .
31        'address_postal_code, address_city, address_country, address_latitude, ' .
32        'address_longitude, parent_id';
33
34    /**
35     * MapHtmxController constructor.
36     *
37     * @param TwigEnvironment                    $twig            Twig template engine.
38     * @param ResponseFactoryInterface           $responseFactory PSR-17 response factory.
39     * @param MapApiClientInterface              $mapClient       Ammonly Map API client.
40     * @param PDO                                $pdo             Database connection.
41     * @param IpGeolocationServiceInterface|null $ipGeoService    IP geolocation resolver service.
42     * @param SmartMapLoadBalancer|null          $loadBalancer    Smart map load balancer.
43     */
44    public function __construct(
45        private readonly TwigEnvironment $twig,
46        private readonly ResponseFactoryInterface $responseFactory,
47        private readonly MapApiClientInterface $mapClient,
48        private readonly PDO $pdo,
49        private readonly ?IpGeolocationServiceInterface $ipGeoService = null,
50        private readonly ?SmartMapLoadBalancer $loadBalancer = null
51    ) {
52    }
53
54    /**
55     * Handles /htmx/maps/record-modal/{module}/{id:\d+}.
56     */
57    public function recordModal(string $module, int $id): ResponseInterface
58    {
59        $table = match ($module) {
60            'companies' => 'c_mod_companies_records',
61            'partners'  => 'c_mod_partners_records',
62            'contacts'  => 'c_mod_contacts_records',
63            default     => null,
64        };
65
66        $record = null;
67        $hierarchyRecords = [];
68        if ($table !== null) {
69            $nameExpr = $module === 'contacts' ? 'c_name AS name' : 'name';
70            $sql = "SELECT id, {$nameExpr}" . self::ADDRESS_COLUMNS . " FROM `{$table}` WHERE `id` = :id LIMIT 1";
71            $stmt = $this->pdo->prepare($sql);
72            $stmt->execute(['id' => $id]);
73            $fetched = $stmt->fetch(PDO::FETCH_ASSOC);
74            $record = is_array($fetched) ? $fetched : null;
75
76            if ($record !== null) {
77                // Find root parent and all related hierarchy records
78                $rootId = !empty($record['parent_id']) ? (int) $record['parent_id'] : $id;
79                $hierarchySql = "SELECT id, {$nameExpr}" . self::ADDRESS_COLUMNS .
80                                " FROM `{$table}` WHERE `id` = :root_id OR `parent_id` = :parent_id";
81                $hStmt = $this->pdo->prepare($hierarchySql);
82                $hStmt->execute(['root_id' => $rootId, 'parent_id' => $rootId]);
83                $hierarchyRecords = $hStmt->fetchAll(PDO::FETCH_ASSOC);
84            }
85        }
86
87        $html = $this->twig->render('modules/map/partials/record_modal.twig', [
88            'module'                 => $module,
89            'record_id'              => $id,
90            'record'                 => is_array($record) ? $record : [],
91            'hierarchy'              => $hierarchyRecords,
92            'tile_url_template'      => $this->mapClient->getTileUrlTemplate(),
93            'tile_dark_url_template' => $this->mapClient->getDarkTileUrlTemplate(),
94            'api_base_url'           => $this->mapClient->getApiUrl(),
95            'api_key'                => $this->mapClient->getApiKey(),
96        ]);
97
98        $response = $this->responseFactory->createResponse(200);
99        $response->getBody()->write($html);
100
101        return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
102    }
103
104    /**
105     * Handles /htmx/maps/batch-modal.
106     */
107    public function batchModal(ServerRequestInterface $request): ResponseInterface
108    {
109        $body = $request->getParsedBody();
110        $idsStr = is_array($body) && isset($body['ids']) ? (string) $body['ids'] : '';
111        $module = is_array($body) && isset($body['module']) ? (string) $body['module'] : 'companies';
112
113        $ids = array_filter(array_map('intval', explode(',', $idsStr)));
114        $records = $this->fetchBatchRecords($module, $ids);
115
116        $maptiler = $this->loadBalancer?->getProvider('maptiler');
117        $html = $this->twig->render('modules/map/partials/batch_modal.twig', [
118            'module'                 => $module,
119            'records'                => $records,
120            'tile_url_template'      => $this->mapClient->getTileUrlTemplate(),
121            'tile_dark_url_template' => $this->mapClient->getDarkTileUrlTemplate(),
122            'tile_maptiler_url'      => $maptiler?->getTileUrlTemplate(false) ?? '',
123            'tile_dark_maptiler_url' => $maptiler?->getTileUrlTemplate(true) ?? '',
124            'api_base_url'           => $this->mapClient->getApiUrl(),
125            'api_key'                => $this->mapClient->getApiKey(),
126        ]);
127
128        $response = $this->responseFactory->createResponse(200);
129        $response->getBody()->write($html);
130
131        return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
132    }
133
134    /**
135     * @param array<int, int> $ids
136     * @return array<int, array<string, mixed>>
137     */
138    private function fetchBatchRecords(string $module, array $ids): array
139    {
140        if ($ids === []) {
141            return [];
142        }
143
144        if ($module === 'logs_auth') {
145            return $this->fetchLogAuthRecords($ids);
146        }
147
148        return $this->fetchBatchModuleRecords($module, $ids);
149    }
150
151    /**
152     * @param array<int, int> $ids
153     * @return array<int, array<string, mixed>>
154     */
155    private function fetchBatchModuleRecords(string $module, array $ids): array
156    {
157        $table = match ($module) {
158            'companies' => 'c_mod_companies_records',
159            'partners'  => 'c_mod_partners_records',
160            'contacts'  => 'c_mod_contacts_records',
161            'leads'     => 'a_mod_leads_records',
162            default     => null,
163        };
164
165        if ($table === null) {
166            return [];
167        }
168
169        $nameExpr = $module === 'contacts' ? 'c_name AS name' : 'name';
170        $inClause = implode(',', array_fill(0, count($ids), '?'));
171        $sql = "SELECT id, {$nameExpr}" . self::ADDRESS_COLUMNS . " FROM `{$table}` WHERE `id` IN ({$inClause})";
172        $stmt = $this->pdo->prepare($sql);
173        $stmt->execute(array_values($ids));
174
175        return $stmt->fetchAll(PDO::FETCH_ASSOC);
176    }
177
178    /**
179     * @param array<int, int> $ids
180     * @return array<int, array<string, mixed>>
181     */
182    private function fetchLogAuthRecords(array $ids): array
183    {
184        $inClause = implode(',', array_fill(0, count($ids), '?'));
185        $sql = "SELECT id, user_id, status, failure_reason, login_identifier, ip_address, user_agent, created_at " .
186               "FROM `a_logs_user_auth_records` WHERE `id` IN ({$inClause})";
187        $stmt = $this->pdo->prepare($sql);
188        $stmt->execute(array_values($ids));
189        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
190
191        $records = [];
192        foreach ($rows as $row) {
193            $ip = (string) ($row['ip_address'] ?? '');
194            $loc = $this->ipGeoService?->resolveIp($ip);
195            if ($loc === null) {
196                continue;
197            }
198
199            $isSuccess = (int) ($row['status'] ?? 0) === 1;
200            $userLabel = trim((string) ($row['login_identifier'] ?? ''));
201            if ($userLabel === '') {
202                $userLabel = !empty($row['user_id']) ? 'User #' . $row['user_id'] : 'Unknown User';
203            }
204
205            $records[] = [
206                'id'                => (int) $row['id'],
207                'name'              => "{$userLabel} ({$ip})",
208                'address_street'    => $isSuccess ? 'Successful login' : 'Authorization failed',
209                'address_city'      => $loc->cityName ?? '',
210                'address_country'   => $loc->countryName ?? ($loc->countryCode ?? ''),
211                'address_latitude'  => $loc->latitude,
212                'address_longitude' => $loc->longitude,
213                'is_success'        => $isSuccess,
214                'marker_color'      => $isSuccess ? 'success' : 'danger',
215                'marker_type'       => 'auth_log',
216                'created_at'        => (string) ($row['created_at'] ?? ''),
217                'user_agent'        => (string) ($row['user_agent'] ?? ''),
218            ];
219        }
220
221        return $records;
222    }
223}