Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.23% covered (warning)
81.23%
264 / 325
48.15% covered (danger)
48.15%
13 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Log
81.23% covered (warning)
81.23%
264 / 325
48.15% covered (danger)
48.15%
13 / 27
159.21
0.00% covered (danger)
0.00%
0 / 1
 writeLogEntry
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
4
 writeProcessLog
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 buildProcessLogPayload
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
3
 actionCodeFromLabel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseLegacyLogData
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
12.05
 readByProcessId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 readByProcessData
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 getBySearchParams
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 buildFieldValueConditions
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
7.58
 buildGeneralSearchConditionList
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 buildScopeIdConditions
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 buildDateConditions
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
3.46
 buildUserActionConditions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 normalizeLogRow
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 delete
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 backtraceLogEntry
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 escapeLikeValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildGeneralSearchConditions
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
6.24
 buildUnquotedSearchParts
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
5.64
 buildTextColumnBoundaryLikeParts
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 escapeFulltextBooleanTerm
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
5.93
 buildQuotedCitizenNameSearchParts
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
4.46
 escapeFulltextQuotedTerm
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
5.93
 buildCitizenNameWordBoundaryParts
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 parseSearchTerms
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
6.35
 isNumericSearchQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clearLogsOlderThan
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace BO\Zmsdb;
4
5use BO\Zmsentities\Collection\LogList;
6use BO\Zmsentities\Collection\RequestList;
7use BO\Zmsentities\Log as Entity;
8use DateTime;
9
10/**
11 * Logging for actions
12 *
13 */
14class Log extends Base
15{
16    const PROCESS = 'buerger';
17    const MIGRATION = 'migration';
18    const ERROR = 'error';
19
20    const ACTION_MAIL_SUCCESS = 'E-Mail-Versand erfolgreich';
21    const ACTION_MAIL_FAIL = 'E-Mail-Versand ist fehlgeschlagen';
22    const ACTION_STATUS_CHANGE = 'Terminstatus wurde geändert';
23    const ACTION_SEND_REMINDER = 'Erinnerungsmail wurde gesendet';
24    const ACTION_REMOVED = 'Termin aus der Warteschlange entfernt';
25    const ACTION_CALLED = 'Termin wurde aufgerufen';
26    const ACTION_ARCHIVED = 'Termin wurde archiviert';
27    const ACTION_EDITED = 'Termin wurde geändert';
28    const ACTION_REDIRECTED = 'Termin wurde weitergeleitet';
29    const ACTION_NEW = 'Neuer Termin wurde erstellt';
30    const ACTION_DELETED = 'Termin wurde gelöscht';
31    const ACTION_CANCELED = 'Termin wurde abgesagt';
32
33    private const FULLTEXT_SEARCH_COLUMNS = 'citizen_name, services, scope_name, citizen_email';
34
35    private const TEXT_SEARCH_COLUMNS = [
36        'citizen_name',
37        'services',
38        'scope_name',
39        'citizen_email',
40    ];
41
42    private const INDEXED_COLUMNS = [
43        'action',
44        'display_number',
45        'queue_number',
46        'appointment_at',
47        'slot_count',
48        'citizen_name',
49        'services',
50        'scope_name',
51        'citizen_email',
52        'citizen_phone',
53        'process_status',
54        'db_status',
55    ];
56
57    private const ACTION_LABEL_TO_CODE = [
58        self::ACTION_MAIL_SUCCESS => 'mail_success',
59        self::ACTION_MAIL_FAIL => 'mail_fail',
60        self::ACTION_STATUS_CHANGE => 'status_changed',
61        self::ACTION_SEND_REMINDER => 'reminder_sent',
62        self::ACTION_REMOVED => 'removed_from_queue',
63        self::ACTION_CALLED => 'called',
64        self::ACTION_ARCHIVED => 'archived',
65        self::ACTION_EDITED => 'edited',
66        self::ACTION_REDIRECTED => 'redirected',
67        self::ACTION_NEW => 'created',
68        self::ACTION_DELETED => 'deleted',
69        self::ACTION_CANCELED => 'canceled',
70    ];
71
72    public static $operator = 'lib';
73
74    public static function writeLogEntry(
75        $message,
76        $referenceId,
77        $type = self::PROCESS,
78        ?int $scopeId = null,
79        ?string $userId = null,
80        ?string $data = null,
81        ?array $indexedFields = null
82    ) {
83        $message .= " [" . static::$operator . "]";
84        $log = new static();
85        $setParts = [
86            '`message`=:message',
87            '`reference_id`=:referenceId',
88            '`type`=:type',
89            '`scope_id`=:scopeId',
90            '`user_id`=:userId',
91            '`data`=:dataString',
92        ];
93        $parameters = [
94            'message' => $message . static::backtraceLogEntry(),
95            'referenceId' => $referenceId,
96            'type' => $type,
97            'scopeId' => $scopeId,
98            'userId' => $userId,
99            'dataString' => $data,
100        ];
101
102        if ($indexedFields !== null) {
103            foreach (self::INDEXED_COLUMNS as $column) {
104                if (!array_key_exists($column, $indexedFields)) {
105                    continue;
106                }
107                $setParts[] = '`' . $column . '`=:' . $column;
108                $parameters[$column] = $indexedFields[$column];
109            }
110        }
111
112        $sql = 'INSERT INTO `log` SET ' . implode(', ', $setParts);
113
114        return $log->perform($sql, $parameters);
115    }
116
117    public static function writeProcessLog(
118        string $method,
119        string $action,
120        ?\BO\Zmsentities\Process $process,
121        ?\BO\Zmsentities\Useraccount $userAccount = null
122    ) {
123        if (empty($process) || empty($process->getId()) || empty($userAccount)) {
124            return;
125        }
126
127        $requests = new RequestList();
128        if (!empty($process->getRequestIds())) {
129            $requests = (new Request())->readRequestsByIds($process->getRequestIds());
130        }
131
132        $payload = self::buildProcessLogPayload($process, $action, $userAccount, $requests);
133        $data = json_encode($payload['display'], JSON_UNESCAPED_UNICODE);
134
135        Log::writeLogEntry(
136            $method,
137            $process->getId(),
138            self::PROCESS,
139            $process->getScopeId(),
140            $userAccount->getId(),
141            $data,
142            $payload['indexed']
143        );
144    }
145
146    public static function buildProcessLogPayload(
147        \BO\Zmsentities\Process $process,
148        string $action,
149        \BO\Zmsentities\Useraccount $userAccount,
150        RequestList $requests
151    ): array {
152        $appointmentAt = $process->getFirstAppointment()->toDateTime();
153        $display = array_filter([
154            'Aktion' => $action,
155            'Sachbearbeiter*in' => $userAccount->getId(),
156            'Terminnummer' => $process->getDisplayNumber(),
157            'Wartenummer' => $process->getQueueNumber(),
158            'Terminzeit' => $appointmentAt->format('d.m.Y H:i:s'),
159            'Slots' => $process->getFirstAppointment()->slotCount ?? null,
160            'Bürger*in' => $process->getFirstClient()->familyName,
161            'Dienstleistungen' => implode(', ', array_map(function ($request) {
162                return $request->getName();
163            }, $requests->getAsArray())),
164            'Anmerkung' => $process->getAmendment(),
165            'Standort' => $process->scope->getName(),
166            'E-Mail' => $process->getFirstClient()->email,
167            'Telefon' => $process->getFirstClient()->telephone,
168            'Status' => $process->getStatus(),
169            'DB Status' => $process->dbstatus,
170        ], static function ($value) {
171            return $value !== null && $value !== '';
172        });
173
174        $indexed = array_filter([
175            'action' => self::actionCodeFromLabel($action),
176            'display_number' => $process->getDisplayNumber(),
177            'queue_number' => $process->getQueueNumber(),
178            'appointment_at' => $appointmentAt->format('Y-m-d H:i:s'),
179            'slot_count' => $process->getFirstAppointment()->slotCount ?? null,
180            'citizen_name' => $process->getFirstClient()->familyName,
181            'services' => implode(', ', array_map(function ($request) {
182                return $request->getName();
183            }, $requests->getAsArray())),
184            'scope_name' => $process->scope->getName(),
185            'citizen_email' => $process->getFirstClient()->email,
186            'citizen_phone' => $process->getFirstClient()->telephone,
187            'process_status' => $process->getStatus(),
188            'db_status' => $process->dbstatus,
189        ], static function ($value) {
190            return $value !== null && $value !== '';
191        });
192
193        return [
194            'display' => $display,
195            'indexed' => $indexed,
196        ];
197    }
198
199    public static function actionCodeFromLabel(string $label): ?string
200    {
201        return self::ACTION_LABEL_TO_CODE[$label] ?? null;
202    }
203
204    public static function parseLegacyLogData(?string $dataJson): ?array
205    {
206        if ($dataJson === null || $dataJson === '') {
207            return null;
208        }
209
210        $display = json_decode($dataJson, true);
211        if (!is_array($display)) {
212            return null;
213        }
214
215        $actionLabel = $display['Aktion'] ?? null;
216        $appointmentAt = null;
217        if (!empty($display['Terminzeit'])) {
218            $parsed = \DateTimeImmutable::createFromFormat('d.m.Y H:i:s', (string) $display['Terminzeit']);
219            if ($parsed instanceof \DateTimeImmutable) {
220                $appointmentAt = $parsed->format('Y-m-d H:i:s');
221            }
222        }
223
224        return array_filter([
225            'action' => is_string($actionLabel) ? self::actionCodeFromLabel($actionLabel) : null,
226            'display_number' => $display['Terminnummer'] ?? null,
227            'queue_number' => isset($display['Wartenummer']) && $display['Wartenummer'] !== ''
228                ? (int) $display['Wartenummer']
229                : null,
230            'appointment_at' => $appointmentAt,
231            'slot_count' => isset($display['Slots']) && $display['Slots'] !== ''
232                ? (int) $display['Slots']
233                : null,
234            'citizen_name' => $display['Bürger*in'] ?? null,
235            'services' => $display['Dienstleistungen'] ?? null,
236            'scope_name' => $display['Standort'] ?? null,
237            'citizen_email' => $display['E-Mail'] ?? null,
238            'citizen_phone' => $display['Telefon'] ?? null,
239            'process_status' => $display['Status'] ?? null,
240            'db_status' => $display['DB Status'] ?? null,
241        ], static function ($value) {
242            return $value !== null && $value !== '';
243        });
244    }
245
246    public function readByProcessId($processId)
247    {
248        $query = new Query\Log(Query\Base::SELECT);
249        $query->addEntityMapping();
250        $query->addConditionProcessId($processId);
251        $logList = new \BO\Zmsentities\Collection\LogList($this->fetchList($query, new Entity()));
252        return $logList;
253    }
254
255    public function readByProcessData(
256        $generalSearch,
257        $service,
258        $provider,
259        $date,
260        $userAction,
261        $page = 1,
262        $perPage = 100,
263        ?array $scopeIds = null
264    ) {
265        $fieldValues = [];
266        if ($provider) {
267            $fieldValues['scope_name'] = $provider;
268        }
269
270        if ($service) {
271            $fieldValues['services'] = $service;
272        }
273
274        return $this->getBySearchParams(
275            $fieldValues,
276            $generalSearch,
277            $userAction,
278            $date,
279            $perPage,
280            ($page - 1) * $perPage,
281            $scopeIds
282        );
283    }
284
285    public function getBySearchParams(
286        array $fieldValues,
287        ?string $generalSearch,
288        int $userAction,
289        ?DateTime $date,
290        int $perPage,
291        int $offset,
292        ?array $scopeIds = null
293    ) {
294        $params = ['logType' => self::PROCESS];
295        $conditions = array_merge(
296            ['type = :logType'],
297            $this->buildFieldValueConditions($fieldValues, $params),
298            $this->buildGeneralSearchConditionList($generalSearch, $params),
299            $this->buildScopeIdConditions($scopeIds, $params),
300            $this->buildDateConditions($date, $params),
301            $this->buildUserActionConditions($userAction, $params)
302        );
303
304        $sql = 'SELECT * FROM log';
305        if (!empty($conditions)) {
306            $sql .= ' WHERE ' . implode(' AND ', $conditions);
307        }
308
309        $sql .= ' ORDER BY ts DESC LIMIT ' . (int) $perPage . ' OFFSET ' . (int) $offset;
310
311        $logs = new LogList();
312        foreach ($this->fetchAll($sql, $params) as $row) {
313            $logs->addEntity(new Entity($this->normalizeLogRow($row)));
314        }
315
316        return $logs;
317    }
318
319    private function buildFieldValueConditions(array $fieldValues, array &$params): array
320    {
321        $conditions = [];
322        foreach ($fieldValues as $field => $value) {
323            if ($value === null || $value === '') {
324                continue;
325            }
326
327            $likeValue = '%' . $this->escapeLikeValue((string) $value) . '%';
328            if ($field === 'scope_name') {
329                $conditions[] = '(scope_name LIKE :scopeName OR (scope_name IS NULL AND data LIKE :scopeNameLegacy))';
330                $params['scopeName'] = $likeValue;
331                $params['scopeNameLegacy'] = '%' . $this->escapeLikeValue('Standort') . '%'
332                    . $this->escapeLikeValue((string) $value) . '%';
333                continue;
334            }
335
336            if ($field === 'services') {
337                $conditions[] = '(services LIKE :services OR (services IS NULL AND data LIKE :servicesLegacy))';
338                $params['services'] = $likeValue;
339                $params['servicesLegacy'] = '%' . $this->escapeLikeValue('Dienstleistungen') . '%'
340                    . $this->escapeLikeValue((string) $value) . '%';
341            }
342        }
343
344        return $conditions;
345    }
346
347    private function buildGeneralSearchConditionList(?string $generalSearch, array &$params): array
348    {
349        if ($generalSearch === null || trim($generalSearch) === '') {
350            return [];
351        }
352
353        return $this->buildGeneralSearchConditions(trim($generalSearch), $params);
354    }
355
356    private function buildScopeIdConditions(?array $scopeIds, array &$params): array
357    {
358        if (empty($scopeIds)) {
359            return [];
360        }
361
362        $scopePlaceholders = [];
363        foreach (array_values($scopeIds) as $index => $scopeId) {
364            $parameterKey = 'scopeId' . $index;
365            $scopePlaceholders[] = ':' . $parameterKey;
366            $params[$parameterKey] = (int) $scopeId;
367        }
368
369        return ['scope_id IN (' . implode(', ', $scopePlaceholders) . ')'];
370    }
371
372    private function buildDateConditions(?DateTime $date, array &$params): array
373    {
374        if (empty($date)) {
375            return [];
376        }
377
378        $start = (clone $date)->setTime(0, 0, 0);
379        $end = (clone $date)->setTime(0, 0, 0)->add(new \DateInterval('P1D'));
380        $params['start'] = $start->format('Y-m-d H:i:s');
381        $params['end'] = $end->format('Y-m-d H:i:s');
382
383        return ['(ts >= :start AND ts < :end)'];
384    }
385
386    private function buildUserActionConditions(int $userAction, array &$params): array
387    {
388        if ($userAction === 1) {
389            $params['ua_yes'] = '%Sachbearbeiter*in%';
390            $params['ua_system'] = '%Sachbearbeiter*in\":\"_system_%';
391
392            return ['(
393                (user_id IS NOT NULL AND user_id != \'\' AND user_id NOT LIKE \'_system_%\')
394                OR (
395                    user_id IS NULL
396                    AND data IS NOT NULL
397                    AND data LIKE :ua_yes
398                    AND data NOT LIKE :ua_system
399                )
400            )'];
401        }
402
403        if ($userAction === 2) {
404            $params['ua_yes'] = '%Sachbearbeiter*in%';
405            $params['ua_system'] = '%Sachbearbeiter*in\":\"_system_%';
406
407            return ['(
408                user_id LIKE \'_system_%\'
409                OR (
410                    (user_id IS NULL OR user_id = \'\')
411                    AND (
412                        data IS NULL
413                        OR data LIKE :ua_system
414                        OR data NOT LIKE :ua_yes
415                    )
416                )
417            )'];
418        }
419
420        return [];
421    }
422
423    private function normalizeLogRow(array $row): array
424    {
425        if (isset($row['reference_id']) && !isset($row['reference'])) {
426            $row['reference'] = $row['reference_id'];
427        }
428
429        if (isset($row['ts']) && !is_numeric($row['ts'])) {
430            $row['ts'] = strtotime((string) $row['ts']);
431        }
432
433        return $row;
434    }
435
436    public function delete($processId)
437    {
438        $query = new Query\Log(Query\Base::SELECT);
439        $query->addEntityMapping();
440        $query->addConditionProcessId($processId);
441        $logList = new \BO\Zmsentities\Collection\LogList($this->fetchList($query, new Entity()));
442        return $logList;
443    }
444
445    protected static function backtraceLogEntry()
446    {
447        $trace = debug_backtrace();
448        $short = '';
449        foreach ($trace as $step) {
450            if (
451                isset($step['file'])
452                && isset($step['line'])
453                && !strpos($step['file'], 'Zmsdb')
454            ) {
455                return ' (' . basename($step['file'], '.php') . ')';
456            }
457        }
458        return $short;
459    }
460
461    private function escapeLikeValue(string $value): string
462    {
463        return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
464    }
465
466    private function buildGeneralSearchConditions(string $generalSearch, array &$params): array
467    {
468        $terms = $this->parseSearchTerms($generalSearch);
469        if ($terms === []) {
470            return [];
471        }
472
473        $conditions = [];
474        foreach ($terms as $index => $termInfo) {
475            $term = $termInfo['value'];
476            $prefix = 'generalSearch' . $index;
477            $useWordBoundary = $termInfo['quoted'];
478
479            if ($useWordBoundary) {
480                $parts = $this->buildQuotedCitizenNameSearchParts($term, $params, $prefix . 'Name');
481            } else {
482                $parts = $this->buildUnquotedSearchParts($term, $prefix, $params);
483            }
484
485            if (count($terms) === 1 && $this->isNumericSearchQuery($term)) {
486                $params[$prefix . 'Id'] = (int) $term;
487                $parts[] = 'reference_id = :' . $prefix . 'Id';
488            }
489
490            $conditions[] = '(' . implode(' OR ', $parts) . ')';
491        }
492
493        return $conditions;
494    }
495
496    private function buildUnquotedSearchParts(string $term, string $paramPrefix, array &$params): array
497    {
498        $parts = [];
499        $params[$paramPrefix . 'Exact'] = $term;
500        $parts[] = 'display_number = :' . $paramPrefix . 'Exact';
501
502        if (mb_strlen($term) <= 2) {
503            if (mb_strlen($term) === 1) {
504                $escaped = $this->escapeLikeValue($term);
505                $params[$paramPrefix . 'Prefix'] = $escaped . '%';
506                foreach (self::TEXT_SEARCH_COLUMNS as $column) {
507                    $parts[] = $column . ' LIKE :' . $paramPrefix . 'Prefix';
508                }
509
510                return $parts;
511            }
512
513            $parts = array_merge($parts, $this->buildTextColumnBoundaryLikeParts($term, $paramPrefix, $params));
514
515            return $parts;
516        }
517
518        $fulltextTerm = $this->escapeFulltextBooleanTerm($term);
519        if ($fulltextTerm !== '') {
520            $params[$paramPrefix . 'Ft'] = $fulltextTerm;
521            $parts[] = 'MATCH(' . self::FULLTEXT_SEARCH_COLUMNS . ') AGAINST(:' . $paramPrefix . 'Ft IN BOOLEAN MODE)';
522        }
523
524        return $parts;
525    }
526
527    private function buildTextColumnBoundaryLikeParts(string $term, string $paramPrefix, array &$params): array
528    {
529        $escaped = $this->escapeLikeValue($term);
530        $params[$paramPrefix . 'Start'] = $escaped . '%';
531        $params[$paramPrefix . 'End'] = '%' . $escaped;
532        $params[$paramPrefix . 'Middle'] = '% ' . $escaped . ' %';
533        $params[$paramPrefix . 'MiddleStart'] = '% ' . $escaped;
534        $params[$paramPrefix . 'Exact'] = $term;
535
536        $parts = [];
537        foreach (self::TEXT_SEARCH_COLUMNS as $column) {
538            $parts[] = $column . ' LIKE :' . $paramPrefix . 'Start';
539            $parts[] = $column . ' LIKE :' . $paramPrefix . 'End';
540            $parts[] = $column . ' LIKE :' . $paramPrefix . 'Middle';
541            $parts[] = $column . ' LIKE :' . $paramPrefix . 'MiddleStart';
542            $parts[] = $column . ' = :' . $paramPrefix . 'Exact';
543        }
544
545        return $parts;
546    }
547
548    private function escapeFulltextBooleanTerm(string $term): string
549    {
550        $term = trim(preg_replace('/[+\-><()~*"@]+/u', ' ', $term) ?? '');
551        if ($term === '') {
552            return '';
553        }
554
555        $words = preg_split('/\s+/u', $term, -1, PREG_SPLIT_NO_EMPTY);
556        if ($words === false || $words === []) {
557            return '';
558        }
559
560        if (count($words) > 1) {
561            return '"' . implode(' ', array_map(static fn ($word) => str_replace('"', '', $word), $words)) . '"';
562        }
563
564        return '+' . $words[0] . '*';
565    }
566
567    private function buildQuotedCitizenNameSearchParts(string $term, array &$params, string $paramPrefix): array
568    {
569        if (mb_strlen($term) <= 2) {
570            return $this->buildCitizenNameWordBoundaryParts(
571                $this->escapeLikeValue($term),
572                $params,
573                $paramPrefix
574            );
575        }
576
577        $fulltextTerm = $this->escapeFulltextQuotedTerm($term);
578        if ($fulltextTerm === '') {
579            return [];
580        }
581
582        $params[$paramPrefix . 'Ft'] = $fulltextTerm;
583
584        return ['MATCH(citizen_name) AGAINST(:' . $paramPrefix . 'Ft IN BOOLEAN MODE)'];
585    }
586
587    private function escapeFulltextQuotedTerm(string $term): string
588    {
589        $term = trim(preg_replace('/[+\-><()~*"@]+/u', ' ', $term) ?? '');
590        if ($term === '') {
591            return '';
592        }
593
594        $words = preg_split('/\s+/u', $term, -1, PREG_SPLIT_NO_EMPTY);
595        if ($words === false || $words === []) {
596            return '';
597        }
598
599        if (count($words) > 1) {
600            return '"' . implode(' ', array_map(static fn ($word) => str_replace('"', '', $word), $words)) . '"';
601        }
602
603        return '+' . $words[0];
604    }
605
606    private function buildCitizenNameWordBoundaryParts(string $escapedTerm, array &$params, string $prefix): array
607    {
608        $params[$prefix . 'Start'] = $escapedTerm . ' %';
609        $params[$prefix . 'End'] = '% ' . $escapedTerm;
610        $params[$prefix . 'Middle'] = '% ' . $escapedTerm . ' %';
611        $params[$prefix . 'Exact'] = $escapedTerm;
612
613        return [
614            'citizen_name LIKE :' . $prefix . 'Start',
615            'citizen_name LIKE :' . $prefix . 'End',
616            'citizen_name LIKE :' . $prefix . 'Middle',
617            'citizen_name = :' . $prefix . 'Exact',
618        ];
619    }
620
621    private function parseSearchTerms(string $queryString): array
622    {
623        if ($queryString === '') {
624            return [];
625        }
626
627        if (!preg_match_all('/"([^"]+)"|(\S+)/u', $queryString, $matches, PREG_SET_ORDER)) {
628            return [['value' => $queryString, 'quoted' => false]];
629        }
630
631        $terms = [];
632        foreach ($matches as $match) {
633            $value = trim($match[1] !== '' ? $match[1] : $match[2]);
634            if ($value === '') {
635                continue;
636            }
637            $terms[] = [
638                'value' => $value,
639                'quoted' => $match[1] !== '',
640            ];
641        }
642
643        return $terms;
644    }
645
646    private function isNumericSearchQuery(string $queryString): bool
647    {
648        return (bool) preg_match('#^\d+$#', $queryString);
649    }
650
651    public function clearLogsOlderThan(int $olderThan): bool
652    {
653        try {
654            $olderThanDate = (new \DateTime())->modify('-' . $olderThan . ' days');
655
656            $query = new Query\Log(Query\Base::DELETE);
657            $query->addConditionOlderThan($olderThanDate);
658
659            $result = $this->writeItem($query);
660            return $result !== false;
661        } catch (\Exception $e) {
662            \App::$log->error('Error during log cleanup', ['exception' => $e->getMessage()]);
663            return false;
664        }
665    }
666}