Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions app/Livewire/CoverLetterEditor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

namespace App\Livewire;

use App\Models\JobPosting;
use App\Services\GroqCoverLetterService;
use Livewire\Component;
use Exception;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Title;

#[Title('Cover Letter Workspace')]
class CoverLetterEditor extends Component
{
public JobPosting $jobPosting;
public string $content = '';
public bool $isGenerating = false;
public string $errorMessage = '';
public bool $showDeleteModal = false;

private int $burstLimit = 2;

public function mount(JobPosting $jobPosting)
{
if ($jobPosting->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');
}
}
95 changes: 95 additions & 0 deletions app/Livewire/CoverLetterGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace App\Livewire;

use App\Models\JobPosting;
use App\Services\GroqCoverLetterService;
use Livewire\Component;
use Exception;

class CoverLetterGenerator extends Component
{
public $jobs;
public $jobId = '';
public ?JobPosting $selectedJob = null;
public string $coverLetterContent = '';
public bool $isGenerating = false;

public function mount()
{
$this->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');
}
}
98 changes: 98 additions & 0 deletions app/Livewire/CoverLetterIndex.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

namespace App\Livewire;

use App\Models\JobPosting;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Title;

#[Title('Cover Letters')]
class CoverLetterIndex extends Component
{
use WithPagination;

public string $search = '';
public $coverLetterToDelete = null;
public $selectedJobForNewLetter = '';

public function updatingSearch()
{
$this->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),
]);
}
}
31 changes: 30 additions & 1 deletion app/Livewire/CreateApplication.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();
Expand All @@ -43,4 +72,4 @@ public function render()
{
return view('livewire.create-application');
}
}
}
Loading
Loading