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