Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
12 / 12
CRAP
100.00% covered (success)
100.00%
1 / 1
RemoteConfigService
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
12 / 12
24
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCurrent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentFresh
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fullUpdate
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 updateSection
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 deleteSection
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 rollback
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 getHistory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getForMetaData
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 generateVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveCurrentToHistory
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 buildSnapshot
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace App\Http\Services;
4
5use App\Http\Models\RemoteConfig;
6use App\Http\Repositories\interfaces\IRemoteConfigRepository;
7use Carbon\Carbon;
8use Illuminate\Contracts\Pagination\LengthAwarePaginator;
9use Illuminate\Support\Facades\Cache;
10
11/**
12 * Service for managing remote configuration.
13 *
14 * Handles business logic for CRUD operations on the remote config,
15 * including versioning, history snapshots, cache management, and
16 * formatting config for the consumer (meta-data) endpoint.
17 */
18class RemoteConfigService
19{
20    /**
21     * Cache TTL in seconds for the consumer endpoint.
22     */
23    private const CACHE_TTL_SECONDS = 60;
24
25    public function __construct(
26        private readonly IRemoteConfigRepository $repository,
27        private readonly CacheInvalidationService $cacheInvalidationService
28    ) {}
29
30    /**
31     * Get the current active config (cached for consumer endpoint).
32     */
33    public function getCurrent(): ?RemoteConfig
34    {
35        $cacheKey = $this->cacheInvalidationService->getRemoteConfigCacheKey();
36
37        return Cache::remember($cacheKey, self::CACHE_TTL_SECONDS, function () {
38            return $this->repository->getCurrent();
39        });
40    }
41
42    /**
43     * Get the current config without cache (for admin reads).
44     */
45    public function getCurrentFresh(): ?RemoteConfig
46    {
47        return $this->repository->getCurrent();
48    }
49
50    /**
51     * Full update of all sections.
52     *
53     * Saves current state to history, generates a new version,
54     * upserts all sections, and invalidates cache.
55     *
56     * @param  array<string, mixed>  $data  The section data
57     * @param  string  $changedBy  Who is making the change
58     * @param  string|null  $description  Description of the change
59     * @return RemoteConfig The updated config
60     */
61    public function fullUpdate(array $data, string $changedBy, ?string $description = null): RemoteConfig
62    {
63        $this->saveCurrentToHistory($changedBy, $description);
64
65        $version = $this->generateVersion();
66
67        $upsertData = ['version' => $version, 'updated_by' => $changedBy];
68        foreach (RemoteConfig::SECTIONS as $section) {
69            if (array_key_exists($section, $data)) {
70                $upsertData[$section] = $data[$section];
71            }
72        }
73
74        $config = $this->repository->upsert($upsertData);
75        $this->cacheInvalidationService->invalidateRemoteConfigCache();
76
77        return $config;
78    }
79
80    /**
81     * Update a single section.
82     *
83     * Saves current state to history, generates a new version,
84     * merges the section data with the current config, and invalidates cache.
85     *
86     * @param  string  $section  The section name (one of RemoteConfig::SECTIONS)
87     * @param  array<string, mixed>  $sectionData  The new section data
88     * @param  string  $changedBy  Who is making the change
89     * @param  string|null  $description  Description of the change
90     * @return RemoteConfig The updated config
91     */
92    public function updateSection(string $section, array $sectionData, string $changedBy, ?string $description = null): RemoteConfig
93    {
94        $this->saveCurrentToHistory($changedBy, $description);
95
96        $version = $this->generateVersion();
97
98        $current = $this->repository->getCurrent();
99        $upsertData = $current ? $current->toArray() : [];
100
101        // Remove non-section fields
102        unset($upsertData['_id'], $upsertData['created_at'], $upsertData['updated_at']);
103
104        $upsertData[$section] = $sectionData;
105        $upsertData['version'] = $version;
106        $upsertData['updated_by'] = $changedBy;
107
108        $config = $this->repository->upsert($upsertData);
109        $this->cacheInvalidationService->invalidateRemoteConfigCache();
110
111        return $config;
112    }
113
114    /**
115     * Delete (null out) a section.
116     *
117     * Saves current state to history, generates a new version,
118     * sets the section to null, and invalidates cache.
119     *
120     * @param  string  $section  The section name to delete
121     * @param  string  $changedBy  Who is making the change
122     * @param  string|null  $description  Description of the change
123     * @return RemoteConfig The updated config
124     */
125    public function deleteSection(string $section, string $changedBy, ?string $description = null): RemoteConfig
126    {
127        $this->saveCurrentToHistory($changedBy, $description);
128
129        $version = $this->generateVersion();
130
131        $current = $this->repository->getCurrent();
132        $upsertData = $current ? $current->toArray() : [];
133
134        unset($upsertData['_id'], $upsertData['created_at'], $upsertData['updated_at']);
135
136        $upsertData[$section] = null;
137        $upsertData['version'] = $version;
138        $upsertData['updated_by'] = $changedBy;
139
140        $config = $this->repository->upsert($upsertData);
141        $this->cacheInvalidationService->invalidateRemoteConfigCache();
142
143        return $config;
144    }
145
146    /**
147     * Rollback to a previous version from history.
148     *
149     * Finds the history entry, saves current state to history,
150     * generates a NEW version, upserts with the snapshot data,
151     * and invalidates cache.
152     *
153     * @param  string  $version  The version to rollback to
154     * @param  string  $changedBy  Who is making the rollback
155     * @param  string|null  $description  Description of the rollback
156     * @return RemoteConfig|null The restored config, or null if version not found
157     */
158    public function rollback(string $version, string $changedBy, ?string $description = null): ?RemoteConfig
159    {
160        $historyEntry = $this->repository->getHistoryByVersion($version);
161
162        if (! $historyEntry) {
163            return null;
164        }
165
166        $this->saveCurrentToHistory($changedBy, $description ?? "Rollback to version {$version}");
167
168        $newVersion = $this->generateVersion();
169        $snapshot = $historyEntry->snapshot;
170
171        $upsertData = ['version' => $newVersion, 'updated_by' => $changedBy];
172        foreach (RemoteConfig::SECTIONS as $section) {
173            $upsertData[$section] = $snapshot[$section] ?? null;
174        }
175
176        $config = $this->repository->upsert($upsertData);
177        $this->cacheInvalidationService->invalidateRemoteConfigCache();
178
179        return $config;
180    }
181
182    /**
183     * Get paginated config history.
184     *
185     * @param  int  $perPage  Items per page
186     */
187    public function getHistory(int $perPage = 20): LengthAwarePaginator
188    {
189        return $this->repository->getHistory($perPage);
190    }
191
192    /**
193     * Get config formatted for the meta-data endpoint.
194     *
195     * Returns only non-null sections with version, or null if no config
196     * exists or all sections are null.
197     *
198     * @return array<string, mixed>|null
199     */
200    public function getForMetaData(): ?array
201    {
202        $config = $this->getCurrent();
203
204        if (! $config || ! $config->hasAnySection()) {
205            return null;
206        }
207
208        $result = ['version' => $config->version];
209
210        foreach (RemoteConfig::SECTIONS as $section) {
211            if ($config->{$section} !== null) {
212                $result[$section] = $config->{$section};
213            }
214        }
215
216        return $result;
217    }
218
219    /**
220     * Generate a new version string using current timestamp.
221     *
222     * @return string ISO-8601 formatted timestamp
223     */
224    private function generateVersion(): string
225    {
226        return Carbon::now()->toIso8601String();
227    }
228
229    /**
230     * Save the current config state to history before making changes.
231     *
232     * @param  string  $changedBy  Who is making the change
233     * @param  string|null  $description  Description of the change
234     */
235    private function saveCurrentToHistory(string $changedBy, ?string $description): void
236    {
237        $current = $this->repository->getCurrent();
238
239        if (! $current) {
240            return;
241        }
242
243        $snapshot = $this->buildSnapshot($current);
244
245        $this->repository->createHistoryEntry($snapshot, $changedBy, $description);
246    }
247
248    /**
249     * Build a snapshot array from a RemoteConfig model.
250     *
251     * @param  RemoteConfig  $config  The config to snapshot
252     * @return array<string, mixed>
253     */
254    private function buildSnapshot(RemoteConfig $config): array
255    {
256        $snapshot = ['version' => $config->version];
257
258        foreach (RemoteConfig::SECTIONS as $section) {
259            $snapshot[$section] = $config->{$section};
260        }
261
262        return $snapshot;
263    }
264}