Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.31% covered (success)
92.31%
60 / 65
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
VapiConfigService
92.31% covered (success)
92.31%
60 / 65
91.67% covered (success)
91.67%
11 / 12
23.24
0.00% covered (danger)
0.00%
0 / 1
 getMergedConfig
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 getGlobalDefaults
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
9.95
 updateGlobalDefaults
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getCompanyOverrides
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 updateCompanyOverrides
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 deleteCompanyOverrides
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUserOverrides
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 updateUserOverrides
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 deleteUserOverrides
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 previewMergedConfig
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 invalidateAllVapiConfigCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 invalidateCompanyUsersCache
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\CompanyVapiConfig;
6use App\Http\Models\RolePlayConfig;
7use App\Http\Models\UserVapiConfig;
8use Illuminate\Support\Facades\Cache;
9
10/**
11 * Service for managing VAPI configuration with 3-level hierarchy.
12 *
13 * Resolves configuration by deep-merging:
14 * Global defaults → Company overrides → User overrides
15 *
16 * Uses the existing RolePlayConfig.vapi_defaults as the global store,
17 * with CompanyVapiConfig and UserVapiConfig for override layers.
18 */
19class VapiConfigService
20{
21    /**
22     * Cache TTL in seconds for merged config.
23     */
24    private const CACHE_TTL_SECONDS = 60;
25
26    /**
27     * Default VAPI configuration used as fallback when no global config exists.
28     *
29     * @var array<string, mixed>
30     */
31    private const FALLBACK_DEFAULTS = [
32        'transcriber' => [
33            'provider' => 'deepgram',
34            'model' => 'nova-2',
35            'language' => 'en-US',
36        ],
37        'model' => [
38            'available_models' => [
39                ['value' => 'gemini', 'label' => 'Gemini', 'provider' => 'google', 'model_id' => 'gemini-3-flash-preview', 'enabled' => true],
40                ['value' => 'openai5', 'label' => 'GPT 5', 'provider' => 'openai', 'model_id' => 'gpt-5', 'enabled' => true],
41                ['value' => 'openai5nano', 'label' => 'GPT 5 Nano', 'provider' => 'openai', 'model_id' => 'gpt-5-nano', 'enabled' => true],
42                ['value' => 'openai', 'label' => 'OpenAI 4o', 'provider' => 'openai', 'model_id' => 'gpt-4o', 'enabled' => true],
43            ],
44            'default_model' => 'gemini-3-flash-preview',
45            'temperature' => 1,
46        ],
47        'voice' => [
48            'provider' => 'vapi',
49        ],
50        'assistant' => [
51            'name' => 'FlyMSG Assistant',
52            'silence_timeout_seconds' => 10,
53            'response_delay_seconds' => 2,
54            'start_speaking_wait_seconds' => 0.7,
55            'end_call_message' => 'Goodbye.',
56            'recording_format' => 'mp3',
57        ],
58        'first_messages' => [
59            'Hello, this is {name}.',
60            'Hello?',
61            'Hi, this is {name}.',
62            'Good day, this is {name}.',
63            'Yes, no problem. Hello, this is {name}.',
64            'Hello, what can I do for you?',
65            '{name} here, how can I help you?',
66        ],
67    ];
68
69    /**
70     * Get the merged VAPI config for a user (global → company → user).
71     *
72     * @param string $userId
73     * @param string|null $companyId
74     * @return array<string, mixed>
75     */
76    public function getMergedConfig(string $userId, ?string $companyId): array
77    {
78        $cacheKey = "vapi_config_{$userId}";
79
80        $tags = ['vapi_config'];
81        if ($companyId) {
82            $tags[] = "vapi_company_{$companyId}";
83        }
84
85        return Cache::tags($tags)->remember($cacheKey, self::CACHE_TTL_SECONDS, function () use ($userId, $companyId) {
86            $globalDefaults = $this->getGlobalDefaults();
87
88            $companyOverrides = [];
89            if ($companyId) {
90                $companyOverrides = $this->getCompanyOverrides($companyId);
91            }
92
93            $userOverrides = $this->getUserOverrides($userId);
94
95            return array_replace_recursive($globalDefaults, $companyOverrides, $userOverrides);
96        });
97    }
98
99    /**
100     * Get the global VAPI defaults from RolePlayConfig.
101     *
102     * @return array<string, mixed>
103     */
104    public function getGlobalDefaults(): array
105    {
106        $config = RolePlayConfig::getGlobal();
107        $stored = $config?->vapi_defaults;
108
109        if (! $stored) {
110            return self::FALLBACK_DEFAULTS;
111        }
112
113        // Ensure stored is an array (could be a string if corrupted in DB)
114        if (! is_array($stored)) {
115            return self::FALLBACK_DEFAULTS;
116        }
117
118        // Deep-merge with fallback defaults to fill any missing fields
119        $merged = array_replace_recursive(self::FALLBACK_DEFAULTS, $stored);
120
121        // Guard against 'model' being overwritten by a scalar (e.g. from transcriber.model leaking)
122        if (! is_array($merged['model'] ?? null)) {
123            $merged['model'] = self::FALLBACK_DEFAULTS['model'];
124        }
125
126        // Special handling: if available_models is empty, use fallback
127        if (empty($merged['model']['available_models'])) {
128            $merged['model']['available_models'] = self::FALLBACK_DEFAULTS['model']['available_models'];
129        }
130
131        // Default temperature when missing on legacy stored config.
132        if (! isset($merged['model']['temperature']) || ! is_numeric($merged['model']['temperature'])) {
133            $merged['model']['temperature'] = self::FALLBACK_DEFAULTS['model']['temperature'];
134        }
135
136        // Special handling: if first_messages is empty, use fallback
137        if (empty($merged['first_messages'])) {
138            $merged['first_messages'] = self::FALLBACK_DEFAULTS['first_messages'];
139        }
140
141        return $merged;
142    }
143
144    /**
145     * Update the global VAPI defaults in RolePlayConfig.
146     *
147     * @param array<string, mixed> $data
148     * @return array<string, mixed>
149     */
150    public function updateGlobalDefaults(array $data): array
151    {
152        $config = RolePlayConfig::getGlobal();
153
154        if (!$config) {
155            $config = RolePlayConfig::create([
156                'type' => 'global',
157                'vapi_defaults' => $data,
158            ]);
159        } else {
160            $config->updateSection('vapi_defaults', $data);
161        }
162
163        $this->invalidateAllVapiConfigCache();
164
165        return $config->fresh()->vapi_defaults;
166    }
167
168    /**
169     * Get company-level VAPI overrides.
170     *
171     * @param string $companyId
172     * @return array<string, mixed>
173     */
174    public function getCompanyOverrides(string $companyId): array
175    {
176        $config = CompanyVapiConfig::forCompany($companyId);
177
178        return $config?->vapi_overrides ?? [];
179    }
180
181    /**
182     * Update company-level VAPI overrides (upsert).
183     *
184     * @param string $companyId
185     * @param array<string, mixed> $data
186     * @return array<string, mixed>
187     */
188    public function updateCompanyOverrides(string $companyId, array $data): array
189    {
190        $config = CompanyVapiConfig::updateOrCreate(
191            ['company_id' => $companyId],
192            ['vapi_overrides' => $data]
193        );
194
195        $this->invalidateCompanyUsersCache($companyId);
196
197        return $config->fresh()->vapi_overrides;
198    }
199
200    /**
201     * Delete company-level VAPI overrides.
202     *
203     * @param string $companyId
204     * @return void
205     */
206    public function deleteCompanyOverrides(string $companyId): void
207    {
208        CompanyVapiConfig::where('company_id', $companyId)->delete();
209        $this->invalidateCompanyUsersCache($companyId);
210    }
211
212    /**
213     * Get user-level VAPI overrides.
214     *
215     * @param string $userId
216     * @return array<string, mixed>
217     */
218    public function getUserOverrides(string $userId): array
219    {
220        $config = UserVapiConfig::forUser($userId);
221
222        return $config?->vapi_overrides ?? [];
223    }
224
225    /**
226     * Update user-level VAPI overrides (upsert).
227     *
228     * @param string $userId
229     * @param array<string, mixed> $data
230     * @return array<string, mixed>
231     */
232    public function updateUserOverrides(string $userId, array $data): array
233    {
234        $config = UserVapiConfig::updateOrCreate(
235            ['user_id' => $userId],
236            ['vapi_overrides' => $data]
237        );
238
239        Cache::tags(['vapi_config'])->forget("vapi_config_{$userId}");
240
241        return $config->fresh()->vapi_overrides;
242    }
243
244    /**
245     * Delete user-level VAPI overrides.
246     *
247     * @param string $userId
248     * @return void
249     */
250    public function deleteUserOverrides(string $userId): void
251    {
252        UserVapiConfig::where('user_id', $userId)->delete();
253        Cache::tags(['vapi_config'])->forget("vapi_config_{$userId}");
254    }
255
256    /**
257     * Preview the merged config for a specific user (admin use).
258     *
259     * Bypasses cache to show the current resolved state.
260     *
261     * @param string $userId
262     * @param string|null $companyId
263     * @return array<string, mixed>
264     */
265    public function previewMergedConfig(string $userId, ?string $companyId): array
266    {
267        $globalDefaults = $this->getGlobalDefaults();
268
269        $companyOverrides = [];
270        if ($companyId) {
271            $companyOverrides = $this->getCompanyOverrides($companyId);
272        }
273
274        $userOverrides = $this->getUserOverrides($userId);
275
276        return array_replace_recursive($globalDefaults, $companyOverrides, $userOverrides);
277    }
278
279    /**
280     * Invalidate all VAPI config caches.
281     *
282     * Called when global defaults change, since all users are affected.
283     *
284     * @return void
285     */
286    private function invalidateAllVapiConfigCache(): void
287    {
288        Cache::tags(['vapi_config'])->flush();
289    }
290
291    /**
292     * Invalidate cached VAPI configs for all users in a company.
293     *
294     * @param string $companyId
295     * @return void
296     */
297    private function invalidateCompanyUsersCache(string $companyId): void
298    {
299        Cache::tags(["vapi_company_{$companyId}"])->flush();
300    }
301}