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