Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.34% covered (warning)
85.34%
326 / 382
25.00% covered (danger)
25.00%
3 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
FlyAIController
85.34% covered (warning)
85.34%
326 / 382
25.00% covered (danger)
25.00%
3 / 12
57.88
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
 checkSentences
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 rewrite
85.71% covered (warning)
85.71%
36 / 42
0.00% covered (danger)
0.00%
0 / 1
7.14
 checkQuota
96.97% covered (success)
96.97%
64 / 66
0.00% covered (danger)
0.00%
0 / 1
13
 engage_generate
84.31% covered (warning)
84.31%
43 / 51
0.00% covered (danger)
0.00%
0 / 1
2.02
 post_generate
85.19% covered (warning)
85.19%
46 / 54
0.00% covered (danger)
0.00%
0 / 1
2.01
 save_custom_prompt
97.44% covered (success)
97.44%
38 / 39
0.00% covered (danger)
0.00%
0 / 1
3
 update_custom_prompt
88.10% covered (warning)
88.10%
37 / 42
0.00% covered (danger)
0.00%
0 / 1
3.02
 delete_custom_prompt
69.23% covered (warning)
69.23%
18 / 26
0.00% covered (danger)
0.00%
0 / 1
3.26
 buildQuotaResponse
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getTypingSpeedConfigByFeature
70.97% covered (warning)
70.97%
22 / 31
0.00% covered (danger)
0.00%
0 / 1
13.96
 convertStringToJson
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace App\Http\Controllers\v2;
4
5use App\Events\TrackFlyMsgAIUsageEvent;
6use App\Helpers\Constants;
7use App\Helpers\FlyMSGLogger;
8use App\Http\Controllers\Controller;
9use App\Http\Models\AIPrompts;
10use App\Http\Models\Parameter;
11use App\Http\Models\PromptLanguage;
12use App\Http\Models\Prompts\CustomPrompts;
13use App\Http\Models\PromptTone;
14use App\Http\Models\Setting;
15use App\Http\Requests\FlyAI\EngageGenerateFormRequest;
16use App\Http\Requests\FlyAI\PostGenerateFormRequest;
17use App\Http\Requests\FlyAI\RewriteFormRequest;
18use App\Http\Requests\FlyAI\SavePromptFormRequest;
19use App\Services\FlyMsgAI\FlyMsgAIService;
20use App\Traits\SubscriptionTrait;
21use Carbon\Carbon;
22use Illuminate\Http\JsonResponse;
23use Illuminate\Http\Request;
24use Illuminate\Http\Response;
25use Illuminate\Support\Str;
26
27class FlyAIController extends Controller
28{
29    use SubscriptionTrait;
30
31    public function __construct(
32        private readonly FlyMsgAIService $ai_service
33    ) {}
34
35    public function checkSentences(Request $request): JsonResponse
36    {
37        $sentences = $request->sentences;
38
39        if (! $sentences) {
40            return response()->json([
41                'status' => 'error',
42                'message' => 'sentences field is required.',
43            ], 400);
44        }
45
46        $rewrite = $this->ai_service->checkSentences($sentences);
47
48        return response()->json([
49            'status' => 'success',
50            'data' => $rewrite,
51        ]);
52    }
53
54    public function rewrite(RewriteFormRequest $request): JsonResponse
55    {
56        $data = $request->validated();
57        $user = $request->user();
58
59        $rewrite = $this->ai_service->rewrite($data, $user->id, $user->company_id ?? null);
60
61        $prompt = 'FlyMSG AI Rewrite: '.$data['action']."\n".'Input: '.$data['input'];
62        if (isset($data['tone']) && ! empty($data['tone'])) {
63            $tone = PromptTone::find($data['tone']);
64            if ($tone) {
65                $prompt .= "\nTone: ".$tone->name;
66            }
67        }
68        if (isset($data['language']) && ! empty($data['language'])) {
69            $language = PromptLanguage::find($data['language']);
70            if ($language) {
71                $prompt .= "\nLanguage: ".$language->name;
72            }
73        }
74
75        $quota = $this->getFlyAIQuota($user);
76
77        TrackFlyMsgAIUsageEvent::dispatch(
78            $user,
79            $rewrite['output']['suggestion'],
80            $request->browser,
81            $data['action'],
82            $rewrite['prompt'],
83            $data['product'],
84            $user->id.'-'.Carbon::now()->timestamp,
85            [
86                'response' => $rewrite['output'],
87                'prompt' => $rewrite['prompt'],
88            ],
89            $data['context'] ?? ''
90        );
91
92        $usedQuota = ($quota['used'] ?? 0) + 1;
93        $totalQuota = $quota['total'] ?? 0;
94
95        return response()->json([
96            'status' => 'success',
97            'data' => [
98                'input' => $data,
99                'output' => $rewrite['output'],
100                'quota' => [
101                    'used' => $usedQuota,
102                    'total' => $totalQuota,
103                    'remaining' => max($totalQuota - $usedQuota, 0),
104                    'seconds_remaining_until_next_prompt_refill' => now()->diffInSeconds(now()->startOfDay()->addDay()),
105                ],
106            ],
107        ]);
108    }
109
110    public function checkQuota(Request $request): JsonResponse
111    {
112        $feature = $request->feature;
113
114        if (! $feature) {
115            return response()->json([
116                'status' => 'error',
117                'message' => 'Feature is required.',
118            ], 400);
119        }
120
121        $user = $request->user();
122
123        $order = [];
124
125        $product = match (true) {
126            Str::contains($feature, 'flyengage') => 'fly_engage',
127            $feature === 'flypost' => 'fly_post',
128            default => $feature,
129        };
130
131        $prompts = AIPrompts::where('product', $product)
132            ->orderBy('version', 'desc')
133            ->get()
134            ->unique('name')
135            ->values();
136
137        $default_prompts = [];
138        foreach ($prompts as $prompt) {
139            $default_prompts[] = [
140                'id' => $prompt->id,
141                'name' => $prompt->name,
142                'prompt' => $prompt->mission ?? '',
143                'feature' => $feature,
144                'prompt_tone_id' => null,
145            ];
146        }
147
148        if (str_contains($feature, 'flyengage')) {
149            $order = ['curious', 'optimistic', 'thoughtful', 'custom'];
150        } elseif ($feature == 'flypost') {
151            $order = ['thought leadership', 'company news', 'celebrate something', 'hiring', 'custom'];
152        }
153
154        $default_prompts = collect($default_prompts)->sort(function ($a, $b) use ($order) {
155            $posA = array_search($a['name'], $order);
156            $posB = array_search($b['name'], $order);
157
158            $posA = $posA === false ? count($order) : $posA;
159            $posB = $posB === false ? count($order) : $posB;
160
161            return $posA - $posB;
162        });
163
164        $default_prompts = $default_prompts->values();
165
166        $quota = $this->getFlyAIQuota($user);
167        $promptsQuota = $this->getPromptQuota($user);
168        $savedPrompts = CustomPrompts::where('feature', $feature)->get();
169
170        $setting = Setting::where('user_id', $user->id)->first();
171        $orderKey = match (true) {
172            Str::contains($feature, 'flyengage') => 'custom_prompts_order_flyengage',
173            $feature === 'flypost' => 'custom_prompts_order_flypost',
174            default => null,
175        };
176
177        if ($orderKey && $setting && ! empty($setting->$orderKey)) {
178            $orderMap = array_flip(array_map('strval', $setting->$orderKey));
179            $savedPrompts = $savedPrompts->sortBy(function ($prompt) use ($orderMap) {
180                return $orderMap[(string) $prompt->id] ?? PHP_INT_MAX;
181            })->values();
182        }
183
184        $plan = $this->getCurrentPlan($user);
185        $show_upgrade_button = empty($user->company_id) && $plan->identifier !== 'sales-pro-monthly' && $plan->identifier !== 'sales-pro-yearly';
186
187        return response()->json([
188            'status' => 'success',
189            'data' => [
190                'is_enterprise' => ! empty($user->company_id) && $user->status !== 'Invited',
191                'show_upgrade_button' => $show_upgrade_button,
192                'quota' => $quota,
193                'prompts_quota' => $promptsQuota,
194                'default_prompts' => $default_prompts,
195                'saved_prompts' => $savedPrompts,
196            ],
197        ]);
198    }
199
200    public function engage_generate(EngageGenerateFormRequest $request): JsonResponse
201    {
202        $user = $request->user();
203        $validated = $request->validated();
204
205        $context = $validated['context'];
206        $context['post']['content'] = strip_tags($context['post']['content']);
207        $additional_instructions = $validated['additional_instructions'] ?? null;
208        $uniqueId = "https://www.linkedin.com/feed/update/{$validated['uniqueId']}";
209
210        $prompt = AIPrompts::find($validated['prompt_id']);
211
212        try {
213            $aiResult = $this->ai_service->engageGenerate(
214                $validated['prompt_id'],
215                $context,
216                $uniqueId,
217                $user,
218                $validated['include_hashtags'],
219                $validated['include_emojis'],
220                $validated['persona_id'] ?? null,
221                $validated['prompt_tone_id'] ?? null,
222                $additional_instructions,
223                $validated['custom_prompt_id'] ?? null,
224                $validated['is_regenerate'] ?? false,
225                $validated['is_reply'] ?? false
226            );
227            $prompt_response = $aiResult['response'];
228
229            TrackFlyMsgAIUsageEvent::dispatch(
230                $user,
231                $prompt_response,
232                $request->browser,
233                $prompt->name,
234                $prompt->mission."\n".$additional_instructions."\n\n Post:\n\n ".$context['post']['content'],
235                'flyengage',
236                $uniqueId,
237                $aiResult,
238                $context
239            );
240
241            return response()->json([
242                'status' => 'success',
243                'data' => [
244                    'prompt' => $prompt->mission."\n".$additional_instructions."\n\n Post:\n\n ",
245                    'context' => $context,
246                    'prompt_response' => $prompt_response,
247                    'quota' => $this->buildQuotaResponse($user),
248                    'typingConfig' => $this->getTypingSpeedConfigByFeature('flyengage', $user),
249                ],
250            ]);
251        } catch (\Throwable $th) {
252            FlyMSGLogger::logError('generate', $th);
253
254            return response()->json([
255                'status' => 'error',
256                'data' => [
257                    'message' => 'Something went wrong. '.$th->getMessage(),
258                ],
259            ], Response::HTTP_INTERNAL_SERVER_ERROR);
260        }
261    }
262
263    public function post_generate(PostGenerateFormRequest $request): JsonResponse
264    {
265        $user = $request->user();
266        $validated = $request->validated();
267
268        $additional_instructions = $validated['additional_instructions'] ?? null;
269        $uniqueId = str_slug($validated['uniqueId'] ?? '');
270
271        $prompt = AIPrompts::find($validated['prompt_id']);
272
273        try {
274            $aiResult = $this->ai_service->postGenerate(
275                $validated['prompt_id'],
276                $validated['youtube_url'] ?? null,
277                $validated['blog_url'] ?? null,
278                $uniqueId,
279                $user,
280                $validated['include_hashtags'],
281                $validated['include_emojis'],
282                $validated['prompt_language_id'],
283                $validated['length_of_post_id'],
284                $validated['topic'] ?? null,
285                $validated['insert_role'] ?? null,
286                $validated['prompt_company_new_update_id'] ?? null,
287                $validated['prompt_personal_milestone_id'] ?? null,
288                $validated['persona_id'] ?? null,
289                $validated['prompt_tone_id'] ?? null,
290                $additional_instructions,
291                $validated['custom_prompt_id'] ?? null,
292                $validated['is_regenerate'] ?? false
293            );
294            $prompt_response = $aiResult['response'];
295
296            TrackFlyMsgAIUsageEvent::dispatch(
297                $user,
298                $prompt_response,
299                $request->browser,
300                $prompt->name,
301                $prompt->mission."\n".$additional_instructions,
302                'flypost',
303                $uniqueId,
304                $aiResult,
305                null
306            );
307
308            return response()->json([
309                'status' => 'success',
310                'data' => [
311                    'prompt' => $prompt->mission."\n".$additional_instructions,
312                    'prompt_response' => $prompt_response,
313                    'quota' => $this->buildQuotaResponse($user),
314                    'typingConfig' => $this->getTypingSpeedConfigByFeature('flypost', $user),
315                ],
316            ]);
317        } catch (\Throwable $th) {
318            FlyMSGLogger::logError('generate', $th);
319
320            return response()->json([
321                'status' => 'error',
322                'data' => [
323                    'message' => 'Something went wrong. '.$th->getMessage(),
324                ],
325            ], Response::HTTP_INTERNAL_SERVER_ERROR);
326        }
327    }
328
329    public function save_custom_prompt(SavePromptFormRequest $request): JsonResponse
330    {
331        $validated = $request->validated();
332        $user = $request->user();
333
334        $existsSameName = CustomPrompts::where('name', $validated['name'])->where('feature', $validated['feature'])->where('user_id', $user->id)->exists();
335
336        if ($existsSameName) {
337            return response()->json([
338                'status' => 'error',
339                'message' => 'You already have a custom prompt with the same name',
340            ], 400);
341        }
342
343        $defaultProduct = match (true) {
344            Str::contains($validated['feature'], 'flyengage') => 'fly_engage',
345            $validated['feature'] === 'flypost' => 'fly_post',
346            default => $validated['feature'],
347        };
348
349        $existsDefaultSameName = AIPrompts::where('product', $defaultProduct)
350            ->where('name', $validated['name'])
351            ->exists();
352
353        if ($existsDefaultSameName) {
354            return response()->json([
355                'status' => 'error',
356                'message' => 'You cannot use the same name as a default prompt',
357            ], 400);
358        }
359
360        $customPrompt = new CustomPrompts;
361        $customPrompt->user_id = $user->id;
362        $customPrompt->fill($validated);
363        $customPrompt->save();
364
365        $customPrompt = $customPrompt->fresh();
366
367        $quota = $this->getFlyAIQuota($user);
368        $promptsQuota = $this->getPromptQuota($user);
369        $savedPrompts = CustomPrompts::where('feature', $validated['feature'])->get();
370
371        return response()->json([
372            'status' => 'success',
373            'data' => [
374                'success' => true,
375                'prompt' => $customPrompt,
376                'quota' => $quota,
377                'prompts_quota' => $promptsQuota,
378                'saved_prompts' => $savedPrompts,
379            ],
380        ]);
381    }
382
383    public function update_custom_prompt(SavePromptFormRequest $request, CustomPrompts $customPrompt): JsonResponse
384    {
385        $validated = $request->validated();
386        $user = $request->user();
387
388        $existsSameName = CustomPrompts::where('name', $validated['name'])
389            ->where('feature', $validated['feature'])
390            ->where('user_id', $user->id)
391            ->where('_id', '!=', $customPrompt->id)
392            ->exists();
393
394        if ($existsSameName) {
395            return response()->json([
396                'status' => 'error',
397                'message' => 'You already have a custom prompt with the same name',
398            ], 400);
399        }
400
401        $defaultProduct = match (true) {
402            Str::contains($validated['feature'], 'flyengage') => 'fly_engage',
403            $validated['feature'] === 'flypost' => 'fly_post',
404            default => $validated['feature'],
405        };
406
407        $existsDefaultSameName = AIPrompts::where('product', $defaultProduct)
408            ->where('name', $validated['name'])
409            ->exists();
410
411        if ($existsDefaultSameName) {
412            return response()->json([
413                'status' => 'error',
414                'message' => 'You cannot use the same name as a default prompt',
415            ], 400);
416        }
417
418        $customPrompt->user_id = $user->id;
419        $customPrompt->fill($validated);
420        $customPrompt->save();
421
422        $customPrompt = $customPrompt->fresh();
423
424        $quota = $this->getFlyAIQuota($user);
425        $promptsQuota = $this->getPromptQuota($user);
426        $savedPrompts = CustomPrompts::where('feature', $validated['feature'])->get();
427
428        return response()->json([
429            'status' => 'success',
430            'data' => [
431                'success' => true,
432                'prompt' => $customPrompt,
433                'quota' => $quota,
434                'prompts_quota' => $promptsQuota,
435                'saved_prompts' => $savedPrompts,
436            ],
437        ]);
438    }
439
440    public function delete_custom_prompt(Request $request, CustomPrompts $customPrompt): JsonResponse
441    {
442        $user = $request->user();
443        $customPrompt->user_id = $user->id;
444        $feature = $customPrompt->feature;
445
446        if ($user->id != $customPrompt->user_id) {
447            return response()->json([
448                'status' => 'error',
449                'message' => 'You are not authorized to delete this custom prompt',
450            ], 403);
451        }
452
453        if (! $customPrompt->delete()) {
454            return response()->json([
455                'status' => 'error',
456                'message' => 'Failed to delete custom prompt',
457            ], 400);
458        }
459
460        $quota = $this->getFlyAIQuota($user);
461        $promptsQuota = $this->getPromptQuota($user);
462        $savedPrompts = CustomPrompts::where('feature', $feature)->get();
463
464        return response()->json([
465            'status' => 'success',
466            'data' => [
467                'message' => 'Custom prompt deleted successfully',
468                'success' => true,
469                'quota' => $quota,
470                'prompts_quota' => $promptsQuota,
471                'saved_prompts' => $savedPrompts,
472            ],
473        ]);
474    }
475
476    /**
477     * Build the quota response array for the current user.
478     *
479     * @param  mixed  $user  The authenticated user
480     * @return array{used: int, total: int, remaining: int, deleted: bool, seconds_remaining_until_next_prompt_refill: int}
481     */
482    private function buildQuotaResponse($user): array
483    {
484        $currentSubscriptionPlan = $this->getCurrentPlan($user);
485        $total = Constants::CURRENT_SUBSCRIPTION_PLAN_IDENTIFIERS[$currentSubscriptionPlan->identifier];
486        $used = $this->getQuotaUsed($user->id) + 1;
487
488        return [
489            'used' => $used,
490            'total' => $total,
491            'remaining' => max($total - $used, 0),
492            'deleted' => $used >= $total,
493            'seconds_remaining_until_next_prompt_refill' => now()->diffInSeconds(now()->startOfDay()->addDay()),
494        ];
495    }
496
497    private function getTypingSpeedConfigByFeature($feature, $user)
498    {
499        $parameters = Parameter::where('name', 'like', 'flywrite_typing%')->get()->keyBy('name');
500
501        $flypostTypingMode = $parameters['flywrite_typing_post_typing_mode']->value ?? 'all';
502        $flypostTypingSpeed = $parameters['flywrite_typing_post_typing_speed']->value ?? Constants::FLYPOST_FE_TYPING_SPEED_PER_MINUTE;
503        $flyengageTypingMode = $parameters['flywrite_typing_engage_typing_mode']->value ?? 'all';
504        $flyengageTypingSpeed = $parameters['flywrite_typing_engage_typing_speed']->value ?? Constants::FLYENGAGE_FE_TYPING_SPEED_PER_MINUTE;
505
506        $userSetting = Setting::where('user_id', $user->id)->first();
507
508        $flyPostType = $userSetting?->typing_style ?? $flypostTypingMode;
509        $flyEngageType = $userSetting?->typing_style ?? $flyengageTypingMode;
510        $flyPostSpeed = (! empty($userSetting?->typing_style) && $userSetting?->typing_style == 'letter') ? ($userSetting->typing_speed ?? $flypostTypingSpeed) : $flypostTypingSpeed;
511        $flyEngageSpeed = (! empty($userSetting?->typing_style) && $userSetting?->typing_style == 'letter') ? ($userSetting->typing_speed ?? $flyengageTypingSpeed) : $flyengageTypingSpeed;
512
513        if ($user->company_id) {
514            $companySetting = Setting::where('company_id', $user->company_id)->first();
515
516            if ($companySetting) {
517                if ($companySetting->override_user_typing_style) {
518                    if (! empty($companySetting->typing_style)) {
519                        $flyPostType = $companySetting->typing_style;
520                        $flyEngageType = $companySetting->typing_style;
521
522                        if ($companySetting->typing_style == 'letter' && ! empty($companySetting->typing_speed)) {
523                            $flyPostSpeed = $companySetting->typing_speed;
524                            $flyEngageSpeed = $companySetting->typing_speed;
525                        }
526                    }
527                }
528            }
529        }
530
531        return match ($feature) {
532            'flypost' => [
533                'type' => $flyPostType,
534                'words_per_minute' => $flyPostSpeed,
535            ],
536            'flyengage' => [
537                'type' => $flyEngageType,
538                'words_per_minute' => $flyEngageSpeed,
539            ],
540            default => null
541        };
542    }
543
544    public function convertStringToJson(string $input)
545    {
546        $cleanedInput = Str::of($input)
547            ->replaceMatches('/^```json/m', '')
548            ->replaceMatches('/```$/m', '')
549            ->replaceMatches('/^```text/m', '')
550            ->replaceMatches('/```$/m', '')
551            ->trim();
552
553        try {
554            return json_decode($cleanedInput, true, 512, JSON_THROW_ON_ERROR);
555        } catch (\Exception $e) {
556            return $cleanedInput;
557        }
558    }
559}