Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
VapiConfigService
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 12
272
0.00% covered (danger)
0.00%
0 / 1
 getMergedConfig
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getGlobalDefaults
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 updateGlobalDefaults
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getCompanyOverrides
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 updateCompanyOverrides
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 deleteCompanyOverrides
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getUserOverrides
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 updateUserOverrides
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 deleteUserOverrides
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 previewMergedConfig
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 invalidateAllVapiConfigCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 invalidateCompanyUsersCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
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-2.5-flash', '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' => 'openai5',
45        ],
46        'voice' => [
47            'provider' => 'playht',
48        ],
49        'assistant' => [
50            'name' => 'FlyMSG Assistant',
51            'silence_timeout_seconds' => 10,
52            'response_delay_seconds' => 2,
53            'start_speaking_wait_seconds' => 0.7,
54            'end_call_message' => 'Goodbye.',
55            'recording_format' => 'mp3',
56        ],
57        'first_messages' => [
58            'Hello, this is {name}.',
59            'Hello?',
60            'Hi, this is {name}.',
61            'Good day, this is {name}.',
62            'Yes, no problem. Hello, this is {name}.',
63            'Hello, what can I do for you?',
64            '{name} here, how can I help you?',
65        ],
66    ];
67
68    /**
69     * Get the merged VAPI config for a user (global → company → user).
70     *
71     * @param string $userId
72     * @param string|null $companyId
73     * @return array<string, mixed>
74     */
75    public function getMergedConfig(string $userId, ?string $companyId): array
76    {
77        $cacheKey = "vapi_config_{$userId}";
78
79        $tags = ['vapi_config'];
80        if ($companyId) {
81            $tags[] = "vapi_company_{$companyId}";
82        }
83
84        return Cache::tags($tags)->remember($cacheKey, self::CACHE_TTL_SECONDS, function () use ($userId, $companyId) {
85            $globalDefaults = $this->getGlobalDefaults();
86
87            $companyOverrides = [];
88            if ($companyId) {
89                $companyOverrides = $this->getCompanyOverrides($companyId);
90            }
91
92            $userOverrides = $this->getUserOverrides($userId);
93
94            return array_replace_recursive($globalDefaults, $companyOverrides, $userOverrides);
95        });
96    }
97
98    /**
99     * Get the global VAPI defaults from RolePlayConfig.
100     *
101     * @return array<string, mixed>
102     */
103    public function getGlobalDefaults(): array
104    {
105        $config = RolePlayConfig::getGlobal();
106
107        return $config?->vapi_defaults ?? self::FALLBACK_DEFAULTS;
108    }
109
110    /**
111     * Update the global VAPI defaults in RolePlayConfig.
112     *
113     * @param array<string, mixed> $data
114     * @return array<string, mixed>
115     */
116    public function updateGlobalDefaults(array $data): array
117    {
118        $config = RolePlayConfig::getGlobal();
119
120        if (!$config) {
121            $config = RolePlayConfig::create([
122                'type' => 'global',
123                'vapi_defaults' => $data,
124            ]);
125        } else {
126            $config->updateSection('vapi_defaults', $data);
127        }
128
129        $this->invalidateAllVapiConfigCache();
130
131        return $config->fresh()->vapi_defaults;
132    }
133
134    /**
135     * Get company-level VAPI overrides.
136     *
137     * @param string $companyId
138     * @return array<string, mixed>
139     */
140    public function getCompanyOverrides(string $companyId): array
141    {
142        $config = CompanyVapiConfig::forCompany($companyId);
143
144        return $config?->vapi_overrides ?? [];
145    }
146
147    /**
148     * Update company-level VAPI overrides (upsert).
149     *
150     * @param string $companyId
151     * @param array<string, mixed> $data
152     * @return array<string, mixed>
153     */
154    public function updateCompanyOverrides(string $companyId, array $data): array
155    {
156        $config = CompanyVapiConfig::updateOrCreate(
157            ['company_id' => $companyId],
158            ['vapi_overrides' => $data]
159        );
160
161        $this->invalidateCompanyUsersCache($companyId);
162
163        return $config->fresh()->vapi_overrides;
164    }
165
166    /**
167     * Delete company-level VAPI overrides.
168     *
169     * @param string $companyId
170     * @return void
171     */
172    public function deleteCompanyOverrides(string $companyId): void
173    {
174        CompanyVapiConfig::where('company_id', $companyId)->delete();
175        $this->invalidateCompanyUsersCache($companyId);
176    }
177
178    /**
179     * Get user-level VAPI overrides.
180     *
181     * @param string $userId
182     * @return array<string, mixed>
183     */
184    public function getUserOverrides(string $userId): array
185    {
186        $config = UserVapiConfig::forUser($userId);
187
188        return $config?->vapi_overrides ?? [];
189    }
190
191    /**
192     * Update user-level VAPI overrides (upsert).
193     *
194     * @param string $userId
195     * @param array<string, mixed> $data
196     * @return array<string, mixed>
197     */
198    public function updateUserOverrides(string $userId, array $data): array
199    {
200        $config = UserVapiConfig::updateOrCreate(
201            ['user_id' => $userId],
202            ['vapi_overrides' => $data]
203        );
204
205        Cache::tags(['vapi_config'])->forget("vapi_config_{$userId}");
206
207        return $config->fresh()->vapi_overrides;
208    }
209
210    /**
211     * Delete user-level VAPI overrides.
212     *
213     * @param string $userId
214     * @return void
215     */
216    public function deleteUserOverrides(string $userId): void
217    {
218        UserVapiConfig::where('user_id', $userId)->delete();
219        Cache::tags(['vapi_config'])->forget("vapi_config_{$userId}");
220    }
221
222    /**
223     * Preview the merged config for a specific user (admin use).
224     *
225     * Bypasses cache to show the current resolved state.
226     *
227     * @param string $userId
228     * @param string|null $companyId
229     * @return array<string, mixed>
230     */
231    public function previewMergedConfig(string $userId, ?string $companyId): array
232    {
233        $globalDefaults = $this->getGlobalDefaults();
234
235        $companyOverrides = [];
236        if ($companyId) {
237            $companyOverrides = $this->getCompanyOverrides($companyId);
238        }
239
240        $userOverrides = $this->getUserOverrides($userId);
241
242        return array_replace_recursive($globalDefaults, $companyOverrides, $userOverrides);
243    }
244
245    /**
246     * Invalidate all VAPI config caches.
247     *
248     * Called when global defaults change, since all users are affected.
249     *
250     * @return void
251     */
252    private function invalidateAllVapiConfigCache(): void
253    {
254        Cache::tags(['vapi_config'])->flush();
255    }
256
257    /**
258     * Invalidate cached VAPI configs for all users in a company.
259     *
260     * @param string $companyId
261     * @return void
262     */
263    private function invalidateCompanyUsersCache(string $companyId): void
264    {
265        Cache::tags(["vapi_company_{$companyId}"])->flush();
266    }
267}