Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.87% covered (warning)
78.87%
209 / 265
50.00% covered (danger)
50.00%
14 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
Log
78.87% covered (warning)
78.87%
209 / 265
50.00% covered (danger)
50.00%
14 / 28
155.79
0.00% covered (danger)
0.00%
0 / 1
 writeLogEntry
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 writeProcessLog
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 buildProcessLogPayload
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 actionCodeFromLabel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionLabelFromCode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 formatDisplayFields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
7.05
 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%
6 / 6
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
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
6.01
 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\Zmsbackend\Log\Service;
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 \BO\Zmsbackend\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        'process_amendment',
56    ];
57
58    private const ACTION_LABEL_TO_CODE = [
59        self::ACTION_MAIL_SUCCESS => 'mail_success',
60        self::ACTION_MAIL_FAIL => 'mail_fail',
61        self::ACTION_STATUS_CHANGE => 'status_changed',
62        self::ACTION_SEND_REMINDER => 'reminder_sent',
63        self::ACTION_REMOVED => 'removed_from_queue',
64        self::ACTION_CALLED => 'called',
65        self::ACTION_ARCHIVED => 'archived',
66        self::ACTION_EDITED => 'edited',
67        self::ACTION_REDIRECTED => 'redirected',
68        self::ACTION_NEW => 'created',
69        self::ACTION_DELETED => 'deleted',
70        self::ACTION_CANCELED => 'canceled',
71    ];
72
73    public static $operator = 'lib';
74
75    public static function writeLogEntry(
76        $message,
77        $referenceId,
78        $type = self::PROCESS,
79        ?int $scopeId = null,
80        ?string $userId = 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        ];
92        $parameters = [
93            'message' => $message . static::backtraceLogEntry(),
94            'referenceId' => $referenceId,
95            'type' => $type,
96            'scopeId' => $scopeId,
97            'userId' => $userId,
98        ];
99
100        if ($indexedFields !== null) {
101            foreach (self::INDEXED_COLUMNS as $column) {
102                if (!array_key_exists($column, $indexedFields)) {
103                    continue;
104                }
105                $setParts[] = '`' . $column . '`=:' . $column;
106                $parameters[$column] = $indexedFields[$column];
107            }
108        }
109
110        $sql = 'INSERT INTO `log` SET ' . implode(', ', $setParts);
111
112        return $log->perform($sql, $parameters);
113    }
114
115    public static function writeProcessLog(
116        string $method,
117        string $action,
118        ?\BO\Zmsentities\Process $process,
119        ?\BO\Zmsentities\Useraccount $userAccount = null
120    ) {
121        if (empty($process) || empty($process->getId()) || empty($userAccount)) {
122            return;
123        }
124
125        $requests = new RequestList();
126        if (!empty($process->getRequestIds())) {
127            $requests = (new \BO\Zmsbackend\Request\Service\Request())->readRequestsByIds($process->getRequestIds());
128        }
129
130        $payload = self::buildProcessLogPayload($process, $action, $requests);
131
132        \BO\Zmsbackend\Log\Service\Log::writeLogEntry(
133            $method,
134            $process->getId(),
135            self::PROCESS,
136            $process->getScopeId(),
137            $userAccount->getId(),
138            $payload['indexed']
139        );
140    }
141
142    public static function buildProcessLogPayload(
143        \BO\Zmsentities\Process $process,
144        string $action,
145        RequestList $requests
146    ): array {
147        $appointmentAt = $process->getFirstAppointment()->toDateTime();
148
149        $indexed = array_filter([
150            'action' => self::actionCodeFromLabel($action),
151            'display_number' => $process->getDisplayNumber(),
152            'queue_number' => $process->getQueueNumber(),
153            'appointment_at' => $appointmentAt->format('Y-m-d H:i:s'),
154            'slot_count' => $process->getFirstAppointment()->slotCount ?? null,
155            'citizen_name' => $process->getFirstClient()->familyName,
156            'services' => implode(', ', array_map(function ($request) {
157                return $request->getName();
158            }, $requests->getAsArray())),
159            'scope_name' => $process->scope->getName(),
160            'citizen_email' => $process->getFirstClient()->email,
161            'citizen_phone' => $process->getFirstClient()->telephone,
162            'process_status' => $process->getStatus(),
163            'db_status' => $process->dbstatus,
164            'process_amendment' => $process->getAmendment(),
165        ], static function ($value) {
166            return $value !== null && $value !== '';
167        });
168
169        return [
170            'indexed' => $indexed,
171        ];
172    }
173
174    public static function actionCodeFromLabel(string $label): ?string
175    {
176        return self::ACTION_LABEL_TO_CODE[$label] ?? null;
177    }
178
179    public static function actionLabelFromCode(?string $code): ?string
180    {
181        return Entity::actionLabelFromCode($code);
182    }
183
184    public static function formatDisplayFields(array $log): array
185    {
186        return Entity::formatDisplayFields($log);
187    }
188
189    public function readByProcessId($processId)
190    {
191        $query = new \BO\Zmsbackend\Log\Repository\Log(\BO\Zmsbackend\Query\Base::SELECT);
192        $query->addEntityMapping();
193        $query->addConditionProcessId($processId);
194        $logList = new \BO\Zmsentities\Collection\LogList($this->fetchList($query, new Entity()));
195        return $logList;
196    }
197
198    public function readByProcessData(
199        $generalSearch,
200        $service,
201        $provider,
202        $date,
203        $userAction,
204        $page = 1,
205        $perPage = 100,
206        ?array $scopeIds = null
207    ) {
208        $fieldValues = [];
209        if ($provider) {
210            $fieldValues['scope_name'] = $provider;
211        }
212
213        if ($service) {
214            $fieldValues['services'] = $service;
215        }
216
217        return $this->getBySearchParams(
218            $fieldValues,
219            $generalSearch,
220            $userAction,
221            $date,
222            $perPage,
223            ($page - 1) * $perPage,
224            $scopeIds
225        );
226    }
227
228    public function getBySearchParams(
229        array $fieldValues,
230        ?string $generalSearch,
231        int $userAction,
232        ?DateTime $date,
233        int $perPage,
234        int $offset,
235        ?array $scopeIds = null
236    ) {
237        $params = ['logType' => self::PROCESS];
238        $conditions = array_merge(
239            ['type = :logType'],
240            $this->buildFieldValueConditions($fieldValues, $params),
241            $this->buildGeneralSearchConditionList($generalSearch, $params),
242            $this->buildScopeIdConditions($scopeIds, $params),
243            $this->buildDateConditions($date, $params),
244            $this->buildUserActionConditions($userAction)
245        );
246
247        $sql = 'SELECT * FROM log';
248        if (!empty($conditions)) {
249            $sql .= ' WHERE ' . implode(' AND ', $conditions);
250        }
251
252        $sql .= ' ORDER BY ts DESC LIMIT ' . (int) $perPage . ' OFFSET ' . (int) $offset;
253
254        $logs = new LogList();
255        foreach ($this->fetchAll($sql, $params) as $row) {
256            $logs->addEntity(new Entity($this->normalizeLogRow($row)));
257        }
258
259        return $logs;
260    }
261
262    private function buildFieldValueConditions(array $fieldValues, array &$params): array
263    {
264        $conditions = [];
265        foreach ($fieldValues as $field => $value) {
266            if ($value === null || $value === '') {
267                continue;
268            }
269
270            $likeValue = '%' . $this->escapeLikeValue((string) $value) . '%';
271            if ($field === 'scope_name') {
272                $conditions[] = 'scope_name LIKE :scopeName';
273                $params['scopeName'] = $likeValue;
274                continue;
275            }
276
277            if ($field === 'services') {
278                $conditions[] = 'services LIKE :services';
279                $params['services'] = $likeValue;
280            }
281        }
282
283        return $conditions;
284    }
285
286    private function buildGeneralSearchConditionList(?string $generalSearch, array &$params): array
287    {
288        if ($generalSearch === null || trim($generalSearch) === '') {
289            return [];
290        }
291
292        return $this->buildGeneralSearchConditions(trim($generalSearch), $params);
293    }
294
295    private function buildScopeIdConditions(?array $scopeIds, array &$params): array
296    {
297        if (empty($scopeIds)) {
298            return [];
299        }
300
301        $scopePlaceholders = [];
302        foreach (array_values($scopeIds) as $index => $scopeId) {
303            $parameterKey = 'scopeId' . $index;
304            $scopePlaceholders[] = ':' . $parameterKey;
305            $params[$parameterKey] = (int) $scopeId;
306        }
307
308        return ['scope_id IN (' . implode(', ', $scopePlaceholders) . ')'];
309    }
310
311    private function buildDateConditions(?DateTime $date, array &$params): array
312    {
313        if (empty($date)) {
314            return [];
315        }
316
317        $start = (clone $date)->setTime(0, 0, 0);
318        $end = (clone $date)->setTime(0, 0, 0)->add(new \DateInterval('P1D'));
319        $params['start'] = $start->format('Y-m-d H:i:s');
320        $params['end'] = $end->format('Y-m-d H:i:s');
321
322        return ['(ts >= :start AND ts < :end)'];
323    }
324
325    private function buildUserActionConditions(int $userAction): array
326    {
327        $systemUserPattern = $this->escapeLikeValue('_system_') . '%';
328
329        if ($userAction === 1) {
330            return ['(user_id IS NOT NULL AND user_id != \'\' AND user_id NOT LIKE \'' . $systemUserPattern . '\')'];
331        }
332
333        if ($userAction === 2) {
334            return ['(user_id LIKE \'' . $systemUserPattern . '\')'];
335        }
336
337        return [];
338    }
339
340    private function normalizeLogRow(array $row): array
341    {
342        if (isset($row['reference_id']) && !isset($row['reference'])) {
343            $row['reference'] = $row['reference_id'];
344        }
345
346        if (isset($row['ts']) && !is_numeric($row['ts'])) {
347            $row['ts'] = strtotime((string) $row['ts']);
348        }
349
350        return $row;
351    }
352
353    public function delete($processId)
354    {
355        $query = new \BO\Zmsbackend\Log\Repository\Log(\BO\Zmsbackend\Query\Base::SELECT);
356        $query->addEntityMapping();
357        $query->addConditionProcessId($processId);
358        $logList = new \BO\Zmsentities\Collection\LogList($this->fetchList($query, new Entity()));
359        return $logList;
360    }
361
362    protected static function backtraceLogEntry()
363    {
364        $trace = debug_backtrace();
365        $short = '';
366        foreach ($trace as $step) {
367            if (
368                isset($step['file'])
369                && isset($step['line'])
370                && !strpos($step['file'], 'Zmsbackend')
371            ) {
372                return ' (' . basename($step['file'], '.php') . ')';
373            }
374        }
375        return $short;
376    }
377
378    private function escapeLikeValue(string $value): string
379    {
380        return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
381    }
382
383    private function buildGeneralSearchConditions(string $generalSearch, array &$params): array
384    {
385        $terms = $this->parseSearchTerms($generalSearch);
386        if ($terms === []) {
387            return [];
388        }
389
390        $conditions = [];
391        foreach ($terms as $index => $termInfo) {
392            $term = $termInfo['value'];
393            $prefix = 'generalSearch' . $index;
394            $useWordBoundary = $termInfo['quoted'];
395
396            if ($useWordBoundary) {
397                $parts = $this->buildQuotedCitizenNameSearchParts($term, $params, $prefix . 'Name');
398            } else {
399                $parts = $this->buildUnquotedSearchParts($term, $prefix, $params);
400            }
401
402            if (count($terms) === 1 && $this->isNumericSearchQuery($term)) {
403                $params[$prefix . 'Id'] = (int) $term;
404                $parts[] = 'reference_id = :' . $prefix . 'Id';
405            }
406
407            $conditions[] = '(' . implode(' OR ', $parts) . ')';
408        }
409
410        return $conditions;
411    }
412
413    private function buildUnquotedSearchParts(string $term, string $paramPrefix, array &$params): array
414    {
415        $parts = [];
416        $params[$paramPrefix . 'Exact'] = $term;
417        $parts[] = 'display_number = :' . $paramPrefix . 'Exact';
418
419        if (mb_strlen($term) <= 2) {
420            if (mb_strlen($term) === 1) {
421                $escaped = $this->escapeLikeValue($term);
422                $params[$paramPrefix . 'Prefix'] = $escaped . '%';
423                foreach (self::TEXT_SEARCH_COLUMNS as $column) {
424                    $parts[] = $column . ' LIKE :' . $paramPrefix . 'Prefix';
425                }
426
427                return $parts;
428            }
429
430            $parts = array_merge($parts, $this->buildTextColumnBoundaryLikeParts($term, $paramPrefix, $params));
431
432            return $parts;
433        }
434
435        $fulltextTerm = $this->escapeFulltextBooleanTerm($term);
436        if ($fulltextTerm !== '') {
437            $params[$paramPrefix . 'Ft'] = $fulltextTerm;
438            $parts[] = 'MATCH(' . self::FULLTEXT_SEARCH_COLUMNS . ') AGAINST(:' . $paramPrefix . 'Ft IN BOOLEAN MODE)';
439        }
440
441        return $parts;
442    }
443
444    private function buildTextColumnBoundaryLikeParts(string $term, string $paramPrefix, array &$params): array
445    {
446        $escaped = $this->escapeLikeValue($term);
447        $params[$paramPrefix . 'Start'] = $escaped . '%';
448        $params[$paramPrefix . 'End'] = '%' . $escaped;
449        $params[$paramPrefix . 'Middle'] = '% ' . $escaped . ' %';
450        $params[$paramPrefix . 'MiddleStart'] = '% ' . $escaped;
451        $params[$paramPrefix . 'Exact'] = $term;
452
453        $parts = [];
454        foreach (self::TEXT_SEARCH_COLUMNS as $column) {
455            $parts[] = $column . ' LIKE :' . $paramPrefix . 'Start';
456            $parts[] = $column . ' LIKE :' . $paramPrefix . 'End';
457            $parts[] = $column . ' LIKE :' . $paramPrefix . 'Middle';
458            $parts[] = $column . ' LIKE :' . $paramPrefix . 'MiddleStart';
459            $parts[] = $column . ' = :' . $paramPrefix . 'Exact';
460        }
461
462        return $parts;
463    }
464
465    private function escapeFulltextBooleanTerm(string $term): string
466    {
467        $term = trim(preg_replace('/[+\-><()~*"@]+/u', ' ', $term) ?? '');
468        if ($term === '') {
469            return '';
470        }
471
472        $words = preg_split('/\s+/u', $term, -1, PREG_SPLIT_NO_EMPTY);
473        if ($words === false || $words === []) {
474            return '';
475        }
476
477        if (count($words) > 1) {
478            return '"' . implode(' ', array_map(static fn ($word) => str_replace('"', '', $word), $words)) . '"';
479        }
480
481        return '+' . $words[0] . '*';
482    }
483
484    private function buildQuotedCitizenNameSearchParts(string $term, array &$params, string $paramPrefix): array
485    {
486        if (mb_strlen($term) <= 2) {
487            return $this->buildCitizenNameWordBoundaryParts(
488                $this->escapeLikeValue($term),
489                $params,
490                $paramPrefix
491            );
492        }
493
494        $fulltextTerm = $this->escapeFulltextQuotedTerm($term);
495        if ($fulltextTerm === '') {
496            return [];
497        }
498
499        $params[$paramPrefix . 'Ft'] = $fulltextTerm;
500
501        return ['MATCH(citizen_name) AGAINST(:' . $paramPrefix . 'Ft IN BOOLEAN MODE)'];
502    }
503
504    private function escapeFulltextQuotedTerm(string $term): string
505    {
506        $term = trim(preg_replace('/[+\-><()~*"@]+/u', ' ', $term) ?? '');
507        if ($term === '') {
508            return '';
509        }
510
511        $words = preg_split('/\s+/u', $term, -1, PREG_SPLIT_NO_EMPTY);
512        if ($words === false || $words === []) {
513            return '';
514        }
515
516        if (count($words) > 1) {
517            return '"' . implode(' ', array_map(static fn ($word) => str_replace('"', '', $word), $words)) . '"';
518        }
519
520        return '+' . $words[0];
521    }
522
523    private function buildCitizenNameWordBoundaryParts(string $escapedTerm, array &$params, string $prefix): array
524    {
525        $params[$prefix . 'Start'] = $escapedTerm . ' %';
526        $params[$prefix . 'End'] = '% ' . $escapedTerm;
527        $params[$prefix . 'Middle'] = '% ' . $escapedTerm . ' %';
528        $params[$prefix . 'Exact'] = $escapedTerm;
529
530        return [
531            'citizen_name LIKE :' . $prefix . 'Start',
532            'citizen_name LIKE :' . $prefix . 'End',
533            'citizen_name LIKE :' . $prefix . 'Middle',
534            'citizen_name = :' . $prefix . 'Exact',
535        ];
536    }
537
538    private function parseSearchTerms(string $queryString): array
539    {
540        if ($queryString === '') {
541            return [];
542        }
543
544        if (!preg_match_all('/"([^"]+)"|(\S+)/u', $queryString, $matches, PREG_SET_ORDER)) {
545            return [['value' => $queryString, 'quoted' => false]];
546        }
547
548        $terms = [];
549        foreach ($matches as $match) {
550            $value = trim($match[1] !== '' ? $match[1] : $match[2]);
551            if ($value === '') {
552                continue;
553            }
554            $terms[] = [
555                'value' => $value,
556                'quoted' => $match[1] !== '',
557            ];
558        }
559
560        return $terms;
561    }
562
563    private function isNumericSearchQuery(string $queryString): bool
564    {
565        return (bool) preg_match('#^\d+$#', $queryString);
566    }
567
568    public function clearLogsOlderThan(int $olderThan): bool
569    {
570        try {
571            $olderThanDate = (new \DateTime())->modify('-' . $olderThan . ' days');
572
573            $query = new \BO\Zmsbackend\Log\Repository\Log(\BO\Zmsbackend\Query\Base::DELETE);
574            $query->addConditionOlderThan($olderThanDate);
575
576            $result = $this->writeItem($query);
577            return $result !== false;
578        } catch (\Exception $e) {
579            \App::$log->error('Error during log cleanup', ['exception' => $e->getMessage()]);
580            return false;
581        }
582    }
583}