Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.89% covered (warning)
89.89%
160 / 178
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
MapSpatialQueryService
89.83% covered (warning)
89.83%
159 / 177
12.50% covered (danger)
12.50%
1 / 8
57.07
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
 findNearbyRecords
98.44% covered (success)
98.44%
63 / 64
0.00% covered (danger)
0.00%
0 / 1
12
 fetchModulePoints
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
12.33
 fetchHierarchyPoints
79.31% covered (warning)
79.31%
23 / 29
0.00% covered (danger)
0.00%
0 / 1
12.07
 fetchLogAuthPoints
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 queryLogAuthRecords
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
3.43
 mapAuthLogRowToPoint
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
7
 fetchStandardModulePoints
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
6
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\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Map\Domain\Repository\IpGeolocationServiceInterface;
12use PDO;
13
14/**
15 * Handles spatial and geolocation database queries for Map markers and hierarchy trees.
16 *
17 * Encapsulates Haversine distance calculations, CRM point mapping, and IP geolocation auth logs.
18 *
19 * @package App\Modules\Map\Application\Service
20 */
21final readonly class MapSpatialQueryService
22{
23    private const string SQL_ADDRESS_FIELDS = 'address_postal_code, address_city, address_country, ';
24
25    /**
26     * MapSpatialQueryService constructor.
27     *
28     * @param PDO|null                           $pdo          Database connection.
29     * @param IpGeolocationServiceInterface|null $ipGeoService IP geolocation resolver.
30     */
31    public function __construct(
32        private ?PDO $pdo = null,
33        private ?IpGeolocationServiceInterface $ipGeoService = null
34    ) {
35    }
36
37    /**
38     * Finds nearby records within a specified radius using Haversine formula.
39     *
40     * @param float  $lat      Center latitude.
41     * @param float  $lon      Center longitude.
42     * @param float  $radiusKm Radius in kilometers.
43     * @param string $module   Target module name (companies, partners, contacts).
44     * @param int    $limit    Maximum records to return.
45     * @return array<string, mixed>|null Nearby dataset array, or null if unsupported.
46     */
47    public function findNearbyRecords(
48        float $lat,
49        float $lon,
50        float $radiusKm,
51        string $module,
52        int $limit = 50
53    ): ?array {
54        if ($this->pdo === null) {
55            return null;
56        }
57
58        $table = match ($module) {
59            'companies'        => 'c_mod_companies_records',
60            'partners'         => 'c_mod_partners_records',
61            'contacts'         => 'c_mod_contacts_records',
62            'system_structure',
63            'structure'        => 'a_mod_structure_records',
64            default            => null,
65        };
66
67        if ($table === null) {
68            return null;
69        }
70
71        $latDelta = $radiusKm / 111.0;
72        $lonDelta = $radiusKm / (111.0 * max(0.1, cos(deg2rad($lat))));
73        $minLat = $lat - $latDelta;
74        $maxLat = $lat + $latDelta;
75        $minLon = $lon - $lonDelta;
76        $maxLon = $lon + $lonDelta;
77
78        $selectName = $module === 'contacts' ? 'first_name, last_name' : 'name';
79        $subCols = $module === 'contacts'
80            ? 'sub.id, sub.first_name, sub.last_name, sub.address_street, sub.address_building_number, '
81              . 'sub.address_postal_code, sub.address_city, sub.address_country, sub.address_latitude, '
82              . 'sub.address_longitude, sub.distance_km'
83            : 'sub.id, sub.name, sub.address_street, sub.address_building_number, '
84              . 'sub.address_postal_code, sub.address_city, sub.address_country, sub.address_latitude, '
85              . 'sub.address_longitude, sub.distance_km';
86
87        $sql = "SELECT {$subCols} FROM ("
88             . "SELECT id, {$selectName}, address_street, address_building_number, "
89             . self::SQL_ADDRESS_FIELDS
90             . "address_latitude, address_longitude, "
91             . "(6371 * acos(least(1.0, greatest(-1.0, "
92             . "cos(radians(:lat1)) * cos(radians(address_latitude)) * "
93             . "cos(radians(address_longitude) - radians(:lon)) + "
94             . "sin(radians(:lat2)) * sin(radians(address_latitude)))))) AS distance_km "
95             . "FROM `{$table}"
96             . "WHERE address_latitude IS NOT NULL AND address_longitude IS NOT NULL "
97             . "  AND address_latitude BETWEEN :min_lat AND :max_lat "
98             . "  AND address_longitude BETWEEN :min_lon AND :max_lon"
99             . ") AS sub "
100             . "WHERE distance_km <= :radius "
101             . "ORDER BY distance_km ASC "
102             . "LIMIT :limit";
103
104        $stmt = $this->pdo->prepare($sql);
105        $stmt->bindValue(':lat1', $lat);
106        $stmt->bindValue(':lat2', $lat);
107        $stmt->bindValue(':lon', $lon);
108        $stmt->bindValue(':min_lat', $minLat);
109        $stmt->bindValue(':max_lat', $maxLat);
110        $stmt->bindValue(':min_lon', $minLon);
111        $stmt->bindValue(':max_lon', $maxLon);
112        $stmt->bindValue(':radius', $radiusKm);
113        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
114        $stmt->execute();
115
116        $records = $stmt->fetchAll(PDO::FETCH_ASSOC);
117        foreach ($records as &$record) {
118            if ($module === 'contacts') {
119                $first = trim((string) ($record['first_name'] ?? ''));
120                $last = trim((string) ($record['last_name'] ?? ''));
121                $record['name'] = trim("{$first} {$last}");
122            }
123        }
124        unset($record);
125
126        return [
127            'module'    => $module,
128            'origin'    => ['latitude' => $lat, 'longitude' => $lon],
129            'radius_km' => $radiusKm,
130            'count'     => count($records),
131            'records'   => $records,
132        ];
133    }
134
135    /**
136     * Resolves mapped points for module records.
137     *
138     * @param string          $module Target module name.
139     * @param array<int, int> $ids    Record IDs.
140     * @return array<int, array<string, mixed>>|null Resolved points, or null if module unsupported.
141     */
142    public function fetchModulePoints(string $module, array $ids = []): ?array
143    {
144        if ($this->pdo === null) {
145            return null;
146        }
147
148        if ($module === 'logs_auth') {
149            return $this->fetchLogAuthPoints($ids);
150        }
151
152        $table = match ($module) {
153            'companies'        => 'c_mod_companies_records',
154            'partners'         => 'c_mod_partners_records',
155            'contacts'         => 'c_mod_contacts_records',
156            'leads'            => 'a_mod_leads_records',
157            'system_structure',
158            'structure'        => 'a_mod_structure_records',
159            default            => null,
160        };
161
162        return $table !== null ? $this->fetchStandardModulePoints($table, $module, $ids) : null;
163    }
164
165    /**
166     * Fetches hierarchical tree records with geographic coordinates.
167     *
168     * @param string $module Target module.
169     * @param int    $id     Record ID.
170     * @return array<string, mixed>|null Hierarchy dataset, or null if unsupported.
171     */
172    public function fetchHierarchyPoints(string $module, int $id): ?array
173    {
174        if ($this->pdo === null) {
175            return null;
176        }
177
178        $table = match ($module) {
179            'companies'        => 'c_mod_companies_records',
180            'partners'         => 'c_mod_partners_records',
181            'contacts'         => 'c_mod_contacts_records',
182            'system_structure',
183            'structure'        => 'a_mod_structure_records',
184            default            => null,
185        };
186
187        if ($table === null || $id <= 0) {
188            return null;
189        }
190
191        $nameExpr = $module === 'contacts' ? 'c_name AS name' : 'name';
192        $columns = "id, {$nameExpr}, address_street, address_building_number, "
193                 . self::SQL_ADDRESS_FIELDS
194                 . "address_latitude, address_longitude, parent_id";
195
196        $rootStmt = $this->pdo->prepare("SELECT id, parent_id FROM `{$table}` WHERE `id` = :id LIMIT 1");
197        $rootStmt->execute(['id' => $id]);
198        $curr = $rootStmt->fetch(PDO::FETCH_ASSOC);
199        $rootId = !empty($curr['parent_id']) ? (int) $curr['parent_id'] : $id;
200
201        $sql = "SELECT {$columns} FROM `{$table}` WHERE `id` = :root_id OR `parent_id` = :parent_id";
202        $stmt = $this->pdo->prepare($sql);
203        $stmt->execute(['root_id' => $rootId, 'parent_id' => $rootId]);
204
205        $records = $stmt->fetchAll(PDO::FETCH_ASSOC);
206
207        return [
208            'module'    => $module,
209            'root_id'   => $rootId,
210            'hierarchy' => $records,
211        ];
212    }
213
214    /**
215     * Resolves authentication log records into mapped geolocation markers.
216     *
217     * @param array<int, int> $ids
218     * @return array<int, array<string, mixed>>
219     */
220    private function fetchLogAuthPoints(array $ids): array
221    {
222        if ($this->pdo === null) {
223            return [];
224        }
225
226        $records = $this->queryLogAuthRecords($ids);
227        $points = [];
228
229        foreach ($records as $row) {
230            $point = $this->mapAuthLogRowToPoint($row);
231            if ($point !== null) {
232                $points[] = $point;
233            }
234        }
235
236        return $points;
237    }
238
239    /**
240     * @param array<int, int> $ids
241     * @return array<int, array<string, mixed>>
242     */
243    private function queryLogAuthRecords(array $ids): array
244    {
245        if ($this->pdo === null) {
246            return [];
247        }
248
249        $columns = 'id, user_id, status, failure_reason, login_identifier, ip_address, user_agent, created_at';
250        if ($ids !== []) {
251            $inClause = implode(',', array_fill(0, count($ids), '?'));
252            $sql = "SELECT {$columns} FROM `a_logs_user_auth_records` WHERE `id` IN ({$inClause})";
253            $stmt = $this->pdo->prepare($sql);
254            $stmt->execute(array_values($ids));
255        } else {
256            $sql = "SELECT {$columns} FROM `a_logs_user_auth_records` ORDER BY `id` DESC LIMIT 100";
257            $stmt = $this->pdo->prepare($sql);
258            $stmt->execute();
259        }
260
261        return $stmt->fetchAll(PDO::FETCH_ASSOC);
262    }
263
264    /**
265     * @param array<string, mixed> $row
266     * @return array<string, mixed>|null
267     */
268    private function mapAuthLogRowToPoint(array $row): ?array
269    {
270        $ip = (string) ($row['ip_address'] ?? '');
271        $loc = $this->ipGeoService?->resolveIp($ip);
272        if ($loc === null) {
273            return null;
274        }
275
276        $isSuccess = (int) ($row['status'] ?? 0) === 1;
277        $userLabel = trim((string) ($row['login_identifier'] ?? ''));
278        if ($userLabel === '') {
279            $userLabel = !empty($row['user_id']) ? 'User #' . $row['user_id'] : 'Unknown User';
280        }
281
282        $statusText = $isSuccess ? 'Successful login' : 'Authorization failed';
283        $cityStr = $loc->cityName ?? '';
284        $countryStr = $loc->countryName ?? ($loc->countryCode ?? '');
285        $address = trim("{$cityStr}{$countryStr}", " ,");
286
287        return [
288            'id'                => (int) $row['id'],
289            'name'              => "{$userLabel} ({$ip})",
290            'address_street'    => $statusText,
291            'address_city'      => $loc->cityName ?? '',
292            'address_country'   => $loc->countryName ?? ($loc->countryCode ?? ''),
293            'address_latitude'  => $loc->latitude,
294            'address_longitude' => $loc->longitude,
295            'is_success'        => $isSuccess,
296            'marker_color'      => $isSuccess ? 'success' : 'danger',
297            'marker_type'       => 'auth_log',
298            'created_at'        => (string) ($row['created_at'] ?? ''),
299            'user_agent'        => (string) ($row['user_agent'] ?? ''),
300            'display_address'   => $address !== '' ? $address : $ip,
301        ];
302    }
303
304    /**
305     * Fetches standard CRM records with coordinates.
306     *
307     * @param string          $table
308     * @param string          $module
309     * @param array<int, int> $ids
310     * @return array<int, array<string, mixed>>
311     */
312    private function fetchStandardModulePoints(string $table, string $module, array $ids): array
313    {
314        if ($this->pdo === null) {
315            return [];
316        }
317
318        $selectName = $module === 'contacts' ? 'first_name, last_name' : 'name';
319        $columns = "id, {$selectName}, address_street, address_building_number, "
320                 . self::SQL_ADDRESS_FIELDS
321                 . "address_latitude, address_longitude, parent_id";
322
323        if ($ids !== []) {
324            $inClause = implode(',', array_fill(0, count($ids), '?'));
325            $sql = "SELECT {$columns} FROM `{$table}` WHERE `id` IN ({$inClause})";
326            $stmt = $this->pdo->prepare($sql);
327            $stmt->execute(array_values($ids));
328        } else {
329            $sql = "SELECT {$columns} FROM `{$table}` WHERE `special_access` = 1 LIMIT 200";
330            $stmt = $this->pdo->prepare($sql);
331            $stmt->execute();
332        }
333
334        $records = $stmt->fetchAll(PDO::FETCH_ASSOC);
335        foreach ($records as &$record) {
336            if ($module === 'contacts') {
337                $first = trim((string) ($record['first_name'] ?? ''));
338                $last = trim((string) ($record['last_name'] ?? ''));
339                $record['name'] = trim("{$first} {$last}");
340            }
341        }
342        unset($record);
343
344        return $records;
345    }
346}