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