Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.06% covered (warning)
81.06%
107 / 132
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ErrorLogRepository
81.06% covered (warning)
81.06%
107 / 132
12.50% covered (danger)
12.50%
1 / 8
65.31
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
 insert
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
4
 findById
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 findAll
70.31% covered (warning)
70.31%
45 / 64
0.00% covered (danger)
0.00%
0 / 1
43.69
 resolve
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 deleteByIds
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 getCountsByLevel
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 getUnresolvedCount
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\ErrorLog\Repository;
6
7use App\Domain\ErrorLog\Data\ErrorLogData;
8use App\Domain\ErrorLog\Data\ErrorLogFilterData;
9use App\Support\Row;
10use PDO;
11use RuntimeException;
12
13/**
14 * Repository for error log data access operations.
15 */
16final class ErrorLogRepository
17{
18    public function __construct(
19        private readonly PDO $pdo,
20    ) {}
21
22    /**
23     * @param array<string, mixed> $data
24     */
25    public function insert(array $data): int
26    {
27        $sql = "
28            INSERT INTO error_logs (
29                level, level_value, error_code, message, stack_trace,
30                request_uri, request_method, user_id, session_id,
31                ip_address, user_agent, request_body, context
32            ) VALUES (
33                :level, :level_value, :error_code, :message, :stack_trace,
34                :request_uri, :request_method, :user_id, :session_id,
35                :ip_address, :user_agent, :request_body, :context
36            )
37            RETURNING error_id
38        ";
39
40        $stmt = $this->pdo->prepare($sql);
41        if ($stmt === false) {
42            throw new RuntimeException('Failed to prepare statement');
43        }
44        $stmt->execute([
45            'level' => $data['level'],
46            'level_value' => $data['levelValue'],
47            'error_code' => $data['errorCode'] ?? null,
48            'message' => $data['message'],
49            'stack_trace' => $data['stackTrace'] ?? null,
50            'request_uri' => $data['requestUri'] ?? null,
51            'request_method' => $data['requestMethod'] ?? null,
52            'user_id' => $data['userId'] ?? null,
53            'session_id' => $data['sessionId'] ?? null,
54            'ip_address' => $data['ipAddress'] ?? null,
55            'user_agent' => $data['userAgent'] ?? null,
56            'request_body' => isset($data['requestBody']) ? json_encode($data['requestBody']) : null,
57            'context' => isset($data['context']) ? json_encode($data['context']) : null,
58        ]);
59
60        return (int)$stmt->fetchColumn();
61    }
62
63    public function findById(int $errorId): ?ErrorLogData
64    {
65        $sql = "
66            SELECT
67                error_id as \"errorId\",
68                level,
69                level_value as \"levelValue\",
70                error_code as \"errorCode\",
71                message,
72                stack_trace as \"stackTrace\",
73                request_uri as \"requestUri\",
74                request_method as \"requestMethod\",
75                user_id as \"userId\",
76                username,
77                user_email as \"userEmail\",
78                session_id as \"sessionId\",
79                session_created_at as \"sessionCreatedAt\",
80                ip_address::TEXT as \"ipAddress\",
81                user_agent as \"userAgent\",
82                request_body as \"requestBody\",
83                context,
84                resolved_at as \"resolvedAt\",
85                resolved_by as \"resolvedBy\",
86                resolved_by_username as \"resolvedByUsername\",
87                resolution_notes as \"resolutionNotes\",
88                created_at as \"createdAt\",
89                is_resolved as \"isResolved\"
90            FROM error_logs_detailed
91            WHERE error_id = :error_id
92        ";
93
94        $stmt = $this->pdo->prepare($sql);
95        if ($stmt === false) {
96            throw new RuntimeException('Failed to prepare statement');
97        }
98        $stmt->execute(['error_id' => $errorId]);
99
100        $row = $stmt->fetch(PDO::FETCH_ASSOC);
101        if (!is_array($row)) {
102            return null;
103        }
104
105        if (isset($row['requestBody']) && is_string($row['requestBody'])) {
106            $row['requestBody'] = json_decode($row['requestBody'], true);
107        }
108        if (isset($row['context']) && is_string($row['context'])) {
109            $row['context'] = json_decode($row['context'], true);
110        }
111
112        return new ErrorLogData($row);
113    }
114
115    /**
116     * @param ErrorLogFilterData $filter
117     * @return array{data: ErrorLogData[], total: int, page: int, limit: int}
118     */
119    public function findAll(ErrorLogFilterData $filter): array
120    {
121        $conditions = [];
122        $params = [];
123
124        if ($filter->level !== null) {
125            $conditions[] = 'level = :level';
126            $params['level'] = $filter->level;
127        }
128
129        if ($filter->minLevelValue !== null) {
130            $conditions[] = 'level_value >= :min_level_value';
131            $params['min_level_value'] = $filter->minLevelValue;
132        }
133
134        if ($filter->userId !== null) {
135            $conditions[] = 'user_id = :user_id';
136            $params['user_id'] = $filter->userId;
137        }
138
139        if ($filter->resolved !== null) {
140            $conditions[] = $filter->resolved ? 'resolved_at IS NOT NULL' : 'resolved_at IS NULL';
141        }
142
143        if ($filter->startDate !== null) {
144            $conditions[] = 'created_at >= :start_date';
145            $params['start_date'] = $filter->startDate;
146        }
147
148        if ($filter->endDate !== null) {
149            $conditions[] = 'created_at <= :end_date';
150            $params['end_date'] = $filter->endDate;
151        }
152
153        if ($filter->search !== null) {
154            $conditions[] = '(message ILIKE :search OR error_code ILIKE :search OR request_uri ILIKE :search)';
155            $params['search'] = '%' . $filter->search . '%';
156        }
157
158        $whereClause = count($conditions) > 0 ? 'WHERE ' . implode(' AND ', $conditions) : '';
159
160        $sortColumn = match ($filter->sortBy) {
161            'errorId' => 'error_id',
162            'level' => 'level',
163            'levelValue' => 'level_value',
164            'message' => 'message',
165            'requestUri' => 'request_uri',
166            'userId' => 'user_id',
167            'resolvedAt' => 'resolved_at',
168            default => 'created_at',
169        };
170
171        $countStmt = $this->pdo->prepare("SELECT COUNT(*) FROM error_logs_detailed {$whereClause}");
172
173        if ($countStmt === false) {
174            throw new RuntimeException('Failed to prepare count statement');
175        }
176        $countStmt->execute($params);
177        $total = (int)$countStmt->fetchColumn();
178
179        $sql = "
180            SELECT
181                error_id as \"errorId\",
182                level,
183                level_value as \"levelValue\",
184                error_code as \"errorCode\",
185                message,
186                stack_trace as \"stackTrace\",
187                request_uri as \"requestUri\",
188                request_method as \"requestMethod\",
189                user_id as \"userId\",
190                username,
191                user_email as \"userEmail\",
192                session_id as \"sessionId\",
193                session_created_at as \"sessionCreatedAt\",
194                ip_address::TEXT as \"ipAddress\",
195                user_agent as \"userAgent\",
196                request_body as \"requestBody\",
197                context,
198                resolved_at as \"resolvedAt\",
199                resolved_by as \"resolvedBy\",
200                resolved_by_username as \"resolvedByUsername\",
201                resolution_notes as \"resolutionNotes\",
202                created_at as \"createdAt\",
203                is_resolved as \"isResolved\"
204            FROM error_logs_detailed
205            {$whereClause}
206            ORDER BY {$sortColumn} {$filter->sortOrder}
207            LIMIT :limit OFFSET :offset
208        ";
209
210        $stmt = $this->pdo->prepare($sql);
211
212        if ($stmt === false) {
213            throw new RuntimeException('Failed to prepare statement');
214        }
215        foreach ($params as $key => $value) {
216            $stmt->bindValue($key, $value);
217        }
218        $stmt->bindValue('limit', $filter->limit, PDO::PARAM_INT);
219        $stmt->bindValue('offset', $filter->getOffset(), PDO::PARAM_INT);
220
221        $stmt->execute();
222
223        $data = [];
224        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
225            $rowArray = Row::from($row);
226            if (isset($rowArray['requestBody']) && is_string($rowArray['requestBody'])) {
227                $rowArray['requestBody'] = json_decode($rowArray['requestBody'], true);
228            }
229            if (isset($rowArray['context']) && is_string($rowArray['context'])) {
230                $rowArray['context'] = json_decode($rowArray['context'], true);
231            }
232            $data[] = new ErrorLogData($rowArray);
233        }
234
235        return [
236            'data' => $data,
237            'total' => $total,
238            'page' => $filter->page,
239            'limit' => $filter->limit,
240        ];
241    }
242
243    public function resolve(int $errorId, int $resolvedBy, string $notes): bool
244    {
245        $sql = "
246            UPDATE error_logs
247            SET resolved_at = CURRENT_TIMESTAMP,
248                resolved_by = :resolved_by,
249                resolution_notes = :notes
250            WHERE error_id = :error_id
251              AND resolved_at IS NULL
252        ";
253
254        $stmt = $this->pdo->prepare($sql);
255
256        if ($stmt === false) {
257            throw new RuntimeException('Failed to prepare statement');
258        }
259        return $stmt->execute([
260            'error_id' => $errorId,
261            'resolved_by' => $resolvedBy,
262            'notes' => $notes,
263        ]) && $stmt->rowCount() > 0;
264    }
265
266    /**
267     * @param int[] $ids
268     */
269    public function deleteByIds(array $ids): int
270    {
271        if (count($ids) === 0) {
272            return 0;
273        }
274
275        $placeholders = implode(',', array_fill(0, count($ids), '?'));
276        $stmt = $this->pdo->prepare("DELETE FROM error_logs WHERE error_id IN ({$placeholders})");
277
278        if ($stmt === false) {
279            throw new RuntimeException('Failed to prepare statement');
280        }
281        $stmt->execute($ids);
282
283        return $stmt->rowCount();
284    }
285
286    /**
287     * @return array<string, int>
288     */
289    public function getCountsByLevel(): array
290    {
291        $sql = "
292            SELECT level, COUNT(*) as count
293            FROM error_logs
294            WHERE resolved_at IS NULL
295            GROUP BY level
296            ORDER BY get_log_level_value(level) DESC
297        ";
298
299        $stmt = $this->pdo->query($sql);
300
301        if ($stmt === false) {
302            throw new RuntimeException('Failed to execute counts by level query');
303        }
304
305        $counts = [];
306        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
307            $rowArray = Row::from($row);
308            $counts[Row::string($rowArray, 'level')] = Row::int($rowArray, 'count');
309        }
310
311        return $counts;
312    }
313
314    public function getUnresolvedCount(): int
315    {
316        $stmt = $this->pdo->query('SELECT COUNT(*) FROM error_logs WHERE resolved_at IS NULL');
317
318        if ($stmt === false) {
319            throw new RuntimeException('Failed to execute unresolved count query');
320        }
321
322        return (int)$stmt->fetchColumn();
323    }
324}