diff --git a/app/Livewire/CoverLetterEditor.php b/app/Livewire/CoverLetterEditor.php new file mode 100644 index 0000000..ed92afb --- /dev/null +++ b/app/Livewire/CoverLetterEditor.php @@ -0,0 +1,145 @@ +user_id !== auth()->id()) { + abort(403); + } + + $this->jobPosting = $jobPosting; + $this->content = $this->formatLetterText($jobPosting->cover_letter ?? ''); + } + + private function formatLetterText(string $text): string + { + if (empty($text)) return ''; + $text = trim(str_replace(['**', '##', '#', '*'], '', $text)); + if (strpos($text, "\n\n") === false && strpos($text, "\n") !== false) { + $text = str_replace("\n", "\n\n", $text); + } + return $text; + } + + // This exposes $this->dailyLimit to your Blade view dynamically + public function getDailyLimitProperty(): int + { + return config('services.groq.daily_limit', 5); + } + + public function getCreditsRemainingProperty(): int + { + $dailyKey = 'cv-gen-daily:' . auth()->id(); + $attempts = RateLimiter::attempts($dailyKey); + return max(0, $this->dailyLimit - $attempts); + } + + public function generate(GroqCoverLetterService $service) + { + $userId = auth()->id(); + $minuteKey = 'cv-gen-min:' . $userId; + $dailyKey = 'cv-gen-daily:' . $userId; + + if (RateLimiter::tooManyAttempts($dailyKey, $this->dailyLimit)) { + $this->errorMessage = "You have exhausted your {$this->dailyLimit} daily AI generations. Please try again tomorrow."; + $this->dispatch('toast', message: 'Daily limit reached.', type: 'error'); + return; + } + + if (RateLimiter::tooManyAttempts($minuteKey, $this->burstLimit)) { + $seconds = RateLimiter::availableIn($minuteKey); + $this->errorMessage = "System cooling down. Please wait {$seconds} seconds to ensure high-quality generation."; + $this->dispatch('toast', message: 'Generating too fast.', type: 'error'); + return; + } + + $this->isGenerating = true; + $this->errorMessage = ''; + + try { + if (empty(config('services.groq.api_key'))) { + throw new Exception('GROQ_API_KEY is missing in your configuration.'); + } + + $resumeContent = null; + $resumeSource = ''; + + $latestMatch = $this->jobPosting->matchReports()->latest()->first(); + + if ($latestMatch && $latestMatch->resume && !empty($latestMatch->resume->content_raw)) { + $resumeContent = $latestMatch->resume->content_raw; + $resumeSource = 'Targeted Match Resume'; + } else { + $primaryResume = auth()->user()->resumes()->where('is_primary', true)->first(); + if ($primaryResume && !empty($primaryResume->content_raw)) { + $resumeContent = $primaryResume->content_raw; + $resumeSource = 'Primary Resume'; + } + } + + if (empty($resumeContent)) { + throw new Exception('No resume found. Please set a Primary Resume.'); + } + + if (empty($this->jobPosting->description)) { + throw new Exception('Job description is missing. AI needs context.'); + } + + $strictContext = "Company: {$this->jobPosting->company}\nJob Title: {$this->jobPosting->title}\nDescription: {$this->jobPosting->description}\n\nCRITICAL AI INSTRUCTION: Output PLAIN TEXT ONLY. You MUST use double newlines (\\n\\n) between paragraphs to format it as a business letter."; + + $generatedText = $service->generate($resumeContent, $strictContext); + $this->content = $this->formatLetterText($generatedText); + + RateLimiter::hit($minuteKey, 60); + RateLimiter::hit($dailyKey, 86400); + + $this->dispatch('toast', message: "Draft generated using {$resumeSource}!", type: 'success'); + + } catch (Exception $e) { + Log::error('Cover Letter Gen Error: ' . $e->getMessage()); + $this->errorMessage = $e->getMessage(); + $this->dispatch('toast', message: 'Generation failed.', type: 'error'); + } finally { + $this->isGenerating = false; + } + } + + public function save() + { + $this->validate(['content' => 'required|string']); + $this->jobPosting->update(['cover_letter' => $this->content]); + $this->dispatch('toast', message: 'Cover letter saved securely!', type: 'success'); + } + + public function executeDelete() + { + $this->jobPosting->update(['cover_letter' => null]); + session()->flash('success', 'Cover letter draft discarded.'); + return redirect()->route('cover-letters.index'); + } + + public function render() + { + return view('livewire.cover-letter-editor'); + } +} \ No newline at end of file diff --git a/app/Livewire/CoverLetterGenerator.php b/app/Livewire/CoverLetterGenerator.php new file mode 100644 index 0000000..f978e26 --- /dev/null +++ b/app/Livewire/CoverLetterGenerator.php @@ -0,0 +1,95 @@ +jobs = auth()->user()->jobPostings()->latest()->get(); + + if (request()->has('job_id')) { + $this->jobId = request('job_id'); + $this->loadJob(); + } + } + + public function updatedJobId() + { + $this->loadJob(); + } + + public function loadJob() + { + if (empty($this->jobId)) { + $this->selectedJob = null; + $this->coverLetterContent = ''; + return; + } + + $this->selectedJob = auth()->user()->jobPostings()->find($this->jobId); + $this->coverLetterContent = $this->selectedJob->cover_letter ?? ''; + } + + public function generate(GroqCoverLetterService $service) + { + if (!$this->selectedJob) { + session()->flash('error', 'Please select an application first.'); + return; + } + + $this->isGenerating = true; + + try { + $primaryResume = auth()->user()->resumes()->where('is_primary', true)->first(); + + if (!$primaryResume || empty($primaryResume->content_raw)) { + throw new Exception('Please upload and set a primary resume in your profile first.'); + } + + // Generate draft and populate the editor + $this->coverLetterContent = $service->generate( + $primaryResume->content_raw, + "Company: {$this->selectedJob->company}\nJob Title: {$this->selectedJob->title}\nDescription: {$this->selectedJob->description}" + ); + + session()->flash('success', 'Draft generated successfully! You can now edit and save it.'); + + } catch (Exception $e) { + session()->flash('error', $e->getMessage()); + } finally { + $this->isGenerating = false; + } + } + + public function save() + { + if (!$this->selectedJob) return; + + $this->validate([ + 'coverLetterContent' => 'required|string', + ]); + + $this->selectedJob->update([ + 'cover_letter' => $this->coverLetterContent + ]); + + session()->flash('success', 'Cover letter saved to application successfully.'); + } + + public function render() + { + return view('livewire.cover-letter-generator'); + } +} \ No newline at end of file diff --git a/app/Livewire/CoverLetterIndex.php b/app/Livewire/CoverLetterIndex.php new file mode 100644 index 0000000..453dd7e --- /dev/null +++ b/app/Livewire/CoverLetterIndex.php @@ -0,0 +1,98 @@ +resetPage(); + } + + public function resetFilters() + { + $this->reset('search'); + $this->resetPage(); + } + + public function confirmDelete($id) + { + $this->coverLetterToDelete = $id; + } + + public function executeDelete() + { + if ($this->coverLetterToDelete) { + $job = auth()->user()->jobPostings()->find($this->coverLetterToDelete); + + if ($job) { + $job->update(['cover_letter' => null]); + $this->dispatch('toast', message: 'Cover letter draft deleted successfully.', type: 'success'); + } + + $this->coverLetterToDelete = null; + } + } + + public function cancelDelete() + { + $this->coverLetterToDelete = null; + } + + public function startWorkspace() + { + $this->validate([ + 'selectedJobForNewLetter' => 'required|exists:job_postings,id' + ]); + + return redirect()->route('cover-letters.edit', $this->selectedJobForNewLetter); + } + + public function getCreditsRemainingProperty(): int + { + $dailyLimit = config('services.groq.daily_limit', 5); + $dailyKey = 'cv-gen-daily:' . auth()->id(); + $attempts = RateLimiter::attempts($dailyKey); + return max(0, $dailyLimit - $attempts); + } + + public function render() + { + $query = auth()->user()->jobPostings()->whereNotNull('cover_letter'); + + if (!empty($this->search)) { + $query->where(function ($q) { + $q->where('title', 'like', '%' . $this->search . '%') + ->orWhere('company', 'like', '%' . $this->search . '%'); + }); + } + + $jobs = $query->latest()->paginate(12); + + $availableJobsForCreation = auth()->user()->jobPostings() + ->whereNull('cover_letter') + ->latest() + ->get(); + + return view('livewire.cover-letter-index', [ + 'jobs' => $jobs, + 'totalDrafts' => auth()->user()->jobPostings()->whereNotNull('cover_letter')->count(), + 'availableJobs' => $availableJobsForCreation, + 'creditsRemaining' => $this->creditsRemaining, + 'dailyLimit' => config('services.groq.daily_limit', 5), + ]); + } +} \ No newline at end of file diff --git a/app/Livewire/CreateApplication.php b/app/Livewire/CreateApplication.php index 5af0dd0..eee521b 100644 --- a/app/Livewire/CreateApplication.php +++ b/app/Livewire/CreateApplication.php @@ -4,6 +4,9 @@ use App\Enums\ApplicationStatus; use App\Models\JobPosting; +use App\Services\GroqFormatterService; +use Exception; +use Illuminate\Support\Facades\Log; use Livewire\Attributes\Validate; use Livewire\Component; @@ -21,6 +24,32 @@ class CreateApplication extends Component #[Validate('required|string')] public $description = ''; + public bool $isFormatting = false; + + public function autoFormatDescription(GroqFormatterService $formatter) + { + if (empty(trim(strip_tags($this->description)))) { + $this->dispatch('toast', message: 'Please paste a description first before formatting.', type: 'error'); + return; + } + + $this->isFormatting = true; + + try { + $cleanHtml = $formatter->formatJobDescription($this->description); + $this->description = $cleanHtml; + + $this->dispatch('description-formatted', html: $cleanHtml); + $this->dispatch('toast', message: 'Description formatted successfully.', type: 'success'); + + } catch (Exception $e) { + Log::error('Description Formatting Error: ' . $e->getMessage()); + $this->dispatch('toast', message: 'Failed to format description.', type: 'error'); + } finally { + $this->isFormatting = false; + } + } + public function save() { $this->validate(); @@ -43,4 +72,4 @@ public function render() { return view('livewire.create-application'); } -} +} \ No newline at end of file diff --git a/app/Livewire/EditApplication.php b/app/Livewire/EditApplication.php index 86bfa37..2f8db37 100644 --- a/app/Livewire/EditApplication.php +++ b/app/Livewire/EditApplication.php @@ -3,7 +3,9 @@ namespace App\Livewire; use App\Models\JobPosting; +use App\Services\GroqCoverLetterService; use Livewire\Component; +use Exception; class EditApplication extends Component { @@ -13,6 +15,8 @@ class EditApplication extends Component public string $company = ''; public ?string $url = null; public string $description = ''; + public string $generatedCoverLetter = ''; + public bool $isGenerating = false; public function mount(JobPosting $jobPosting) { @@ -26,6 +30,41 @@ public function mount(JobPosting $jobPosting) $this->company = $this->jobPosting->company ?? ''; $this->url = $this->jobPosting->source_url ?? ''; $this->description = $this->jobPosting->description ?? ''; + $this->generatedCoverLetter = $this->jobPosting->cover_letter ?? ''; + } + + public function generateCoverLetter(GroqCoverLetterService $service) + { + $this->isGenerating = true; + + try { + + $primaryResume = auth()->user()->resumes()->where('is_primary', true)->first(); + + $resumeContent = $primaryResume->content_raw ?? ''; + $jobDescription = $this->description; + + if (empty($resumeContent)) { + throw new Exception('No primary resume found. Please upload or set a primary resume first.'); + } + + if (empty($jobDescription)) { + throw new Exception('Job description is required to generate a cover letter.'); + } + + $this->generatedCoverLetter = $service->generate($resumeContent, $jobDescription); + + $this->jobPosting->update([ + 'cover_letter' => $this->generatedCoverLetter + ]); + + session()->flash('success', 'Cover letter generated successfully.'); + + } catch (Exception $e) { + session()->flash('error', $e->getMessage()); + } finally { + $this->isGenerating = false; + } } public function update() @@ -35,6 +74,7 @@ public function update() 'company' => 'required|string|max:255', 'url' => 'nullable|url|max:255', 'description' => 'required|string', + 'generatedCoverLetter' => 'nullable|string', ]); $this->jobPosting->update([ @@ -42,6 +82,7 @@ public function update() 'company' => $this->company, 'source_url' => $this->url, 'description' => $this->description, + 'cover_letter' => $this->generatedCoverLetter, ]); session()->flash('success', 'Job posting updated successfully.'); diff --git a/app/Models/JobPosting.php b/app/Models/JobPosting.php index f8747b0..15beb12 100644 --- a/app/Models/JobPosting.php +++ b/app/Models/JobPosting.php @@ -18,6 +18,7 @@ class JobPosting extends Model 'description', 'source_url', 'status', + 'cover_letter', ]; protected $casts = [ @@ -33,4 +34,4 @@ public function user(): BelongsTo { return $this->belongsTo(User::class); } -} +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php index 33e928e..4cf6cd2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -31,4 +32,20 @@ protected function casts(): array 'password' => 'hashed', ]; } -} + + /** + * Get the job postings associated with the user. + */ + public function jobPostings(): HasMany + { + return $this->hasMany(JobPosting::class); + } + + /** + * Get the resumes associated with the user. + */ + public function resumes(): HasMany + { + return $this->hasMany(Resume::class); + } +} \ No newline at end of file diff --git a/app/Services/GroqCoverLetterService.php b/app/Services/GroqCoverLetterService.php new file mode 100644 index 0000000..0e775bd --- /dev/null +++ b/app/Services/GroqCoverLetterService.php @@ -0,0 +1,44 @@ +timeout(60) + ->post('https://api.groq.com/openai/v1/chat/completions', [ + 'model' => 'llama-3.3-70b-versatile', + 'messages' => [ + ['role' => 'system', 'content' => $prompt], + ['role' => 'user', 'content' => "Resume: \n" . $resumeContent . "\n\nJob Context: \n" . $jobContext], + ], + 'response_format' => ['type' => 'json_object'], + ]); + + if ($response->failed()) { + Log::error('Groq API Error (Cover Letter): ' . $response->body()); + throw new Exception('Failed to connect to Groq AI. Please try again later.'); + } + + $content = $response->json('choices.0.message.content'); + $decoded = json_decode($content, true); + + return $decoded['cover_letter'] ?? 'Failed to generate content.'; + } +} \ No newline at end of file diff --git a/app/Services/GroqFormatterService.php b/app/Services/GroqFormatterService.php new file mode 100644 index 0000000..3f9c877 --- /dev/null +++ b/app/Services/GroqFormatterService.php @@ -0,0 +1,48 @@ +,
Header Name
. " + . "5. You must return ONLY the raw HTML string. Do not use markdown blocks, do not wrap the output in ```html, and do not include any conversational text."; + + // Breaking the URL into pieces so your clipboard/editor cannot auto-format it into a markdown link + $endpoint = 'https://' . 'api.groq.com' . '/openai/v1/chat/completions'; + + $response = Http::withToken($apiKey) + ->timeout(30) + ->post($endpoint, [ + 'model' => 'llama-3.1-8b-instant', + 'messages' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $rawText], + ], + 'temperature' => 0.1, + ]); + + if ($response->failed()) { + throw new Exception('Failed to format the text via Groq API. ' . $response->body()); + } + + $result = $response->json('choices.0.message.content'); + + return trim(str_replace(['```html', '```'], '', $result)); + } +} \ No newline at end of file diff --git a/app/Support/Csp/CustomPolicy.php b/app/Support/Csp/CustomPolicy.php index d30e9ab..51d0983 100644 --- a/app/Support/Csp/CustomPolicy.php +++ b/app/Support/Csp/CustomPolicy.php @@ -36,7 +36,10 @@ public function configure(Policy $policy): void Keyword::UNSAFE_INLINE, Keyword::UNSAFE_EVAL, 'unpkg.com', // Added for Trix Editor JS - // 'cdn.jsdelivr.net', // Uncomment if your chart uses jsdelivr + ]) + ->add(Directive::CONNECT, [ + Keyword::SELF, + 'unpkg.com', // Added for Trix Editor source maps ]) ->add(Directive::FRAME, [ Keyword::SELF, diff --git a/config/services.php b/config/services.php index 9e2a891..bc2531f 100644 --- a/config/services.php +++ b/config/services.php @@ -43,6 +43,7 @@ 'groq' => [ 'api_key' => env('GROQ_API_KEY'), + 'daily_limit' => env('GROQ_DAILY_LIMIT', 5), ], -]; +]; \ No newline at end of file diff --git a/database/migrations/2026_04_07_125131_add_cover_letter_to_applications_table.php b/database/migrations/2026_04_07_125131_add_cover_letter_to_applications_table.php new file mode 100644 index 0000000..36336fb --- /dev/null +++ b/database/migrations/2026_04_07_125131_add_cover_letter_to_applications_table.php @@ -0,0 +1,22 @@ +text('cover_letter')->nullable()->after('status'); + }); + } + + public function down(): void + { + Schema::table('job_postings', function (Blueprint $table) { + $table->dropColumn('cover_letter'); + }); + } +}; \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index 8a4e70d..70e66ff 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -51,4 +51,26 @@ .custom-pagination nav a:hover svg { color: #e26a35 !important; +} + +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: #cbd5e1 transparent; +} + +.custom-scrollbar::-webkit-scrollbar { + width: 4px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background-color: #cbd5e1; + border-radius: 10px; +} + +.custom-scrollbar:hover::-webkit-scrollbar-thumb { + background-color: #94a3b8; } \ No newline at end of file diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php index ba20800..6c2f84d 100644 --- a/resources/views/components/toast.blade.php +++ b/resources/views/components/toast.blade.php @@ -1,6 +1,6 @@ @php $initialMessage = session('success') ?? session('status') ?? session('error') ?? ''; - $isError = session('error') || $errors->any(); + $initialType = session('error') || $errors->any() ? 'error' : 'success'; if (!$initialMessage && $errors->any()) { $initialMessage = $errors->first(); @@ -10,47 +10,53 @@