Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 225
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
SyncService
0.00% covered (danger)
0.00%
0 / 225
0.00% covered (danger)
0.00%
0 / 15
4422
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 sync
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 syncFromWhenIWork
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
272
 syncFromHomebase
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getWhenIWorkApi
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 createUserFromWhenIWork
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 updateUserFromWhenIWork
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
110
 ensureStoreAssignment
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getStoreAssignments
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getUserById
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 deactivateAssignment
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 reactivateAssignment
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 downloadAndCacheAvatar
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
306
 logSync
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 logSyncSummary
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\TeamMember\Services;
4
5use BuyerKiosk\Auth\Models\StoreAssignment;
6use BuyerKiosk\Auth\Services\UserMatcher;
7use BuyerKiosk\Auth\Services\AuditLogger;
8use BuyerKiosk\Employee\SyncResult;
9use PDO;
10use InvalidArgumentException;
11use Exception;
12
13/**
14 * SyncService - Handles synchronization from external providers
15 *
16 * This service orchestrates the sync process from external employee management
17 * systems (WhenIWork, Homebase) to the unified users table. It implements
18 * the business rules defined in PRD Section "External Provider Sync".
19 *
20 * Business Rules (SR-1 through SR-7):
21 * - SR-1: Sync matches by externalId first, then by email
22 * - SR-2: Synced fields overwrite local unless avatarOverride=true for photos
23 * - SR-3: Local-only fields (clockPin, emergencyContact, etc.) are NEVER overwritten
24 * - SR-4: New synced employees have canLogin=false and source='wheniwork'
25 * - SR-5: Employees removed from WhenIWork get isActive=false
26 * - SR-6: Sync creates audit log entry with counts
27 * - SR-7: If WhenIWork API fails, preserve all existing data
28 *
29 * @see docs/specs/014-manage-employees-unified/product-requirements.md Lines 389-399
30 * @see docs/specs/014-manage-employees-unified/solution-design.md Lines 1300-1361
31 *
32 * @package BuyerKiosk\TeamMember\Services
33 */
34class SyncService
35{
36    /**
37     * Fields that are always local-only and never overwritten by sync
38     * @ref PRD Rule SR-3
39     */
40    private const LOCAL_ONLY_FIELDS = [
41        'clockPin',
42        'emergencyContactName',
43        'emergencyContactPhone',
44        'drsEmployeeId',
45        'roleColor',
46        'dailyReport',
47    ];
48
49    /**
50     * @var PDO Central database connection (kiosk_users)
51     */
52    private PDO $db;
53
54    /**
55     * @var \BuyerKiosk\Core\Store Store object
56     */
57    private $store;
58
59    /**
60     * @var string Store identifier
61     */
62    private string $typeNum;
63
64    /**
65     * @var object|null Mock WhenIWork API for testing
66     */
67    private $wiwApi;
68
69    /**
70     * @var UserMatcher User matching service
71     */
72    private UserMatcher $userMatcher;
73
74    /**
75     * @var AuditLogger Audit logging service
76     */
77    private AuditLogger $auditLogger;
78
79    /**
80     * Constructor
81     *
82     * @param PDO $centralDb Central database connection (kiosk_users)
83     * @param \BuyerKiosk\Core\Store $store Store object
84     * @param object|null $wiwApi Mock WhenIWork API for testing
85     * @param UserMatcher|null $userMatcher User matching service
86     * @param AuditLogger|null $auditLogger Audit logging service
87     */
88    public function __construct(
89        PDO $centralDb,
90        $store,
91        ?object $wiwApi = null,
92        ?UserMatcher $userMatcher = null,
93        ?AuditLogger $auditLogger = null
94    ) {
95        $this->db = $centralDb;
96        $this->store = $store;
97        $this->typeNum = $store->getTypeNum();
98        $this->wiwApi = $wiwApi;
99        $this->userMatcher = $userMatcher ?? new UserMatcher($centralDb);
100        $this->auditLogger = $auditLogger ?? new AuditLogger($centralDb);
101    }
102
103    /**
104     * Sync employees from external provider
105     *
106     * This is the main entry point for syncing. It determines the provider
107     * from the store configuration and delegates to the appropriate sync method.
108     *
109     * @return SyncResult Result containing counts and errors
110     * @throws InvalidArgumentException If store doesn't use external provider
111     */
112    public function sync(): SyncResult
113    {
114        $source = $this->store->getEmployeeSource();
115
116        if ($source === 'homegrown') {
117            throw new InvalidArgumentException(
118                'Cannot sync for homegrown stores. Use external provider (WhenIWork/Homebase).'
119            );
120        }
121
122        if ($source === 'wheniwork') {
123            return $this->syncFromWhenIWork();
124        }
125
126        if ($source === 'homebase') {
127            return $this->syncFromHomebase();
128        }
129
130        throw new InvalidArgumentException("Unknown employee source: {$source}");
131    }
132
133    /**
134     * Sync employees from WhenIWork
135     *
136     * Process:
137     * 1. Fetch all employees from WhenIWork API for this location
138     * 2. For each employee, use UserMatcher to find existing user
139     * 3. Create new user OR update existing + create/update store assignment
140     * 4. Deactivate store assignments for employees removed from WhenIWork
141     * 5. Log all operations to userSyncLog
142     *
143     * @return SyncResult
144     */
145    private function syncFromWhenIWork(): SyncResult
146    {
147        $result = new SyncResult();
148
149        try {
150            // Fetch from WhenIWork API
151            $wiwApi = $this->getWhenIWorkApi();
152            $wiwResponse = $wiwApi->get('users', ['location_id' => $this->store->getWiwLocationID()]);
153
154            if (!isset($wiwResponse->users)) {
155                $result->errors[] = 'Invalid response from WhenIWork API';
156                return $result;
157            }
158
159            $wiwEmployees = $wiwResponse->users;
160
161            // Get current store assignments for this store (including inactive)
162            $currentAssignments = $this->getStoreAssignments(false);
163            $assignmentsByUserId = [];
164            foreach ($currentAssignments as $assignment) {
165                $assignmentsByUserId[$assignment['userId']] = $assignment;
166            }
167
168            // Track which user IDs we've processed
169            $processedUserIds = [];
170
171            // Track emails we've seen to detect duplicates (SR-7 edge case)
172            $seenEmails = [];
173
174            foreach ($wiwEmployees as $wiwEmp) {
175                $externalId = (string)$wiwEmp->id;
176                $firstName = $wiwEmp->first_name ?? '';
177                $lastName = $wiwEmp->last_name ?? '';
178                $email = $wiwEmp->email ?? null;
179                $phone = $wiwEmp->phone_number ?? null;
180
181                // Check for duplicate emails in response
182                if ($email && isset($seenEmails[$email])) {
183                    // Skip duplicate, just log to error_log (not sync log - no enum value for warnings)
184                    error_log("SyncService: Duplicate email in WhenIWork response: {$email}");
185                    continue;
186                }
187                if ($email) {
188                    $seenEmails[$email] = true;
189                }
190
191                // Use UserMatcher to find existing user (SR-1: externalId first, then email, then store-scoped name)
192                $matchResult = $this->userMatcher->findMatch(
193                    'wheniwork',
194                    $externalId,
195                    $email,
196                    $firstName,
197                    $lastName,
198                    $phone,
199                    $this->typeNum  // Pass typeNum for store-scoped name matching
200                );
201
202                if ($matchResult->wasMatched()) {
203                    // Found existing user - update and ensure store assignment
204                    $userId = $matchResult->getUserId();
205                    $this->updateUserFromWhenIWork($userId, $wiwEmp, $matchResult);
206                    $this->ensureStoreAssignment($userId, $wiwEmp, $assignmentsByUserId[$userId] ?? null);
207
208                    $processedUserIds[] = $userId;
209
210                    // Log merge if matched by something other than externalId
211                    if ($matchResult->getMatchType() !== 'external_id') {
212                        $this->logSync(
213                            'linked',  // Use 'linked' enum value for merge operations
214                            $userId,
215                            $externalId,
216                            "Matched via {$matchResult->getMatchType()} (confidence: {$matchResult->getConfidence()}%): {$matchResult->getReason()}"
217                        );
218                        $result->merged++;
219                    } else {
220                        $this->logSync('updated', $userId, $externalId, null);
221                    }
222
223                    $result->updated++;
224                } else {
225                    // No existing user - create new (SR-4: canLogin=false, source='wheniwork')
226                    $userId = $this->createUserFromWhenIWork($wiwEmp);
227                    $this->ensureStoreAssignment($userId, $wiwEmp, null);
228
229                    $processedUserIds[] = $userId;
230                    $result->created++;
231
232                    $this->logSync('created', $userId, $externalId, null);
233                }
234            }
235
236            // SR-5: Deactivate assignments for users no longer in WhenIWork
237            foreach ($assignmentsByUserId as $userId => $assignment) {
238                if (!in_array($userId, $processedUserIds)) {
239                    // Check if this user was WhenIWork-sourced
240                    $user = $this->getUserById($userId);
241                    if ($user && $user['source'] === 'wheniwork') {
242                        if ($assignment['isActive']) {
243                            $this->deactivateAssignment($userId);
244                            $this->logSync('deactivated', $userId, $user['externalId'] ?? null, 'Removed from WhenIWork');
245                            $result->deactivated++;
246                        }
247                    }
248                } elseif (!$assignment['isActive']) {
249                    // Reactivate if user is back in WhenIWork
250                    $this->reactivateAssignment($userId);
251                    $user = $this->getUserById($userId);
252                    $this->logSync('reactivated', $userId, $user['externalId'] ?? null, 'Reappeared in WhenIWork');
253                    $result->reactivated++;
254                }
255            }
256
257            // SR-6: Log successful sync summary (use 'updated' with null userId for summary)
258            $this->logSyncSummary($result);
259
260        } catch (Exception $e) {
261            // SR-7: API failure - log error and preserve existing data
262            $result->errors[] = $e->getMessage();
263            error_log("SyncService::syncFromWhenIWork error: " . $e->getMessage());
264        }
265
266        return $result;
267    }
268
269    /**
270     * Sync employees from Homebase (stub - to be implemented)
271     *
272     * @return SyncResult
273     */
274    private function syncFromHomebase(): SyncResult
275    {
276        $result = new SyncResult();
277        $result->errors[] = 'Homebase sync not yet implemented';
278        return $result;
279    }
280
281    /**
282     * Get WhenIWork API client
283     *
284     * @return object
285     */
286    private function getWhenIWorkApi(): object
287    {
288        if ($this->wiwApi !== null) {
289            return $this->wiwApi;
290        }
291
292        return new \Wheniwork($this->store->getWiwToken());
293    }
294
295    /**
296     * Create a new user from WhenIWork data
297     *
298     * @param object $wiwEmp WhenIWork employee object
299     * @return int New user ID
300     * @ref PRD SR-4: canLogin=false, source='wheniwork'
301     */
302    private function createUserFromWhenIWork(object $wiwEmp): int
303    {
304        // Download and cache avatar locally
305        $photoUrl = $this->downloadAndCacheAvatar($wiwEmp);
306
307        $stmt = $this->db->prepare("
308            INSERT INTO users (
309                firstName, lastName, email, phone, photoUrl,
310                position, hourlyRate, source, externalId,
311                canLogin, accountType, enabled, active,
312                lastSyncedAt, createdAt, updatedAt
313            ) VALUES (
314                :firstName, :lastName, :email, :phone, :photoUrl,
315                :position, :hourlyRate, 'wheniwork', :externalId,
316                0, 'employee', 1, 1,
317                NOW(), NOW(), NOW()
318            )
319        ");
320
321        $params = [
322            'firstName' => $wiwEmp->first_name ?? '',
323            'lastName' => $wiwEmp->last_name ?? '',
324            'email' => $wiwEmp->email ?? null,
325            'phone' => $wiwEmp->phone_number ?? null,
326            'photoUrl' => $photoUrl,
327            'position' => isset($wiwEmp->positions[0]->name) ? $wiwEmp->positions[0]->name : null,
328            'hourlyRate' => $wiwEmp->hourly_rate ?? null,
329            'externalId' => (string)$wiwEmp->id,
330        ];
331
332        $stmt->execute($params);
333
334        return (int)$this->db->lastInsertId();
335    }
336
337    /**
338     * Update existing user with WhenIWork data
339     *
340     * @param int $userId User ID
341     * @param object $wiwEmp WhenIWork employee object
342     * @param \BuyerKiosk\Auth\Services\MatchResult $matchResult How the user was matched
343     * @ref PRD SR-2: Synced fields overwrite except avatarOverride
344     * @ref PRD SR-3: Local-only fields never overwritten
345     */
346    private function updateUserFromWhenIWork(int $userId, object $wiwEmp, $matchResult): void
347    {
348        // Check if this user has a manual avatar override
349        $stmt = $this->db->prepare("SELECT avatarOverride, photoUrl, source, externalId FROM users WHERE id = :id");
350        $stmt->execute(['id' => $userId]);
351        $currentUser = $stmt->fetch(PDO::FETCH_ASSOC);
352
353        $photoUrl = null;
354        $updatePhoto = true;
355
356        // SR-2: If avatar is overridden, don't update the photo
357        if ($currentUser && !empty($currentUser['avatarOverride'])) {
358            $updatePhoto = false;
359            $photoUrl = $currentUser['photoUrl'];
360        } else {
361            $photoUrl = $this->downloadAndCacheAvatar($wiwEmp);
362        }
363
364        // Build update SQL (SR-3: only sync allowed fields)
365        $sql = "
366            UPDATE users SET
367                firstName = :firstName,
368                lastName = :lastName,
369                email = COALESCE(:email, email),
370                phone = COALESCE(:phone, phone),
371                position = :position,
372                hourlyRate = COALESCE(:hourlyRate, hourlyRate),
373                lastSyncedAt = NOW(),
374                updatedAt = NOW()";
375
376        // Update photo if not overridden
377        if ($updatePhoto && $photoUrl) {
378            $sql .= ", photoUrl = :photoUrl";
379        }
380
381        // If this was a non-external-id match, update source/externalId to link records
382        if ($matchResult->getMatchType() !== 'external_id') {
383            $sql .= ", source = 'wheniwork', externalId = :externalId";
384        }
385
386        $sql .= " WHERE id = :id";
387
388        $stmt = $this->db->prepare($sql);
389
390        $params = [
391            'firstName' => $wiwEmp->first_name ?? '',
392            'lastName' => $wiwEmp->last_name ?? '',
393            'email' => $wiwEmp->email ?? null,
394            'phone' => $wiwEmp->phone_number ?? null,
395            'position' => isset($wiwEmp->positions[0]->name) ? $wiwEmp->positions[0]->name : null,
396            'hourlyRate' => $wiwEmp->hourly_rate ?? null,
397            'id' => $userId,
398        ];
399
400        if ($updatePhoto && $photoUrl) {
401            $params['photoUrl'] = $photoUrl;
402        }
403
404        if ($matchResult->getMatchType() !== 'external_id') {
405            $params['externalId'] = (string)$wiwEmp->id;
406        }
407
408        $stmt->execute($params);
409    }
410
411    /**
412     * Ensure a store assignment exists for user
413     *
414     * @param int $userId User ID
415     * @param object $wiwEmp WhenIWork employee object
416     * @param array|null $existingAssignment Existing assignment data if any
417     * @ref PRD SR-3: clockPin is NEVER overwritten
418     */
419    private function ensureStoreAssignment(int $userId, object $wiwEmp, ?array $existingAssignment): void
420    {
421        if ($existingAssignment) {
422            // Assignment exists - don't modify local-only fields (SR-3)
423            // Just ensure it's active if needed
424            if (!$existingAssignment['isActive']) {
425                $this->reactivateAssignment($userId);
426            }
427        } else {
428            // Create new assignment
429            $stmt = $this->db->prepare("
430                INSERT INTO userStoreAssignments (userId, typeNum, isActive, assignedAt)
431                VALUES (:userId, :typeNum, 1, NOW())
432                ON DUPLICATE KEY UPDATE isActive = 1, deactivatedAt = NULL
433            ");
434            $stmt->execute([
435                'userId' => $userId,
436                'typeNum' => $this->typeNum,
437            ]);
438        }
439    }
440
441    /**
442     * Get current store assignments
443     *
444     * @param bool $activeOnly Only return active assignments
445     * @return array
446     */
447    private function getStoreAssignments(bool $activeOnly = true): array
448    {
449        $sql = "SELECT userId, typeNum, clockPin, drsEmployeeId, role, isActive
450                FROM userStoreAssignments
451                WHERE typeNum = :typeNum";
452
453        if ($activeOnly) {
454            $sql .= " AND isActive = 1";
455        }
456
457        $stmt = $this->db->prepare($sql);
458        $stmt->execute(['typeNum' => $this->typeNum]);
459
460        return $stmt->fetchAll(PDO::FETCH_ASSOC);
461    }
462
463    /**
464     * Get user by ID
465     *
466     * @param int $userId User ID
467     * @return array|null
468     */
469    private function getUserById(int $userId): ?array
470    {
471        $stmt = $this->db->prepare("SELECT * FROM users WHERE id = :id");
472        $stmt->execute(['id' => $userId]);
473        $row = $stmt->fetch(PDO::FETCH_ASSOC);
474        return $row ?: null;
475    }
476
477    /**
478     * Deactivate a store assignment
479     *
480     * @param int $userId User ID
481     * @return bool
482     */
483    private function deactivateAssignment(int $userId): bool
484    {
485        $stmt = $this->db->prepare("
486            UPDATE userStoreAssignments
487            SET isActive = 0, deactivatedAt = NOW()
488            WHERE userId = :userId AND typeNum = :typeNum
489        ");
490        return $stmt->execute(['userId' => $userId, 'typeNum' => $this->typeNum]);
491    }
492
493    /**
494     * Reactivate a store assignment
495     *
496     * @param int $userId User ID
497     * @return bool
498     */
499    private function reactivateAssignment(int $userId): bool
500    {
501        $stmt = $this->db->prepare("
502            UPDATE userStoreAssignments
503            SET isActive = 1, deactivatedAt = NULL
504            WHERE userId = :userId AND typeNum = :typeNum
505        ");
506        return $stmt->execute(['userId' => $userId, 'typeNum' => $this->typeNum]);
507    }
508
509    /**
510     * Download and cache employee avatar locally
511     *
512     * WhenIWork returns avatar as an object with URL templates.
513     * We download and cache locally to avoid external dependencies.
514     *
515     * @param object $wiwEmp WhenIWork employee object
516     * @param string $size Avatar size name (small, medium, large)
517     * @return string|null Local URL path or null if download failed
518     */
519    private function downloadAndCacheAvatar(object $wiwEmp, string $size = 'medium'): ?string
520    {
521        // Generate filename from WhenIWork user ID
522        $wiwUserId = (string)$wiwEmp->id;
523        $filename = 'avatar_' . $wiwUserId . '.jpg';
524        $uploadDir = $_SERVER['DOCUMENT_ROOT'] . '/uploads/employees/' . $this->typeNum;
525        $localPath = $uploadDir . '/' . $filename;
526        $localUrl = '/uploads/employees/' . $this->typeNum . '/' . $filename;
527
528        // Check if we already have a recent local copy (< 24 hours old)
529        if (file_exists($localPath) && filesize($localPath) > 100) {
530            $fileAge = time() - filemtime($localPath);
531            if ($fileAge < 86400) { // 24 hours
532                return $localUrl;
533            }
534        }
535
536        // Get the remote avatar URL
537        $remoteUrl = null;
538
539        if (isset($wiwEmp->avatar) && is_object($wiwEmp->avatar)) {
540            if (isset($wiwEmp->avatar->cacheUrl)) {
541                $remoteUrl = str_replace('%s', $size, $wiwEmp->avatar->cacheUrl);
542            } elseif (isset($wiwEmp->avatar->url)) {
543                $remoteUrl = str_replace('%s', $size, $wiwEmp->avatar->url);
544            }
545        } elseif (isset($wiwEmp->avatar_url) && is_string($wiwEmp->avatar_url)) {
546            $remoteUrl = $wiwEmp->avatar_url;
547        }
548
549        if (!$remoteUrl) {
550            return null;
551        }
552
553        // Create local storage directory
554        if (!is_dir($uploadDir)) {
555            mkdir($uploadDir, 0755, true);
556        }
557
558        // Download the image
559        try {
560            $context = stream_context_create([
561                'http' => [
562                    'timeout' => 10,
563                    'user_agent' => 'BuyerKiosk/1.0',
564                ],
565            ]);
566
567            $imageData = @file_get_contents($remoteUrl, false, $context);
568
569            if ($imageData === false || strlen($imageData) < 100) {
570                return null;
571            }
572
573            // Verify it's actually an image
574            $finfo = new \finfo(FILEINFO_MIME_TYPE);
575            $mimeType = $finfo->buffer($imageData);
576
577            if (!in_array($mimeType, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'])) {
578                return null;
579            }
580
581            // Save locally
582            if (file_put_contents($localPath, $imageData) !== false) {
583                return $localUrl;
584            }
585        } catch (Exception $e) {
586            error_log("SyncService::downloadAndCacheAvatar error for user {$wiwUserId}" . $e->getMessage());
587        }
588
589        return null;
590    }
591
592    /**
593     * Log a sync operation to userSyncLog table
594     *
595     * Valid action values (enum): created, updated, deactivated, reactivated, linked, unlinked
596     *
597     * @param string $action Action type (must match enum values in userSyncLog table)
598     * @param int|null $userId User ID
599     * @param string|null $externalId WhenIWork user ID
600     * @param string|null $details Additional details (will be wrapped in JSON)
601     */
602    private function logSync(string $action, ?int $userId, ?string $externalId, ?string $details): void
603    {
604        // The 'changes' column has a JSON validity constraint, so wrap details in JSON
605        $changes = null;
606        if ($details !== null) {
607            // If it's already valid JSON, use it directly; otherwise wrap it
608            json_decode($details);
609            if (json_last_error() === JSON_ERROR_NONE) {
610                $changes = $details;
611            } else {
612                $changes = json_encode(['message' => $details]);
613            }
614        }
615
616        $stmt = $this->db->prepare("
617            INSERT INTO userSyncLog (userId, typeNum, provider, action, externalId, changes)
618            VALUES (:userId, :typeNum, 'wheniwork', :action, :externalId, :changes)
619        ");
620        $stmt->execute([
621            'userId' => $userId,
622            'typeNum' => $this->typeNum,
623            'action' => $action,
624            'externalId' => $externalId,
625            'changes' => $changes,
626        ]);
627    }
628
629    /**
630     * Log sync summary to userSyncLog table
631     *
632     * Uses 'updated' action with null userId to indicate a summary record.
633     *
634     * @param SyncResult $result Sync result with counts
635     */
636    private function logSyncSummary(SyncResult $result): void
637    {
638        $stmt = $this->db->prepare("
639            INSERT INTO userSyncLog (userId, typeNum, provider, action, externalId, changes)
640            VALUES (NULL, :typeNum, 'wheniwork', 'updated', NULL, :changes)
641        ");
642        $stmt->execute([
643            'typeNum' => $this->typeNum,
644            'changes' => json_encode($result->toArray()),
645        ]);
646    }
647}