Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 105
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
RoleConfigService
0.00% covered (danger)
0.00%
0 / 105
0.00% covered (danger)
0.00%
0 / 16
870
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getRoles
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getRole
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getRoleName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getRoleColor
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultRoles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultsAsMap
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 hasCustomRoles
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 seedDefaultRoles
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 saveRole
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 deactivateRole
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 toArray
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 toMap
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 clearCache
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 clearAllCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadRolesFromDatabase
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace BuyerKiosk\TeamMember\Services;
4
5use PDO;
6
7/**
8 * RoleConfigService - Store-specific role configuration
9 *
10 * Provides centralized access to role definitions (name, color, etc.) that can
11 * be customized per store. Falls back to default roles if a store hasn't
12 * configured custom roles.
13 *
14 * Key features:
15 * - Per-store role customization (name, color, sort order)
16 * - Default role fallback for unconfigured stores
17 * - Caching for performance
18 * - Seed function to initialize store with defaults
19 *
20 * Usage:
21 *   $service = new RoleConfigService($db, 'ou00');
22 *   $roles = $service->getRoles();           // All roles for store
23 *   $role = $service->getRole(4);            // Get 'Buyer' role
24 *   $service->seedDefaultRoles();            // Initialize store with defaults
25 *
26 * @package BuyerKiosk\TeamMember\Services
27 */
28class RoleConfigService
29{
30    /**
31     * Default role definitions with BuyerKiosk design system colors
32     * Colors are softer/refined to match the admin theme palette
33     *
34     * Color palette (from tokens.css):
35     * - Employee: neutral-500 (#64748b) - Default, muted
36     * - Owner: neutral-800 (#1e293b) - Dark, authoritative
37     * - Manager: rose-600 (#e11d48) - Important, leadership
38     * - Shift Lead: primary-600 (#7c3aed) - Brand purple
39     * - Buyer: emerald-500 (#10b981) - Growth, positive
40     * - Cashier: sky-500 (#0ea5e9) - Calm, trustworthy
41     */
42    public const DEFAULT_ROLES = [
43        0 => ['name' => 'Employee', 'color' => '#64748b', 'sortOrder' => 99],
44        1 => ['name' => 'Owner', 'color' => '#1e293b', 'sortOrder' => 0],
45        2 => ['name' => 'Manager', 'color' => '#e11d48', 'sortOrder' => 1],
46        3 => ['name' => 'Shift Lead', 'color' => '#7c3aed', 'sortOrder' => 2],
47        4 => ['name' => 'Buyer', 'color' => '#10b981', 'sortOrder' => 3],
48        5 => ['name' => 'Cashier', 'color' => '#0ea5e9', 'sortOrder' => 4],
49    ];
50
51    /**
52     * In-memory cache of roles by store
53     * @var array<string, array>
54     */
55    private static array $cache = [];
56
57    /**
58     * @var PDO Database connection (kiosk_buykiosk central DB)
59     */
60    private PDO $db;
61
62    /**
63     * @var string Store identifier (e.g., 'ou00')
64     */
65    private string $typeNum;
66
67    /**
68     * Constructor
69     *
70     * @param PDO $db Database connection to central database (kiosk_buykiosk)
71     * @param string $typeNum Store identifier
72     */
73    public function __construct(PDO $db, string $typeNum)
74    {
75        $this->db = $db;
76        $this->typeNum = $typeNum;
77    }
78
79    /**
80     * Get all roles for the store
81     *
82     * Returns store-specific roles if configured, otherwise returns defaults.
83     * Results are cached for the duration of the request.
84     *
85     * @param bool $activeOnly Only return active roles (default: true)
86     * @return array<int, array> Roles indexed by roleId
87     */
88    public function getRoles(bool $activeOnly = true): array
89    {
90        $cacheKey = $this->typeNum . ($activeOnly ? '_active' : '_all');
91
92        if (isset(self::$cache[$cacheKey])) {
93            return self::$cache[$cacheKey];
94        }
95
96        $roles = $this->loadRolesFromDatabase($activeOnly);
97
98        // If no custom roles, use defaults
99        if (empty($roles)) {
100            $roles = self::DEFAULT_ROLES;
101        }
102
103        // Sort by sortOrder
104        uasort($roles, fn($a, $b) => ($a['sortOrder'] ?? 99) <=> ($b['sortOrder'] ?? 99));
105
106        self::$cache[$cacheKey] = $roles;
107        return $roles;
108    }
109
110    /**
111     * Get a single role by ID
112     *
113     * @param int $roleId Role identifier
114     * @return array|null Role data or null if not found
115     */
116    public function getRole(int $roleId): ?array
117    {
118        $roles = $this->getRoles();
119        return $roles[$roleId] ?? null;
120    }
121
122    /**
123     * Get role name by ID
124     *
125     * @param int $roleId Role identifier
126     * @return string Role name or 'Unknown' if not found
127     */
128    public function getRoleName(int $roleId): string
129    {
130        $role = $this->getRole($roleId);
131        return $role['name'] ?? 'Unknown';
132    }
133
134    /**
135     * Get role color by ID
136     *
137     * @param int $roleId Role identifier
138     * @return string Hex color code
139     */
140    public function getRoleColor(int $roleId): string
141    {
142        $role = $this->getRole($roleId);
143        return $role['color'] ?? '#6c757d';
144    }
145
146    /**
147     * Get default roles (static, no database)
148     *
149     * @return array<int, array>
150     */
151    public static function getDefaultRoles(): array
152    {
153        return self::DEFAULT_ROLES;
154    }
155
156    /**
157     * Get default roles as a map for JavaScript (static, no database)
158     *
159     * @return array<int, array{name: string, color: string}>
160     */
161    public static function getDefaultsAsMap(): array
162    {
163        $result = [];
164        foreach (self::DEFAULT_ROLES as $roleId => $role) {
165            $result[$roleId] = [
166                'name' => $role['name'],
167                'color' => $role['color'],
168            ];
169        }
170        return $result;
171    }
172
173    /**
174     * Check if store has custom role configuration
175     *
176     * @return bool
177     */
178    public function hasCustomRoles(): bool
179    {
180        $sql = "SELECT COUNT(*) FROM storeRoles WHERE typeNum = :typeNum AND isActive = 1";
181        $stmt = $this->db->prepare($sql);
182        $stmt->execute([':typeNum' => $this->typeNum]);
183        return (int) $stmt->fetchColumn() > 0;
184    }
185
186    /**
187     * Seed the store with default roles
188     *
189     * Creates default role entries in the database for this store.
190     * Will not overwrite existing roles.
191     *
192     * @return int Number of roles created
193     */
194    public function seedDefaultRoles(): int
195    {
196        $created = 0;
197        $sql = "INSERT INTO storeRoles (typeNum, roleId, name, color, sortOrder, isActive)
198                VALUES (:typeNum, :roleId, :name, :color, :sortOrder, 1)
199                ON DUPLICATE KEY UPDATE typeNum = typeNum"; // No-op on duplicate
200
201        $stmt = $this->db->prepare($sql);
202
203        foreach (self::DEFAULT_ROLES as $roleId => $role) {
204            $stmt->execute([
205                ':typeNum' => $this->typeNum,
206                ':roleId' => $roleId,
207                ':name' => $role['name'],
208                ':color' => $role['color'],
209                ':sortOrder' => $role['sortOrder'],
210            ]);
211
212            if ($stmt->rowCount() > 0) {
213                $created++;
214            }
215        }
216
217        // Clear cache
218        $this->clearCache();
219
220        return $created;
221    }
222
223    /**
224     * Save a role configuration
225     *
226     * Creates or updates a role for this store.
227     *
228     * @param int $roleId Role identifier (0-255)
229     * @param string $name Display name
230     * @param string $color Hex color code
231     * @param int $sortOrder Display order
232     * @return bool Success
233     */
234    public function saveRole(int $roleId, string $name, string $color, int $sortOrder = 0): bool
235    {
236        // Validate color format
237        if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
238            throw new \InvalidArgumentException('Color must be a valid hex color (e.g., #3b82f6)');
239        }
240
241        $sql = "INSERT INTO storeRoles (typeNum, roleId, name, color, sortOrder, isActive)
242                VALUES (:typeNum, :roleId, :name, :color, :sortOrder, 1)
243                ON DUPLICATE KEY UPDATE
244                    name = VALUES(name),
245                    color = VALUES(color),
246                    sortOrder = VALUES(sortOrder),
247                    isActive = 1,
248                    updatedAt = CURRENT_TIMESTAMP";
249
250        $stmt = $this->db->prepare($sql);
251        $result = $stmt->execute([
252            ':typeNum' => $this->typeNum,
253            ':roleId' => $roleId,
254            ':name' => $name,
255            ':color' => $color,
256            ':sortOrder' => $sortOrder,
257        ]);
258
259        if ($result) {
260            $this->clearCache();
261        }
262
263        return $result;
264    }
265
266    /**
267     * Deactivate a role (soft delete)
268     *
269     * @param int $roleId Role identifier
270     * @return bool Success
271     */
272    public function deactivateRole(int $roleId): bool
273    {
274        $sql = "UPDATE storeRoles SET isActive = 0 WHERE typeNum = :typeNum AND roleId = :roleId";
275        $stmt = $this->db->prepare($sql);
276        $result = $stmt->execute([
277            ':typeNum' => $this->typeNum,
278            ':roleId' => $roleId,
279        ]);
280
281        if ($result) {
282            $this->clearCache();
283        }
284
285        return $result;
286    }
287
288    /**
289     * Get roles formatted for JSON API response
290     *
291     * @return array Array of role objects
292     */
293    public function toArray(): array
294    {
295        $roles = $this->getRoles();
296        $result = [];
297
298        foreach ($roles as $roleId => $role) {
299            $result[] = [
300                'id' => $roleId,
301                'name' => $role['name'],
302                'color' => $role['color'],
303                'sortOrder' => $role['sortOrder'] ?? 99,
304            ];
305        }
306
307        return $result;
308    }
309
310    /**
311     * Get roles as a map (for JavaScript)
312     *
313     * @return array Role map indexed by ID
314     */
315    public function toMap(): array
316    {
317        $roles = $this->getRoles();
318        $result = [];
319
320        foreach ($roles as $roleId => $role) {
321            $result[$roleId] = [
322                'name' => $role['name'],
323                'color' => $role['color'],
324            ];
325        }
326
327        return $result;
328    }
329
330    /**
331     * Clear the in-memory cache
332     */
333    public function clearCache(): void
334    {
335        unset(self::$cache[$this->typeNum . '_active']);
336        unset(self::$cache[$this->typeNum . '_all']);
337    }
338
339    /**
340     * Clear entire cache (all stores)
341     */
342    public static function clearAllCache(): void
343    {
344        self::$cache = [];
345    }
346
347    /**
348     * Load roles from database
349     *
350     * @param bool $activeOnly Only load active roles
351     * @return array<int, array> Roles indexed by roleId
352     */
353    private function loadRolesFromDatabase(bool $activeOnly): array
354    {
355        $sql = "SELECT roleId, name, color, sortOrder
356                FROM storeRoles
357                WHERE typeNum = :typeNum";
358
359        if ($activeOnly) {
360            $sql .= " AND isActive = 1";
361        }
362
363        $sql .= " ORDER BY sortOrder ASC, roleId ASC";
364
365        $stmt = $this->db->prepare($sql);
366        $stmt->execute([':typeNum' => $this->typeNum]);
367
368        $roles = [];
369        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
370            $roles[(int) $row['roleId']] = [
371                'name' => $row['name'],
372                'color' => $row['color'],
373                'sortOrder' => (int) $row['sortOrder'],
374            ];
375        }
376
377        return $roles;
378    }
379}