Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 158
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
TeamMemberDTO
0.00% covered (danger)
0.00%
0 / 158
0.00% covered (danger)
0.00%
0 / 8
506
0.00% covered (danger)
0.00%
0 / 1
 fromArray
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
30
 fromUnifiedUser
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
2
 toArray
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
2
 computeDisplayName
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 computeStatus
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 isCurrentlyOnLeave
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 computeHasPendingInvitation
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 computeEditableFields
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace BuyerKiosk\TeamMember\DTOs;
4
5use BuyerKiosk\Auth\Models\UnifiedUser;
6use BuyerKiosk\Auth\Models\StoreAssignment;
7
8/**
9 * TeamMemberDTO - Data Transfer Object for Team Member UI
10 *
11 * Represents a team member in the Manage Team Members page. Combines data from
12 * UnifiedUser and StoreAssignment models into a single structure optimized for
13 * the UI layer and API responses.
14 *
15 * Key features:
16 * - Computed status (active, inactive, on_leave)
17 * - Source-based field editability (homegrown vs external sync)
18 * - Role name/color mapping
19 * - Pending invitation detection
20 *
21 * @see docs/specs/014-manage-employees-unified/solution-design.md lines 1138-1201
22 *
23 * @package BuyerKiosk\TeamMember\DTOs
24 */
25class TeamMemberDTO
26{
27    // =========================================================================
28    // Role Mappings (static configuration)
29    // =========================================================================
30
31    /**
32     * Role ID to display name mapping
33     * Maps to userStoreAssignments.role column values
34     */
35    private const ROLE_NAMES = [
36        0 => 'Employee',
37        1 => 'Owner',
38        2 => 'Manager',
39        3 => 'Shift Lead',
40        4 => 'Buyer',
41        5 => 'Cashier',
42    ];
43
44    /**
45     * Role ID to color hex mapping
46     * Colors align with Bootstrap theme colors
47     */
48    private const ROLE_COLORS = [
49        0 => '#6c757d', // Employee - gray (bg-secondary)
50        1 => '#212529', // Owner - dark (bg-dark)
51        2 => '#dc3545', // Manager - red (bg-danger)
52        3 => '#0d6efd', // Shift Lead - blue (bg-primary)
53        4 => '#ffc107', // Buyer - yellow (bg-warning)
54        5 => '#0dcaf0', // Cashier - cyan (bg-info)
55    ];
56
57    /**
58     * Fields that are always editable regardless of source
59     * (local-only fields that are not synced from external providers)
60     */
61    private const LOCAL_ONLY_FIELDS = [
62        'clockPin',
63        'drsEmployeeId',
64        'dailyEmailEnabled',
65        'emergencyContactName',
66        'emergencyContactPhone',
67        'role',
68    ];
69
70    /**
71     * All editable fields (for homegrown source)
72     */
73    private const ALL_EDITABLE_FIELDS = [
74        'firstName',
75        'lastName',
76        'email',
77        'phone',
78        'photoUrl',
79        'position',
80        'hireDate',
81        'terminationDate',
82        'hourlyRate',
83        'leaveStartDate',
84        'leaveEndDate',
85        'clockPin',
86        'drsEmployeeId',
87        'dailyEmailEnabled',
88        'emergencyContactName',
89        'emergencyContactPhone',
90        'role',
91    ];
92
93    // =========================================================================
94    // Properties
95    // =========================================================================
96
97    // Core Identity
98    public int $id;
99    public ?string $firstName;
100    public ?string $lastName;
101    public string $displayName;
102    public ?string $email;
103    public ?string $phone;
104    public ?string $photoUrl;
105    public bool $avatarOverride;
106
107    // Employment
108    public ?string $position;
109    public int $role;
110    public string $roleName;
111    public string $roleColor;
112    public ?string $hireDate;
113    public ?string $terminationDate;
114    public ?string $leaveStartDate;
115    public ?string $leaveEndDate;
116    public ?string $hourlyRate;
117    public ?string $drsEmployeeId;
118    public bool $dailyEmailEnabled;
119
120    // Emergency Contact
121    public ?string $emergencyContactName;
122    public ?string $emergencyContactPhone;
123
124    // Status
125    public string $status; // 'active', 'inactive', 'on_leave'
126    public bool $isActive;
127
128    // Source & Sync
129    public string $source;
130    public ?string $externalId;
131    public ?string $lastSyncedAt;
132
133    // Access
134    public bool $canLogin;
135    public ?string $username;
136    public ?string $lastLoginAt;
137    public bool $mfaEnabled;
138    public bool $hasClockPin;
139    public ?string $clockPin; // Internal only - not serialized
140    public bool $hasPendingInvitation;
141    public ?string $invitationExpiresAt;
142
143    // Store Assignment
144    public string $assignedAt;
145    public ?string $deactivatedAt;
146
147    // Computed
148    public bool $isEditable;
149    public array $editableFields;
150
151    // =========================================================================
152    // Factory Methods
153    // =========================================================================
154
155    /**
156     * Create DTO from array of data
157     *
158     * @param array $data Combined user and store assignment data
159     * @param array|null $roleConfig Optional role config map (roleId => ['name' => ..., 'color' => ...])
160     * @return self
161     */
162    public static function fromArray(array $data, ?array $roleConfig = null): self
163    {
164        $dto = new self();
165
166        // Core Identity
167        $dto->id = (int) ($data['id'] ?? 0);
168        $dto->firstName = $data['firstName'] ?? null;
169        $dto->lastName = $data['lastName'] ?? null;
170        $dto->email = $data['email'] ?? null;
171        $dto->phone = $data['phone'] ?? null;
172        $dto->photoUrl = $data['photoUrl'] ?? null;
173        $dto->avatarOverride = (bool) ($data['avatarOverride'] ?? false);
174
175        // Display name computation
176        $dto->displayName = self::computeDisplayName(
177            $data['displayName'] ?? null,
178            $dto->firstName,
179            $dto->lastName
180        );
181
182        // Employment
183        $dto->position = $data['position'] ?? null;
184        $dto->role = (int) ($data['role'] ?? 0);
185
186        // Use provided roleConfig if available, otherwise fall back to constants
187        if ($roleConfig !== null && isset($roleConfig[$dto->role])) {
188            $dto->roleName = $roleConfig[$dto->role]['name'] ?? self::ROLE_NAMES[$dto->role] ?? self::ROLE_NAMES[0];
189            $dto->roleColor = $roleConfig[$dto->role]['color'] ?? self::ROLE_COLORS[$dto->role] ?? self::ROLE_COLORS[0];
190        } else {
191            $dto->roleName = self::ROLE_NAMES[$dto->role] ?? self::ROLE_NAMES[0];
192            $dto->roleColor = self::ROLE_COLORS[$dto->role] ?? self::ROLE_COLORS[0];
193        }
194        $dto->hireDate = $data['hireDate'] ?? null;
195        $dto->terminationDate = $data['terminationDate'] ?? null;
196        $dto->leaveStartDate = $data['leaveStartDate'] ?? null;
197        $dto->leaveEndDate = $data['leaveEndDate'] ?? null;
198        $dto->hourlyRate = $data['hourlyRate'] ?? null;
199        $dto->drsEmployeeId = $data['drsEmployeeId'] ?? null;
200        $dto->dailyEmailEnabled = (bool) ($data['dailyReport'] ?? $data['dailyEmailEnabled'] ?? false);
201
202        // Emergency Contact
203        $dto->emergencyContactName = $data['emergencyContactName'] ?? null;
204        $dto->emergencyContactPhone = $data['emergencyContactPhone'] ?? null;
205
206        // Source & Sync
207        $dto->source = $data['source'] ?? 'homegrown';
208        $dto->externalId = $data['externalId'] ?? null;
209        $dto->lastSyncedAt = $data['lastSyncedAt'] ?? null;
210
211        // Access
212        $dto->canLogin = (bool) ($data['canLogin'] ?? false);
213        $dto->username = $data['username'] ?? null;
214        $dto->lastLoginAt = $data['lastLoginAt'] ?? null;
215        $dto->mfaEnabled = (bool) ($data['mfaEnabled'] ?? false);
216
217        // Clock PIN
218        $dto->clockPin = $data['clockPin'] ?? null;
219        $dto->hasClockPin = !empty($dto->clockPin);
220
221        // Pending Invitation detection
222        $activationToken = $data['activationToken'] ?? null;
223        $expiresAt = $data['activationTokenExpiresAt'] ?? null;
224        $dto->hasPendingInvitation = self::computeHasPendingInvitation($activationToken, $expiresAt);
225        $dto->invitationExpiresAt = $dto->hasPendingInvitation ? $expiresAt : null;
226
227        // Store Assignment
228        $dto->assignedAt = $data['assignedAt'] ?? '';
229        $dto->deactivatedAt = $data['deactivatedAt'] ?? null;
230
231        // Status derivation
232        $userEnabled = (bool) ($data['enabled'] ?? true);
233        $assignmentActive = (bool) ($data['isActive'] ?? true);
234        $dto->isActive = $userEnabled && $assignmentActive;
235        $dto->status = self::computeStatus(
236            $dto->isActive,
237            $dto->leaveStartDate,
238            $dto->leaveEndDate
239        );
240
241        // Editable fields based on source
242        $dto->isEditable = ($dto->source === 'homegrown');
243        $dto->editableFields = self::computeEditableFields($dto->source, $dto->avatarOverride);
244
245        return $dto;
246    }
247
248    /**
249     * Create DTO from UnifiedUser and StoreAssignment domain models
250     *
251     * @param UnifiedUser $user The unified user model
252     * @param StoreAssignment $assignment The store assignment for this user
253     * @param array|null $roleConfig Optional role config map (roleId => ['name' => ..., 'color' => ...])
254     * @return self
255     */
256    public static function fromUnifiedUser(UnifiedUser $user, StoreAssignment $assignment, ?array $roleConfig = null): self
257    {
258        // Build array from domain models and delegate to fromArray
259        $data = [
260            // UnifiedUser fields
261            'id' => $user->getId(),
262            'username' => $user->getUsername(),
263            'email' => $user->getEmail(),
264            'displayName' => $user->getDisplayName(),
265            'firstName' => $user->getFirstName(),
266            'lastName' => $user->getLastName(),
267            'phone' => $user->getPhone(),
268            'photoUrl' => $user->getPhotoUrl(),
269            'avatarOverride' => $user->hasAvatarOverride(),
270            'position' => $user->getPosition(),
271            'hourlyRate' => $user->getHourlyRate(),
272            'hireDate' => $user->getHireDate(),
273            'terminationDate' => $user->getTerminationDate(),
274            'leaveStartDate' => $user->getLeaveStartDate(),
275            'leaveEndDate' => $user->getLeaveEndDate(),
276            'emergencyContactName' => $user->getEmergencyContactName(),
277            'emergencyContactPhone' => $user->getEmergencyContactPhone(),
278            'source' => $user->getSource(),
279            'externalId' => $user->getExternalId(),
280            'lastSyncedAt' => $user->getLastSyncedAt(),
281            'canLogin' => $user->canLogin(),
282            'enabled' => $user->isEnabled(),
283            'active' => $user->isActive(),
284            'activationToken' => $user->getActivationToken(),
285            'activationTokenExpiresAt' => $user->getActivationTokenExpiresAt(),
286            'mfaEnabled' => $user->hasMfaEnabled(),
287            'dailyReport' => $user->wantsDailyReport(),
288            'lastLoginAt' => $user->getLastLoginAt(),
289            'createdAt' => $user->getCreatedAt(),
290
291            // StoreAssignment fields
292            'clockPin' => $assignment->getClockPin(),
293            'drsEmployeeId' => $assignment->getDrsEmployeeId(),
294            'role' => $assignment->getRole(),
295            'isActive' => $assignment->isActive(),
296            'assignedAt' => $assignment->getAssignedAt(),
297            'deactivatedAt' => $assignment->getDeactivatedAt(),
298        ];
299
300        return self::fromArray($data, $roleConfig);
301    }
302
303    // =========================================================================
304    // Serialization
305    // =========================================================================
306
307    /**
308     * Convert DTO to array for JSON serialization
309     *
310     * Note: Sensitive data like clockPin is excluded from the output.
311     * Only hasClockPin boolean is exposed.
312     *
313     * @return array
314     */
315    public function toArray(): array
316    {
317        return [
318            // Core Identity
319            'id' => $this->id,
320            'firstName' => $this->firstName,
321            'lastName' => $this->lastName,
322            'displayName' => $this->displayName,
323            'email' => $this->email,
324            'phone' => $this->phone,
325            'photoUrl' => $this->photoUrl,
326            'avatarOverride' => $this->avatarOverride,
327
328            // Employment
329            'position' => $this->position,
330            'role' => $this->role,
331            'roleName' => $this->roleName,
332            'roleColor' => $this->roleColor,
333            'hireDate' => $this->hireDate,
334            'terminationDate' => $this->terminationDate,
335            'leaveStartDate' => $this->leaveStartDate,
336            'leaveEndDate' => $this->leaveEndDate,
337            'hourlyRate' => $this->hourlyRate,
338            'drsEmployeeId' => $this->drsEmployeeId,
339            'dailyEmailEnabled' => $this->dailyEmailEnabled,
340
341            // Emergency Contact
342            'emergencyContactName' => $this->emergencyContactName,
343            'emergencyContactPhone' => $this->emergencyContactPhone,
344
345            // Status
346            'status' => $this->status,
347            'isActive' => $this->isActive,
348
349            // Source & Sync
350            'source' => $this->source,
351            'externalId' => $this->externalId,
352            'lastSyncedAt' => $this->lastSyncedAt,
353
354            // Access
355            'canLogin' => $this->canLogin,
356            'username' => $this->username,
357            'lastLoginAt' => $this->lastLoginAt,
358            'mfaEnabled' => $this->mfaEnabled,
359            'hasClockPin' => $this->hasClockPin,
360            'hasPendingInvitation' => $this->hasPendingInvitation,
361            'invitationExpiresAt' => $this->invitationExpiresAt,
362
363            // Store Assignment
364            'assignedAt' => $this->assignedAt,
365            'deactivatedAt' => $this->deactivatedAt,
366
367            // Computed
368            'isEditable' => $this->isEditable,
369            'editableFields' => $this->editableFields,
370        ];
371    }
372
373    // =========================================================================
374    // Private Computation Methods
375    // =========================================================================
376
377    /**
378     * Compute display name from available name fields
379     *
380     * @param string|null $displayName Explicit display name
381     * @param string|null $firstName First name
382     * @param string|null $lastName Last name
383     * @return string
384     */
385    private static function computeDisplayName(
386        ?string $displayName,
387        ?string $firstName,
388        ?string $lastName
389    ): string {
390        if (!empty($displayName)) {
391            return $displayName;
392        }
393
394        return trim(($firstName ?? '') . ' ' . ($lastName ?? ''));
395    }
396
397    /**
398     * Compute status based on active flag and leave dates
399     *
400     * @param bool $isActive Whether the member is active
401     * @param string|null $leaveStartDate Leave start date
402     * @param string|null $leaveEndDate Leave end date
403     * @return string 'active', 'inactive', or 'on_leave'
404     */
405    private static function computeStatus(
406        bool $isActive,
407        ?string $leaveStartDate,
408        ?string $leaveEndDate
409    ): string {
410        if (!$isActive) {
411            return 'inactive';
412        }
413
414        // Check if currently on leave
415        if (self::isCurrentlyOnLeave($leaveStartDate, $leaveEndDate)) {
416            return 'on_leave';
417        }
418
419        return 'active';
420    }
421
422    /**
423     * Check if the member is currently on leave
424     *
425     * @param string|null $leaveStartDate Leave start date
426     * @param string|null $leaveEndDate Leave end date
427     * @return bool
428     */
429    private static function isCurrentlyOnLeave(?string $leaveStartDate, ?string $leaveEndDate): bool
430    {
431        if ($leaveStartDate === null) {
432            return false;
433        }
434
435        $now = time();
436        $start = strtotime($leaveStartDate);
437
438        // Leave hasn't started yet
439        if ($start > $now) {
440            return false;
441        }
442
443        // Indefinite leave (no end date)
444        if ($leaveEndDate === null) {
445            return true;
446        }
447
448        // Check if current time is within leave period
449        return strtotime($leaveEndDate) >= $now;
450    }
451
452    /**
453     * Check if there's a pending invitation
454     *
455     * @param string|null $activationToken The activation token
456     * @param string|null $expiresAt Expiration timestamp
457     * @return bool
458     */
459    private static function computeHasPendingInvitation(
460        ?string $activationToken,
461        ?string $expiresAt
462    ): bool {
463        if (empty($activationToken) || empty($expiresAt)) {
464            return false;
465        }
466
467        // Check if token hasn't expired
468        return strtotime($expiresAt) > time();
469    }
470
471    /**
472     * Compute which fields are editable based on source
473     *
474     * @param string $source Data source (homegrown, wheniwork, homebase)
475     * @param bool $avatarOverride Whether avatar override is enabled
476     * @return array List of editable field names
477     */
478    private static function computeEditableFields(string $source, bool $avatarOverride): array
479    {
480        // Homegrown source: all fields editable
481        if ($source === 'homegrown') {
482            return self::ALL_EDITABLE_FIELDS;
483        }
484
485        // External source: only local-only fields editable
486        $editableFields = self::LOCAL_ONLY_FIELDS;
487
488        // If avatar override is enabled, photo is also editable
489        if ($avatarOverride) {
490            $editableFields[] = 'photoUrl';
491        }
492
493        return $editableFields;
494    }
495}