Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.44% covered (warning)
82.44%
108 / 131
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserFieldInjectionService
82.44% covered (warning)
82.44%
108 / 131
81.82% covered (warning)
81.82%
9 / 11
50.10
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
 getUserFieldInjection
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getUserFieldInjectionFresh
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 upsertDomain
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 removeDomain
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 addRemovedEntry
41.67% covered (danger)
41.67%
15 / 36
0.00% covered (danger)
0.00%
0 / 1
40.58
 restoreRemovedEntry
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 deleteAll
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 mergeWithGlobalConfig
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
4.05
 deepMergeFieldInjection
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
10
 invalidateCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Http\Services;
4
5use App\Http\Models\UserFieldInjection;
6use App\Http\Repositories\interfaces\IUserFieldInjectionRepository;
7use Illuminate\Support\Facades\Cache;
8
9/**
10 * Service for managing per-user field injection customizations.
11 *
12 * Handles CRUD operations on user-specific field injection rules and provides
13 * the deep merge algorithm that combines global config with user overrides.
14 */
15class UserFieldInjectionService
16{
17    /**
18     * Cache TTL in seconds.
19     */
20    private const CACHE_TTL_SECONDS = 60;
21
22    /**
23     * Cache key prefix.
24     */
25    private const CACHE_KEY_PREFIX = 'user_field_injection_';
26
27    public function __construct(
28        private readonly IUserFieldInjectionRepository $repository
29    ) {}
30
31    /**
32     * Get a user's field injection config (cached).
33     *
34     * @param  string  $userId  The user ID
35     * @return UserFieldInjection|null The user's config or null
36     */
37    public function getUserFieldInjection(string $userId): ?UserFieldInjection
38    {
39        $cacheKey = self::CACHE_KEY_PREFIX.$userId;
40
41        return Cache::remember($cacheKey, self::CACHE_TTL_SECONDS, function () use ($userId) {
42            return $this->repository->findByUserId($userId);
43        });
44    }
45
46    /**
47     * Get a user's field injection config without cache.
48     *
49     * @param  string  $userId  The user ID
50     * @return UserFieldInjection|null The user's config or null
51     */
52    public function getUserFieldInjectionFresh(string $userId): ?UserFieldInjection
53    {
54        return $this->repository->findByUserId($userId);
55    }
56
57    /**
58     * Upsert a domain entry in the user's field_injection array.
59     *
60     * If the domain already exists in the user's config, its fields are replaced.
61     * If not, the domain entry is appended.
62     *
63     * @param  string  $userId  The user ID
64     * @param  string  $domain  The domain name
65     * @param  array<array{selector: string, is_inside_shadow_dom?: bool, inside_of?: string, not_inside_of?: string, has_parent_selector?: string, icon_offset_bottom?: int}>  $fields  The field configs
66     * @return UserFieldInjection The updated config
67     */
68    public function upsertDomain(string $userId, string $domain, array $fields): UserFieldInjection
69    {
70        $config = $this->repository->findByUserId($userId);
71        $fieldInjection = $config ? ($config->field_injection ?? []) : [];
72
73        $found = false;
74        foreach ($fieldInjection as $index => $entry) {
75            if (($entry['domain'] ?? '') === $domain) {
76                $fieldInjection[$index]['fields'] = $fields;
77                $found = true;
78                break;
79            }
80        }
81
82        if (! $found) {
83            $fieldInjection[] = ['domain' => $domain, 'fields' => $fields];
84        }
85
86        $result = $this->repository->upsert($userId, [
87            'field_injection' => $fieldInjection,
88        ]);
89
90        $this->invalidateCache($userId);
91
92        return $result;
93    }
94
95    /**
96     * Remove a domain entry from the user's field_injection array.
97     *
98     * @param  string  $userId  The user ID
99     * @param  string  $domain  The domain to remove
100     * @return UserFieldInjection|null The updated config, or null if user has no config
101     */
102    public function removeDomain(string $userId, string $domain): ?UserFieldInjection
103    {
104        $config = $this->repository->findByUserId($userId);
105
106        if (! $config) {
107            return null;
108        }
109
110        $fieldInjection = $config->field_injection ?? [];
111        $fieldInjection = array_values(array_filter($fieldInjection, function ($entry) use ($domain) {
112            return ($entry['domain'] ?? '') !== $domain;
113        }));
114
115        $result = $this->repository->upsert($userId, [
116            'field_injection' => $fieldInjection,
117        ]);
118
119        $this->invalidateCache($userId);
120
121        return $result;
122    }
123
124    /**
125     * Remove a {domain, selector} entry from the user's config.
126     *
127     * If the selector exists in the user's own field_injection overrides, it is removed
128     * from there (leaving other selectors in the domain intact, and dropping the domain
129     * only when it becomes empty). Otherwise the pair is appended to removed_entries so
130     * that the matching global entry is excluded for this user.
131     *
132     * @param  string  $userId  The user ID
133     * @param  string  $domain  The domain name
134     * @param  string  $selector  The CSS selector to remove
135     * @return UserFieldInjection The updated config
136     */
137    public function addRemovedEntry(string $userId, string $domain, string $selector): UserFieldInjection
138    {
139        $config = $this->repository->findByUserId($userId);
140        $fieldInjection = $config ? ($config->field_injection ?? []) : [];
141
142        // Check if the selector exists in the user's own field_injection overrides.
143        $domainIndex = null;
144        foreach ($fieldInjection as $i => $entry) {
145            if (($entry['domain'] ?? '') === $domain) {
146                $domainIndex = $i;
147                break;
148            }
149        }
150
151        if ($domainIndex !== null) {
152            $fields = $fieldInjection[$domainIndex]['fields'] ?? [];
153            $selectorFound = false;
154
155            $fields = array_values(array_filter($fields, function ($field) use ($selector, &$selectorFound) {
156                if (($field['selector'] ?? '') === $selector) {
157                    $selectorFound = true;
158
159                    return false;
160                }
161
162                return true;
163            }));
164
165            if ($selectorFound) {
166                if (empty($fields)) {
167                    // Domain has no remaining selectors â€” remove the whole domain entry.
168                    unset($fieldInjection[$domainIndex]);
169                    $fieldInjection = array_values($fieldInjection);
170                } else {
171                    $fieldInjection[$domainIndex]['fields'] = $fields;
172                }
173
174                $result = $this->repository->upsert($userId, [
175                    'field_injection' => $fieldInjection,
176                ]);
177
178                $this->invalidateCache($userId);
179
180                return $result;
181            }
182        }
183
184        // Selector not found in user overrides â€” add to removed_entries to exclude the global entry.
185        $removedEntries = $config ? ($config->removed_entries ?? []) : [];
186
187        // Avoid duplicates.
188        foreach ($removedEntries as $entry) {
189            if (($entry['domain'] ?? '') === $domain && ($entry['selector'] ?? '') === $selector) {
190                return $config;
191            }
192        }
193
194        $removedEntries[] = ['domain' => $domain, 'selector' => $selector];
195
196        $result = $this->repository->upsert($userId, [
197            'removed_entries' => $removedEntries,
198        ]);
199
200        $this->invalidateCache($userId);
201
202        return $result;
203    }
204
205    /**
206     * Restore a previously removed global entry (remove from removed_entries).
207     *
208     * @param  string  $userId  The user ID
209     * @param  string  $domain  The domain name
210     * @param  string  $selector  The CSS selector to restore
211     * @return UserFieldInjection|null The updated config, or null if user has no config
212     */
213    public function restoreRemovedEntry(string $userId, string $domain, string $selector): ?UserFieldInjection
214    {
215        $config = $this->repository->findByUserId($userId);
216
217        if (! $config) {
218            return null;
219        }
220
221        $removedEntries = $config->removed_entries ?? [];
222        $removedEntries = array_values(array_filter($removedEntries, function ($entry) use ($domain, $selector) {
223            return ! (($entry['domain'] ?? '') === $domain && ($entry['selector'] ?? '') === $selector);
224        }));
225
226        $result = $this->repository->upsert($userId, [
227            'removed_entries' => $removedEntries,
228        ]);
229
230        $this->invalidateCache($userId);
231
232        return $result;
233    }
234
235    /**
236     * Delete all user customizations (reset to defaults).
237     *
238     * @param  string  $userId  The user ID
239     * @return bool True if deleted, false if not found
240     */
241    public function deleteAll(string $userId): bool
242    {
243        $deleted = $this->repository->deleteByUserId($userId);
244        $this->invalidateCache($userId);
245
246        return $deleted;
247    }
248
249    /**
250     * Merge user's field injection overrides with global remote config.
251     *
252     * Deep merge algorithm (by selector):
253     * 1. Start with global field_injection as base
254     * 2. Remove entries listed in user's removed_entries
255     * 3. Merge user's field_injection (override by selector, append new selectors/domains)
256     * 4. Return the merged remote config array
257     *
258     * @param  array<string, mixed>  $remoteConfig  The global remote config array
259     * @param  string  $userId  The user ID
260     * @return array<string, mixed> The merged remote config array
261     */
262    public function mergeWithGlobalConfig(array $remoteConfig, string $userId): array
263    {
264        $userConfig = $this->getUserFieldInjection($userId);
265
266        if (! $userConfig) {
267            return $remoteConfig;
268        }
269
270        $globalFieldInjection = $remoteConfig['field_injection'] ?? [];
271
272        $merged = $this->deepMergeFieldInjection(
273            $globalFieldInjection,
274            $userConfig->field_injection ?? [],
275            $userConfig->removed_entries ?? []
276        );
277
278        if (! empty($merged)) {
279            $remoteConfig['field_injection'] = $merged;
280        } elseif (isset($remoteConfig['field_injection'])) {
281            unset($remoteConfig['field_injection']);
282        }
283
284        return $remoteConfig;
285    }
286
287    /**
288     * Perform the deep merge of field injection arrays.
289     *
290     * @param  array  $global  Global field_injection entries
291     * @param  array  $userAdditions  User's custom domain entries
292     * @param  array  $removedEntries  Entries the user wants excluded
293     * @return array The merged field_injection array
294     */
295    public function deepMergeFieldInjection(array $global, array $userAdditions, array $removedEntries): array
296    {
297        // Step 1: Index global by domain for efficient lookups
298        $base = [];
299        foreach ($global as $entry) {
300            $domain = $entry['domain'] ?? '';
301            $base[$domain] = $entry['fields'] ?? [];
302        }
303
304        // Step 2: Apply removals
305        foreach ($removedEntries as $removal) {
306            $domain = $removal['domain'] ?? '';
307            $selector = $removal['selector'] ?? '';
308
309            if (! isset($base[$domain])) {
310                continue;
311            }
312
313            $base[$domain] = array_values(array_filter($base[$domain], function ($field) use ($selector) {
314                return ($field['selector'] ?? '') !== $selector;
315            }));
316
317            // Remove domain if no fields left
318            if (empty($base[$domain])) {
319                unset($base[$domain]);
320            }
321        }
322
323        // Step 3: Merge user additions/overrides
324        foreach ($userAdditions as $entry) {
325            $domain = $entry['domain'] ?? '';
326            $userFields = $entry['fields'] ?? [];
327
328            if (! isset($base[$domain])) {
329                // New domain - append entirely
330                $base[$domain] = $userFields;
331
332                continue;
333            }
334
335            // Existing domain - merge fields by selector
336            $existingFields = $base[$domain];
337            $existingBySelector = [];
338            foreach ($existingFields as $field) {
339                $existingBySelector[$field['selector'] ?? ''] = $field;
340            }
341
342            foreach ($userFields as $userField) {
343                $selector = $userField['selector'] ?? '';
344                // Override existing or add new
345                $existingBySelector[$selector] = $userField;
346            }
347
348            $base[$domain] = array_values($existingBySelector);
349        }
350
351        // Step 4: Convert back to array format
352        $result = [];
353        foreach ($base as $domain => $fields) {
354            $result[] = ['domain' => $domain, 'fields' => $fields];
355        }
356
357        return $result;
358    }
359
360    /**
361     * Invalidate the user's field injection cache.
362     *
363     * @param  string  $userId  The user ID
364     */
365    private function invalidateCache(string $userId): void
366    {
367        Cache::forget(self::CACHE_KEY_PREFIX.$userId);
368    }
369}