From 2b930a06365274dfb294650c20bf6d2019cb888a Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Fri, 3 Apr 2026 00:39:57 +0300 Subject: [PATCH] phase 4 --- app/Console/Commands/EnrichJobs.php | 37 ++++ app/Models/Job.php | 79 +++++++++ app/Repositories/JobRepository.php | 206 +++++++++++++++++++++++ app/Services/JobEnrichmentService.php | 206 +++++++++++++++++++++++ app/Services/JobSearchService.php | 223 +++++++++++++++++++++++++ database/seeders/JobCategorySeeder.php | 117 +++++++++++++ 6 files changed, 868 insertions(+) create mode 100644 app/Console/Commands/EnrichJobs.php create mode 100644 app/Repositories/JobRepository.php create mode 100644 app/Services/JobEnrichmentService.php create mode 100644 app/Services/JobSearchService.php create mode 100644 database/seeders/JobCategorySeeder.php diff --git a/app/Console/Commands/EnrichJobs.php b/app/Console/Commands/EnrichJobs.php new file mode 100644 index 0000000..21f6fdf --- /dev/null +++ b/app/Console/Commands/EnrichJobs.php @@ -0,0 +1,37 @@ +option('batch'); + + $this->info('Enriching job listings...'); + $this->newLine(); + + $enrichedCount = $this->enrichmentService->enrichJobs($batchSize); + + $this->newLine(); + $this->info("Enriched {$enrichedCount} jobs successfully!"); + + return self::SUCCESS; + } +} diff --git a/app/Models/Job.php b/app/Models/Job.php index fdbb241..9cd6218 100644 --- a/app/Models/Job.php +++ b/app/Models/Job.php @@ -108,4 +108,83 @@ class Job extends Model { return $query->where('experience_level', $level); } + + /** + * Scope a query to search by keywords in title, company, and description. + */ + public function scopeSearchKeywords($query, string $keywords) + { + return $query->where(function ($q) use ($keywords) { + $q->where('title', 'LIKE', "%{$keywords}%") + ->orWhere('company', 'LIKE', "%{$keywords}%") + ->orWhere('description', 'LIKE', "%{$keywords}%"); + }); + } + + /** + * Scope a query to filter by category. + */ + public function scopeInCategory($query, int $categoryId) + { + return $query->where('job_category_id', $categoryId); + } + + /** + * Scope a query to filter by location. + */ + public function scopeInLocation($query, string $location) + { + return $query->where(function ($q) use ($location) { + $q->where('location', 'LIKE', "%{$location}%") + ->orWhereHas('jobLocation', function ($q2) use ($location) { + $q2->where('city', 'LIKE', "%{$location}%") + ->orWhere('state', 'LIKE', "%{$location}%") + ->orWhere('country', 'LIKE', "%{$location}%"); + }); + }); + } + + /** + * Scope a query to filter by salary range. + */ + public function scopeWithinSalaryRange($query, ?float $minSalary = null, ?float $maxSalary = null) + { + if ($minSalary) { + $query->where(function ($q) use ($minSalary) { + $q->where('salary_max', '>=', $minSalary) + ->orWhereNull('salary_max'); + }); + } + + if ($maxSalary) { + $query->where(function ($q) use ($maxSalary) { + $q->where('salary_min', '<=', $maxSalary) + ->orWhereNull('salary_min'); + }); + } + + return $query; + } + + /** + * Scope a query to filter by posted date. + */ + public function scopePostedWithin($query, int $days) + { + return $query->where(function ($q) use ($days) { + $q->where('posted_at', '>=', now()->subDays($days)) + ->orWhere(function ($q2) use ($days) { + $q2->whereNull('posted_at') + ->where('created_at', '>=', now()->subDays($days)); + }); + }); + } + + /** + * Scope a query to filter by company. + */ + public function scopeByCompany($query, string $company) + { + return $query->where('company', 'LIKE', "%{$company}%"); + } } diff --git a/app/Repositories/JobRepository.php b/app/Repositories/JobRepository.php new file mode 100644 index 0000000..d2d6009 --- /dev/null +++ b/app/Repositories/JobRepository.php @@ -0,0 +1,206 @@ +active() + ->latest('posted_at') + ->paginate($perPage); + } + + /** + * Find a job by ID + */ + public function find(int $id): ?Job + { + return Job::with(['source', 'category', 'jobLocation'])->find($id); + } + + /** + * Get jobs by category + */ + public function getByCategory(int $categoryId, int $perPage = 15): LengthAwarePaginator + { + return Job::with(['source', 'category', 'jobLocation']) + ->active() + ->where('job_category_id', $categoryId) + ->latest('posted_at') + ->paginate($perPage); + } + + /** + * Get jobs by location + */ + public function getByLocation(int $locationId, int $perPage = 15): LengthAwarePaginator + { + return Job::with(['source', 'category', 'jobLocation']) + ->active() + ->where('job_location_id', $locationId) + ->latest('posted_at') + ->paginate($perPage); + } + + /** + * Get remote jobs + */ + public function getRemoteJobs(int $perPage = 15): LengthAwarePaginator + { + return Job::with(['source', 'category', 'jobLocation']) + ->active() + ->remote() + ->latest('posted_at') + ->paginate($perPage); + } + + /** + * Get recent jobs (last N days) + */ + public function getRecentJobs(int $days = 7, int $perPage = 15): LengthAwarePaginator + { + return Job::with(['source', 'category', 'jobLocation']) + ->active() + ->where(function ($query) use ($days) { + $query->where('posted_at', '>=', now()->subDays($days)) + ->orWhere(function ($q) use ($days) { + $q->whereNull('posted_at') + ->where('created_at', '>=', now()->subDays($days)); + }); + }) + ->latest('posted_at') + ->paginate($perPage); + } + + /** + * Get jobs by experience level + */ + public function getByExperienceLevel(string $level, int $perPage = 15): LengthAwarePaginator + { + return Job::with(['source', 'category', 'jobLocation']) + ->active() + ->experienceLevel($level) + ->latest('posted_at') + ->paginate($perPage); + } + + /** + * Get jobs by type + */ + public function getByJobType(string $type, int $perPage = 15): LengthAwarePaginator + { + return Job::with(['source', 'category', 'jobLocation']) + ->active() + ->ofType($type) + ->latest('posted_at') + ->paginate($perPage); + } + + /** + * Get similar jobs (same category, exclude current) + */ + public function getSimilarJobs(Job $job, int $limit = 5): Collection + { + return Job::with(['source', 'category', 'jobLocation']) + ->active() + ->where('id', '!=', $job->id) + ->where('job_category_id', $job->job_category_id) + ->latest('posted_at') + ->limit($limit) + ->get(); + } + + /** + * Get featured jobs (most recent from diverse sources) + */ + public function getFeaturedJobs(int $limit = 10): Collection + { + return Job::with(['source', 'category', 'jobLocation']) + ->active() + ->select('jobs.*') + ->selectRaw('ROW_NUMBER() OVER (PARTITION BY job_source_id ORDER BY posted_at DESC) as rn') + ->fromSub(function ($query) { + $query->from('jobs') + ->whereNull('deleted_at') + ->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }, 'jobs') + ->havingRaw('rn <= 2') // Max 2 jobs per source + ->latest('posted_at') + ->limit($limit) + ->get(); + } + + /** + * Get job statistics + */ + public function getStatistics(): array + { + $total = Job::count(); + $active = Job::active()->count(); + $remote = Job::active()->remote()->count(); + $recentlyAdded = Job::where('created_at', '>=', now()->subDays(7))->count(); + + $topCategories = Job::active() + ->select('job_category_id', \DB::raw('COUNT(*) as count')) + ->whereNotNull('job_category_id') + ->groupBy('job_category_id') + ->orderBy('count', 'desc') + ->limit(5) + ->with('category') + ->get(); + + return [ + 'total_jobs' => $total, + 'active_jobs' => $active, + 'remote_jobs' => $remote, + 'recently_added' => $recentlyAdded, + 'top_categories' => $topCategories, + ]; + } + + /** + * Create a new job + */ + public function create(array $data): Job + { + return Job::create($data); + } + + /** + * Update a job + */ + public function update(Job $job, array $data): bool + { + return $job->update($data); + } + + /** + * Delete a job + */ + public function delete(Job $job): bool + { + return $job->delete(); + } + + /** + * Get jobs count by source + */ + public function getCountBySource(): Collection + { + return Job::with('source') + ->select('job_source_id', \DB::raw('COUNT(*) as count')) + ->groupBy('job_source_id') + ->orderBy('count', 'desc') + ->get(); + } +} diff --git a/app/Services/JobEnrichmentService.php b/app/Services/JobEnrichmentService.php new file mode 100644 index 0000000..91a2f57 --- /dev/null +++ b/app/Services/JobEnrichmentService.php @@ -0,0 +1,206 @@ + ['software', 'developer', 'engineer', 'programming', 'coding', 'web', 'mobile', 'frontend', 'backend', 'fullstack', 'full-stack'], + 'Data Science' => ['data', 'analytics', 'scientist', 'machine learning', 'ml', 'ai', 'artificial intelligence', 'statistician'], + 'Design' => ['designer', 'ux', 'ui', 'graphic', 'creative', 'figma', 'sketch', 'product design'], + 'Marketing' => ['marketing', 'seo', 'social media', 'content', 'growth', 'digital marketing', 'brand'], + 'Sales' => ['sales', 'account executive', 'business development', 'account manager', 'sales representative'], + 'Customer Support' => ['support', 'customer service', 'help desk', 'technical support', 'customer success'], + 'Human Resources' => ['hr', 'human resources', 'recruiter', 'talent', 'recruiting'], + 'Finance' => ['finance', 'accountant', 'accounting', 'financial', 'controller', 'analyst'], + 'Operations' => ['operations', 'logistics', 'supply chain', 'project manager', 'program manager'], + 'DevOps' => ['devops', 'infrastructure', 'cloud', 'aws', 'kubernetes', 'docker', 'sre'], + 'Product Management' => ['product manager', 'product owner', 'product lead'], + 'Quality Assurance' => ['qa', 'quality', 'testing', 'test engineer', 'sdet'], + 'Writing' => ['writer', 'content writer', 'copywriter', 'technical writer', 'editor'], + ]; + + /** + * Auto-categorize a job based on title and description + */ + public function categorizeJob(Job $job): ?JobCategory + { + if ($job->job_category_id) { + return $job->category; + } + + $text = strtolower($job->title . ' ' . $job->description); + + foreach ($this->categoryKeywords as $categoryName => $keywords) { + foreach ($keywords as $keyword) { + if (str_contains($text, $keyword)) { + $category = JobCategory::firstOrCreate( + ['slug' => \Str::slug($categoryName)], + ['name' => $categoryName, 'description' => "Jobs related to {$categoryName}"] + ); + + $job->update(['job_category_id' => $category->id]); + + Log::debug("Auto-categorized job #{$job->id} as '{$categoryName}'"); + + return $category; + } + } + } + + return null; + } + + /** + * Enrich job with additional data + */ + public function enrichJob(Job $job): void + { + // Auto-categorize if not categorized + if (!$job->job_category_id) { + $this->categorizeJob($job); + } + + // Detect if remote from title/description + if (!$job->remote_allowed) { + $this->detectRemote($job); + } + + // Parse salary if not set + if (!$job->salary_min && !$job->salary_max) { + $this->parseSalaryFromDescription($job); + } + + // Infer experience level if not set + if (!$job->experience_level) { + $this->inferExperienceLevel($job); + } + } + + /** + * Detect if job is remote based on title/description + */ + private function detectRemote(Job $job): void + { + $text = strtolower($job->title . ' ' . $job->description . ' ' . ($job->location ?? '')); + $remoteKeywords = ['remote', 'work from home', 'wfh', 'telecommute', 'distributed', 'anywhere']; + + foreach ($remoteKeywords as $keyword) { + if (str_contains($text, $keyword)) { + $job->update(['remote_allowed' => true]); + Log::debug("Detected remote job #{$job->id}"); + return; + } + } + } + + /** + * Try to parse salary from description + */ + private function parseSalaryFromDescription(Job $job): void + { + $description = $job->description; + + // Match patterns like $50,000-$80,000, $50k-80k, 50-80k, etc. + $patterns = [ + '/\$?([\d,]+)k?\s*-\s*\$?([\d,]+)k?/i', + '/salary:\s*\$?([\d,]+)k?\s*-\s*\$?([\d,]+)k?/i', + '/compensation:\s*\$?([\d,]+)k?\s*-\s*\$?([\d,]+)k?/i', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $description, $matches)) { + $min = (float) str_replace(',', '', $matches[1]); + $max = (float) str_replace(',', '', $matches[2]); + + // If values look like they're in 'k' format (e.g., 50 instead of 50000) + if ($min < 1000) { + $min *= 1000; + $max *= 1000; + } + + $job->update([ + 'salary_min' => $min, + 'salary_max' => $max, + ]); + + Log::debug("Parsed salary for job #{$job->id}: {$min}-{$max}"); + return; + } + } + } + + /** + * Infer experience level from title/description + */ + private function inferExperienceLevel(Job $job): void + { + $text = strtolower($job->title); + + $levelKeywords = [ + 'entry' => ['junior', 'entry', 'associate', 'intern', 'graduate', 'trainee'], + 'mid' => ['mid', 'intermediate', 'regular'], + 'senior' => ['senior', 'sr', 'lead developer', 'staff'], + 'lead' => ['lead', 'principal', 'architect', 'head of'], + 'executive' => ['director', 'vp', 'vice president', 'chief', 'cto', 'ceo', 'cfo', 'coo'], + ]; + + foreach ($levelKeywords as $level => $keywords) { + foreach ($keywords as $keyword) { + if (str_contains($text, $keyword)) { + $job->update(['experience_level' => $level]); + Log::debug("Inferred experience level for job #{$job->id}: {$level}"); + return; + } + } + } + + // Default to mid-level if no keywords found + $job->update(['experience_level' => 'mid']); + } + + /** + * Batch enrich multiple jobs + */ + public function enrichJobs(int $batchSize = 100): int + { + $jobs = Job::whereNull('job_category_id') + ->orWhereNull('experience_level') + ->limit($batchSize) + ->get(); + + $enrichedCount = 0; + + foreach ($jobs as $job) { + try { + $this->enrichJob($job); + $enrichedCount++; + } catch (\Exception $e) { + Log::error("Failed to enrich job #{$job->id}: " . $e->getMessage()); + } + } + + return $enrichedCount; + } + + /** + * Normalize salary to USD (if we had currency conversion) + */ + public function normalizeSalary(Job $job): void + { + // Placeholder for currency conversion logic + // Would use an API like exchangerate-api.com or similar + + if ($job->salary_currency !== 'USD') { + // Convert to USD + // $job->update(['salary_min' => $converted, 'salary_max' => $converted]); + } + } +} diff --git a/app/Services/JobSearchService.php b/app/Services/JobSearchService.php new file mode 100644 index 0000000..2d48818 --- /dev/null +++ b/app/Services/JobSearchService.php @@ -0,0 +1,223 @@ +with(['source', 'category', 'jobLocation']) + ->active(); + + // Apply filters + $this->applyFilters($query, $filters); + + // Apply sorting + $this->applySorting($query, $filters['sort'] ?? 'date'); + + return $query->paginate($perPage); + } + + /** + * Apply filters to the query + */ + private function applyFilters(Builder $query, array $filters): void + { + // Keyword search (title, company, description) + if (!empty($filters['keywords'])) { + $keywords = $filters['keywords']; + $query->where(function ($q) use ($keywords) { + $q->where('title', 'LIKE', "%{$keywords}%") + ->orWhere('company', 'LIKE', "%{$keywords}%") + ->orWhere('description', 'LIKE', "%{$keywords}%"); + }); + } + + // Full-text search (if supported) + if (!empty($filters['search'])) { + $search = $filters['search']; + $query->whereRaw( + "MATCH(title, company, description) AGAINST(? IN BOOLEAN MODE)", + [$search] + ); + } + + // Location filter + if (!empty($filters['location'])) { + $location = $filters['location']; + $query->where(function ($q) use ($location) { + $q->where('location', 'LIKE', "%{$location}%") + ->orWhereHas('jobLocation', function ($q2) use ($location) { + $q2->where('city', 'LIKE', "%{$location}%") + ->orWhere('state', 'LIKE', "%{$location}%") + ->orWhere('country', 'LIKE', "%{$location}%"); + }); + }); + } + + // Category filter + if (!empty($filters['category_id'])) { + $query->where('job_category_id', $filters['category_id']); + } + + // Job type filter (can be array for multiple types) + if (!empty($filters['job_type'])) { + $types = is_array($filters['job_type']) ? $filters['job_type'] : [$filters['job_type']]; + $query->whereIn('job_type', $types); + } + + // Experience level filter (can be array) + if (!empty($filters['experience_level'])) { + $levels = is_array($filters['experience_level']) ? $filters['experience_level'] : [$filters['experience_level']]; + $query->whereIn('experience_level', $levels); + } + + // Remote filter + if (isset($filters['remote']) && $filters['remote'] !== '') { + $query->where('remote_allowed', (bool) $filters['remote']); + } + + // Salary range filter + if (!empty($filters['salary_min'])) { + $query->where(function ($q) use ($filters) { + $q->where('salary_max', '>=', $filters['salary_min']) + ->orWhereNull('salary_max'); + }); + } + + if (!empty($filters['salary_max'])) { + $query->where(function ($q) use ($filters) { + $q->where('salary_min', '<=', $filters['salary_max']) + ->orWhereNull('salary_min'); + }); + } + + // Posted date filter (e.g., last 24 hours, 7 days, 30 days) + if (!empty($filters['posted_within'])) { + $days = (int) $filters['posted_within']; + $query->where(function ($q) use ($days) { + $q->where('posted_at', '>=', now()->subDays($days)) + ->orWhere(function ($q2) use ($days) { + $q2->whereNull('posted_at') + ->where('created_at', '>=', now()->subDays($days)); + }); + }); + } + + // Source filter + if (!empty($filters['source_id'])) { + $query->where('job_source_id', $filters['source_id']); + } + + // Company filter + if (!empty($filters['company'])) { + $query->where('company', 'LIKE', "%{$filters['company']}%"); + } + } + + /** + * Apply sorting to the query + */ + private function applySorting(Builder $query, string $sort): void + { + match ($sort) { + 'date' => $query->latest('posted_at'), + 'date_asc' => $query->oldest('posted_at'), + 'salary_desc' => $query->orderBy('salary_max', 'desc')->orderBy('salary_min', 'desc'), + 'salary_asc' => $query->orderBy('salary_min', 'asc')->orderBy('salary_max', 'asc'), + 'title' => $query->orderBy('title', 'asc'), + 'company' => $query->orderBy('company', 'asc'), + 'relevance' => $query->latest('created_at'), // Default to newest if no full-text score + default => $query->latest('posted_at'), + }; + } + + /** + * Get autocomplete suggestions for job titles + */ + public function getTitleSuggestions(string $query, int $limit = 10): array + { + return Job::select('title') + ->where('title', 'LIKE', "{$query}%") + ->active() + ->groupBy('title') + ->limit($limit) + ->pluck('title') + ->toArray(); + } + + /** + * Get autocomplete suggestions for companies + */ + public function getCompanySuggestions(string $query, int $limit = 10): array + { + return Job::select('company') + ->where('company', 'LIKE', "{$query}%") + ->active() + ->groupBy('company') + ->limit($limit) + ->pluck('company') + ->toArray(); + } + + /** + * Get autocomplete suggestions for locations + */ + public function getLocationSuggestions(string $query, int $limit = 10): array + { + return Job::select('location') + ->where('location', 'LIKE', "{$query}%") + ->whereNotNull('location') + ->active() + ->groupBy('location') + ->limit($limit) + ->pluck('location') + ->toArray(); + } + + /** + * Get available filter options + */ + public function getFilterOptions(): array + { + return [ + 'job_types' => [ + 'full-time' => 'Full-time', + 'part-time' => 'Part-time', + 'contract' => 'Contract', + 'temporary' => 'Temporary', + 'internship' => 'Internship', + 'freelance' => 'Freelance', + ], + 'experience_levels' => [ + 'entry' => 'Entry Level', + 'mid' => 'Mid Level', + 'senior' => 'Senior Level', + 'lead' => 'Lead', + 'executive' => 'Executive', + ], + 'posted_within' => [ + 1 => 'Last 24 hours', + 7 => 'Last 7 days', + 30 => 'Last 30 days', + 90 => 'Last 3 months', + ], + 'sort_options' => [ + 'date' => 'Most Recent', + 'date_asc' => 'Oldest First', + 'salary_desc' => 'Highest Salary', + 'salary_asc' => 'Lowest Salary', + 'title' => 'Title A-Z', + 'company' => 'Company A-Z', + ], + ]; + } +} diff --git a/database/seeders/JobCategorySeeder.php b/database/seeders/JobCategorySeeder.php new file mode 100644 index 0000000..80a687a --- /dev/null +++ b/database/seeders/JobCategorySeeder.php @@ -0,0 +1,117 @@ + 'Software Development', + 'slug' => 'software-development', + 'description' => 'Software engineering, web development, mobile development, and programming jobs', + 'icon' => 'code', + ], + [ + 'name' => 'Data Science', + 'slug' => 'data-science', + 'description' => 'Data analysis, machine learning, AI, and analytics positions', + 'icon' => 'chart-bar', + ], + [ + 'name' => 'Design', + 'slug' => 'design', + 'description' => 'UX/UI design, graphic design, product design, and creative roles', + 'icon' => 'palette', + ], + [ + 'name' => 'Marketing', + 'slug' => 'marketing', + 'description' => 'Digital marketing, content marketing, SEO, and growth positions', + 'icon' => 'megaphone', + ], + [ + 'name' => 'Sales', + 'slug' => 'sales', + 'description' => 'Sales representatives, account executives, and business development roles', + 'icon' => 'trending-up', + ], + [ + 'name' => 'Customer Support', + 'slug' => 'customer-support', + 'description' => 'Customer service, technical support, and customer success positions', + 'icon' => 'headset', + ], + [ + 'name' => 'Human Resources', + 'slug' => 'human-resources', + 'description' => 'HR, recruiting, talent acquisition, and people operations', + 'icon' => 'users', + ], + [ + 'name' => 'Finance', + 'slug' => 'finance', + 'description' => 'Accounting, financial analysis, and finance management roles', + 'icon' => 'dollar-sign', + ], + [ + 'name' => 'Operations', + 'slug' => 'operations', + 'description' => 'Operations management, logistics, and project management', + 'icon' => 'settings', + ], + [ + 'name' => 'DevOps', + 'slug' => 'devops', + 'description' => 'Infrastructure, cloud engineering, SRE, and DevOps positions', + 'icon' => 'server', + ], + [ + 'name' => 'Product Management', + 'slug' => 'product-management', + 'description' => 'Product managers, product owners, and product leads', + 'icon' => 'package', + ], + [ + 'name' => 'Quality Assurance', + 'slug' => 'quality-assurance', + 'description' => 'QA engineers, testers, and quality assurance specialists', + 'icon' => 'check-circle', + ], + [ + 'name' => 'Writing', + 'slug' => 'writing', + 'description' => 'Content writers, copywriters, technical writers, and editors', + 'icon' => 'pen', + ], + [ + 'name' => 'Legal', + 'slug' => 'legal', + 'description' => 'Legal counsel, paralegals, and compliance positions', + 'icon' => 'scale', + ], + [ + 'name' => 'Healthcare', + 'slug' => 'healthcare', + 'description' => 'Healthcare, medical, and health-related positions', + 'icon' => 'heart', + ], + ]; + + foreach ($categories as $category) { + JobCategory::updateOrCreate( + ['slug' => $category['slug']], + $category + ); + } + + $this->command->info('Job categories seeded successfully!'); + } +}