Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 100
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
TimePunchAuditRepository
0.00% covered (danger)
0.00%
0 / 100
0.00% covered (danger)
0.00%
0 / 12
930
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logCreate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logEdit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logDelete
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logManagerOverride
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 log
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 findByPunchId
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 findByDateRange
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 findModifications
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 findManagerOverrides
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getWeeklyModificationsByEmployee
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getActionSummary
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace BuyerKiosk\Scheduling\Repositories;
4
5use DateTime;
6use DateTimeZone;
7use PDO;
8
9/**
10 * TimePunchAuditRepository
11 *
12 * Data access layer for time punch audit trail.
13 * Tracks all changes made to time punches for compliance and payroll accuracy.
14 *
15 * @package BuyerKiosk\Scheduling\Repositories
16 */
17class TimePunchAuditRepository
18{
19    private PDO $db;
20
21    public function __construct(PDO $db)
22    {
23        $this->db = $db;
24    }
25
26    /**
27     * Log a punch creation
28     *
29     * @param int $punchId The created punch ID
30     * @param int $userId User who created the punch
31     * @param array $newValues The punch data
32     * @param string|null $reason Optional reason for the punch
33     * @return int Audit record ID
34     */
35    public function logCreate(int $punchId, int $userId, array $newValues, ?string $reason = null): int
36    {
37        return $this->log($punchId, 'create', $userId, null, $newValues, $reason);
38    }
39
40    /**
41     * Log a punch edit
42     *
43     * @param int $punchId The edited punch ID
44     * @param int $userId User who made the edit
45     * @param array $oldValues Previous values
46     * @param array $newValues New values
47     * @param string $reason Required reason for the edit
48     * @return int Audit record ID
49     */
50    public function logEdit(int $punchId, int $userId, array $oldValues, array $newValues, string $reason): int
51    {
52        // Table schema stores action as enum('create','update','delete')
53        return $this->log($punchId, 'update', $userId, $oldValues, $newValues, $reason);
54    }
55
56    /**
57     * Log a punch deletion
58     *
59     * @param int $punchId The deleted punch ID
60     * @param int $userId User who deleted the punch
61     * @param array $oldValues The punch data at time of deletion
62     * @param string $reason Required reason for deletion
63     * @return int Audit record ID
64     */
65    public function logDelete(int $punchId, int $userId, array $oldValues, string $reason): int
66    {
67        return $this->log($punchId, 'delete', $userId, $oldValues, null, $reason);
68    }
69
70    /**
71     * Log a manager override
72     *
73     * @param int $punchId The punch ID
74     * @param int $managerId Manager who performed the override
75     * @param array $punchValues Current punch values
76     * @param string $overrideType Type of override (e.g., 'clock_window', 'unscheduled')
77     * @param string $reason Reason for override
78     * @return int Audit record ID
79     */
80    public function logManagerOverride(
81        int $punchId,
82        int $managerId,
83        array $punchValues,
84        string $overrideType,
85        string $reason
86    ): int {
87        $newValues = $punchValues;
88        $newValues['overrideType'] = $overrideType;
89
90        // Stored as an 'update' with a note prefix so it can be filtered.
91        return $this->log(
92            $punchId,
93            'update',
94            $managerId,
95            null,
96            $newValues,
97            "MANAGER_OVERRIDE: {$overrideType}{$reason}"
98        );
99    }
100
101    /**
102     * Internal log method
103     *
104     * @param int $punchId Punch ID
105     * @param string $action Action type
106     * @param int $userId User who performed the action
107     * @param array|null $oldValues Previous values
108     * @param array|null $newValues New values
109     * @param string|null $note Note/reason for the action
110     * @return int Audit record ID
111     */
112    private function log(
113        int $punchId,
114        string $action,
115        int $userId,
116        ?array $oldValues,
117        ?array $newValues,
118        ?string $note
119    ): int {
120        $occurredAtUtc = new DateTime('now', new DateTimeZone('UTC'));
121
122        $stmt = $this->db->prepare("
123            INSERT INTO scheduleTimePunchAudit
124                (punchId, action, actorUserId, occurredAt, oldValueJson, newValueJson, note)
125            VALUES
126                (:punchId, :action, :actorUserId, :occurredAt, :oldValueJson, :newValueJson, :note)
127        ");
128
129        $stmt->bindValue(':punchId', $punchId, PDO::PARAM_INT);
130        $stmt->bindValue(':action', $action);
131        $stmt->bindValue(':actorUserId', $userId, PDO::PARAM_INT);
132        $stmt->bindValue(':occurredAt', $occurredAtUtc->format('Y-m-d H:i:s'));
133        $stmt->bindValue(':oldValueJson', $oldValues !== null ? json_encode($oldValues) : null);
134        $stmt->bindValue(':newValueJson', $newValues !== null ? json_encode($newValues) : null);
135        $stmt->bindValue(':note', $note);
136
137        $stmt->execute();
138
139        return (int)$this->db->lastInsertId();
140    }
141
142    /**
143     * Get audit history for a specific punch
144     *
145     * @param int $punchId Punch ID
146     * @return array Audit records
147     */
148    public function findByPunchId(int $punchId): array
149    {
150        $stmt = $this->db->prepare("
151            SELECT
152                tpa.*,
153                CONCAT(u.first_name, ' ', u.last_name) as userName
154            FROM scheduleTimePunchAudit tpa
155            LEFT JOIN kiosk_users.users u ON tpa.actorUserId = u.id
156            WHERE tpa.punchId = :punchId
157            ORDER BY tpa.occurredAt DESC
158        ");
159        $stmt->bindValue(':punchId', $punchId, PDO::PARAM_INT);
160        $stmt->execute();
161
162        $records = [];
163        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
164            $row['oldValueJson'] = $row['oldValueJson'] ? json_decode($row['oldValueJson'], true) : null;
165            $row['newValueJson'] = $row['newValueJson'] ? json_decode($row['newValueJson'], true) : null;
166            $records[] = $row;
167        }
168
169        return $records;
170    }
171
172    /**
173     * Get all audit records within a date range
174     *
175     * @param DateTime $start Range start
176     * @param DateTime $end Range end
177     * @return array Audit records
178     */
179    public function findByDateRange(DateTime $start, DateTime $end): array
180    {
181        $stmt = $this->db->prepare("
182            SELECT
183                tpa.*,
184                CONCAT(u.first_name, ' ', u.last_name) as userName
185            FROM scheduleTimePunchAudit tpa
186            LEFT JOIN kiosk_users.users u ON tpa.actorUserId = u.id
187            WHERE tpa.occurredAt >= :start
188              AND tpa.occurredAt < :end
189            ORDER BY tpa.occurredAt DESC
190        ");
191        $stmt->bindValue(':start', $start->format('Y-m-d H:i:s'));
192        $stmt->bindValue(':end', $end->format('Y-m-d H:i:s'));
193        $stmt->execute();
194
195        $records = [];
196        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
197            $row['oldValueJson'] = $row['oldValueJson'] ? json_decode($row['oldValueJson'], true) : null;
198            $row['newValueJson'] = $row['newValueJson'] ? json_decode($row['newValueJson'], true) : null;
199            $records[] = $row;
200        }
201
202        return $records;
203    }
204
205    /**
206     * Get only edit/delete actions (for payroll review)
207     *
208     * @param DateTime $start Range start
209     * @param DateTime $end Range end
210     * @return array Audit records with modifications only
211     */
212    public function findModifications(DateTime $start, DateTime $end): array
213    {
214        $stmt = $this->db->prepare("
215            SELECT
216                tpa.*,
217                CONCAT(u.first_name, ' ', u.last_name) as userName
218            FROM scheduleTimePunchAudit tpa
219            LEFT JOIN kiosk_users.users u ON tpa.actorUserId = u.id
220            WHERE tpa.occurredAt >= :start
221              AND tpa.occurredAt < :end
222              AND tpa.action IN ('update', 'delete')
223            ORDER BY tpa.occurredAt DESC
224        ");
225        $stmt->bindValue(':start', $start->format('Y-m-d H:i:s'));
226        $stmt->bindValue(':end', $end->format('Y-m-d H:i:s'));
227        $stmt->execute();
228
229        $records = [];
230        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
231            $row['oldValueJson'] = $row['oldValueJson'] ? json_decode($row['oldValueJson'], true) : null;
232            $row['newValueJson'] = $row['newValueJson'] ? json_decode($row['newValueJson'], true) : null;
233            $records[] = $row;
234        }
235
236        return $records;
237    }
238
239    /**
240     * Get manager override records
241     *
242     * @param DateTime $start Range start
243     * @param DateTime $end Range end
244     * @return array Override records
245     */
246    public function findManagerOverrides(DateTime $start, DateTime $end): array
247    {
248        $stmt = $this->db->prepare("
249            SELECT
250                tpa.*,
251                CONCAT(u.first_name, ' ', u.last_name) as userName
252            FROM scheduleTimePunchAudit tpa
253            LEFT JOIN kiosk_users.users u ON tpa.actorUserId = u.id
254            WHERE tpa.occurredAt >= :start
255              AND tpa.occurredAt < :end
256              AND tpa.action = 'update'
257              AND tpa.note LIKE 'MANAGER_OVERRIDE:%'
258            ORDER BY tpa.occurredAt DESC
259        ");
260        $stmt->bindValue(':start', $start->format('Y-m-d H:i:s'));
261        $stmt->bindValue(':end', $end->format('Y-m-d H:i:s'));
262        $stmt->execute();
263
264        $records = [];
265        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
266            $row['oldValueJson'] = $row['oldValueJson'] ? json_decode($row['oldValueJson'], true) : null;
267            $row['newValueJson'] = $row['newValueJson'] ? json_decode($row['newValueJson'], true) : null;
268            $records[] = $row;
269        }
270
271        return $records;
272    }
273
274    /**
275     * Get summary of punch modifications by employee
276     *
277     * @param DateTime $weekStart Start of week
278     * @return array Employee modification counts
279     */
280    public function getWeeklyModificationsByEmployee(DateTime $weekStart): array
281    {
282        $weekEnd = (clone $weekStart)->modify('+7 days');
283
284        $stmt = $this->db->prepare("
285            SELECT
286                tp.employeeId,
287                u.firstName AS employeeFirstName,
288                u.lastName AS employeeLastName,
289                COUNT(*) as modificationCount
290            FROM scheduleTimePunchAudit tpa
291            INNER JOIN scheduleTimePunches tp ON tpa.punchId = tp.punchId
292            INNER JOIN kiosk_users.users u ON tp.employeeId = u.id
293            WHERE tpa.occurredAt >= :start
294              AND tpa.occurredAt < :end
295              AND tpa.action IN ('update', 'delete')
296            GROUP BY tp.employeeId, u.firstName, u.lastName
297            ORDER BY modificationCount DESC
298        ");
299        $stmt->bindValue(':start', $weekStart->format('Y-m-d H:i:s'));
300        $stmt->bindValue(':end', $weekEnd->format('Y-m-d H:i:s'));
301        $stmt->execute();
302
303        return $stmt->fetchAll(PDO::FETCH_ASSOC);
304    }
305
306    /**
307     * Get audit summary for compliance reporting
308     *
309     * @param DateTime $start Range start
310     * @param DateTime $end Range end
311     * @return array{creates: int, edits: int, deletes: int, managerOverrides: int}
312     */
313    public function getActionSummary(DateTime $start, DateTime $end): array
314    {
315        $stmt = $this->db->prepare("
316            SELECT
317                action,
318                COUNT(*) as count,
319                SUM(CASE WHEN action = 'update' AND note LIKE 'MANAGER_OVERRIDE:%' THEN 1 ELSE 0 END) as managerOverrideCount
320            FROM scheduleTimePunchAudit
321            WHERE occurredAt >= :start
322              AND occurredAt < :end
323            GROUP BY action
324        ");
325        $stmt->bindValue(':start', $start->format('Y-m-d H:i:s'));
326        $stmt->bindValue(':end', $end->format('Y-m-d H:i:s'));
327        $stmt->execute();
328
329        $summary = [
330            'creates' => 0,
331            'edits' => 0,
332            'deletes' => 0,
333            'managerOverrides' => 0,
334        ];
335
336        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
337            switch ($row['action']) {
338                case 'create':
339                    $summary['creates'] = (int)$row['count'];
340                    break;
341                case 'update':
342                    $summary['edits'] = (int)$row['count'];
343                    $summary['managerOverrides'] = (int)($row['managerOverrideCount'] ?? 0);
344                    break;
345                case 'delete':
346                    $summary['deletes'] = (int)$row['count'];
347                    break;
348            }
349        }
350
351        return $summary;
352    }
353}