diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..5d3fd05 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,520 @@ +# Job Aggregator Platform - Implementation Plan + +## Project Overview +Build a comprehensive job aggregator platform using Laravel 13 that fetches, stores, and displays job listings from multiple free job APIs. + +--- + +## Phase 1: Database Design & Setup + +### 1.1 Core Database Tables +- **jobs** - Main job listings table + - id, external_id, title, company, description, location, salary_min, salary_max, job_type (full-time, part-time, contract, etc.) + - source (API provider name), source_url, apply_url, posted_at, expires_at + - remote_allowed, experience_level, category + - timestamps, soft deletes + +- **job_sources** - API providers configuration + - id, name, api_url, api_key (encrypted), is_active, rate_limit, last_fetched_at + - configuration (JSON for API-specific settings) + +- **job_categories** - Job categories/industries + - id, name, slug, description, icon + +- **job_locations** - Normalized locations + - id, city, state, country, country_code, latitude, longitude + +- **user_saved_jobs** - User bookmarks (if implementing user features) + - user_id, job_id, timestamps + +- **user_job_alerts** - Job alerts/notifications + - user_id, keywords, location, category, frequency + +### 1.2 Migrations +Create migrations for all tables with proper indexes and foreign keys + +--- + +## Phase 2: API Integration Layer + +### 2.1 Job API Sources to Integrate +Research and integrate free job APIs: +- **Adzuna API** - Free tier available with registration +- **The Muse API** - Free, no key required +- **GitHub Jobs API** (deprecated but alternatives exist) +- **Remotive API** - Remote jobs, free access +- **Indeed API** - May require partnership +- **USAJobs API** - US government jobs +- **Reed.co.uk API** - UK jobs (requires registration) + +### 2.2 API Client Architecture +- Create abstract `JobApiClient` base class +- Implement concrete client for each API source: + - `AdzunaClient` + - `MuseClient` + - `RemotiveClient` + - etc. + +### 2.3 Data Transformation Layer +- Create `JobTransformer` interface +- Implement transformers for each API to normalize data into common format +- Handle different date formats, salary formats, location formats +- Sanitize and validate incoming data + +### 2.4 HTTP Client Setup +- Use Laravel HTTP client with retry logic +- Implement rate limiting per API +- Add request/response logging +- Handle API errors gracefully + +--- + +## Phase 3: Job Fetching System + +### 3.1 Console Commands +- `php artisan jobs:fetch {source?}` - Fetch jobs from all or specific source +- `php artisan jobs:fetch-all` - Fetch from all active sources +- `php artisan jobs:clean` - Remove expired/old jobs +- `php artisan jobs:deduplicate` - Remove duplicate jobs across sources + +### 3.2 Queue Jobs +- `FetchJobsFromSource` - Queue job to fetch from specific API +- `ProcessJobListing` - Process and store individual job +- `DeduplicateJobs` - Background job deduplication +- `NotifyUserJobAlerts` - Send alerts to users + +### 3.3 Scheduling +Set up Laravel scheduler for automated fetching: +- Hourly fetches from fast-updating sources +- Daily fetches from slower sources +- Daily cleanup of expired jobs +- Weekly deduplication runs + +### 3.4 Deduplication Logic +- Create job fingerprinting algorithm (hash of title + company + location) +- Implement similarity matching for near-duplicates +- Prefer jobs from higher-quality sources +- Keep audit trail of merged/deduplicated jobs + +--- + +## Phase 4: Business Logic Layer + +### 4.1 Repositories +- `JobRepository` - CRUD and query operations for jobs +- `JobSourceRepository` - Manage API sources +- `CategoryRepository` - Category management + +### 4.2 Services +- `JobAggregatorService` - Orchestrate fetching from multiple sources +- `JobSearchService` - Advanced search with filters +- `JobDeduplicationService` - Handle duplicate detection +- `JobEnrichmentService` - Enhance job data (geocoding, categorization) + +### 4.3 Search & Filter Features +- Full-text search (Laravel Scout with Meilisearch/Algolia or database) +- Filter by: + - Location (city, state, country, remote) + - Job type (full-time, part-time, contract) + - Experience level + - Salary range + - Date posted + - Company + - Category/industry +- Sort by: relevance, date, salary + +### 4.4 Job Enrichment +- Geocoding service for location standardization +- Auto-categorization using keywords +- Salary normalization across different formats +- Company logo fetching (Clearbit API, Brandfetch) + +--- + +## Phase 5: API & Controllers + +### 5.1 API Endpoints +RESTful API for job listings: +``` +GET /api/jobs - List jobs with pagination & filters +GET /api/jobs/{id} - Single job details +GET /api/jobs/search - Advanced search +GET /api/categories - List categories +GET /api/locations - List locations (autocomplete) +GET /api/stats - Platform statistics +POST /api/jobs/{id}/apply - Track apply clicks +POST /api/jobs/{id}/save - Save job (authenticated) +DELETE /api/jobs/{id}/save - Unsave job +``` + +### 5.2 Web Controllers +- `JobController` - Display jobs, search, details +- `HomeController` - Homepage with featured/recent jobs +- `CategoryController` - Jobs by category +- `LocationController` - Jobs by location +- `DashboardController` - User dashboard (saved jobs, alerts) + +### 5.3 Admin Panel +- Dashboard with statistics +- Manage job sources (enable/disable, configure) +- Manual job moderation/removal +- View fetch logs and errors +- System health monitoring + +--- + +## Phase 6: Frontend Development + +### 6.1 Public Pages +- **Homepage** + - Hero search bar + - Featured categories + - Latest jobs + - Statistics counter + +- **Job Listings Page** + - Advanced filters sidebar + - Search bar + - Job cards with key info + - Infinite scroll or pagination + +- **Job Details Page** + - Full job description + - Apply button (external link) + - Similar jobs + - Share buttons + +- **Search Results Page** + - Results with highlighting + - Saved search functionality + +- **Category/Location Pages** + - Browse by category + - Browse by location + - Related categories/locations + +### 6.2 User Features (Optional) +- Authentication (Laravel Breeze/Jetstream) +- User dashboard +- Save/bookmark jobs +- Job alerts setup +- Application tracking + +### 6.3 UI/UX Components +- Responsive design (mobile-first) +- Job card component +- Filter components +- Search autocomplete +- Loading states +- Empty states +- Error handling + +### 6.4 Frontend Stack +- Blade templates with Alpine.js (lightweight) +- OR Inertia.js + Vue/React (SPA experience) +- TailwindCSS for styling +- Vite for asset bundling + +--- + +## Phase 7: Monitoring & Analytics + +### 7.1 Logging +- API fetch success/failure logs +- Job processing errors +- User activity logs +- Performance metrics + +### 7.2 Metrics & Monitoring +- Track jobs fetched per source +- API response times +- Queue processing times +- Database query performance +- User engagement metrics + +### 7.3 Error Handling +- Graceful API failure handling +- Retry mechanisms with exponential backoff +- Admin notifications for critical failures +- Error reporting (Sentry/Bugsnag) + +### 7.4 Analytics +- Track popular searches +- Most viewed jobs +- Click-through rates +- Geographic distribution +- Source effectiveness + +--- + +## Phase 8: Performance Optimization + +### 8.1 Caching Strategy +- Cache job listings (Redis/Memcached) +- Cache search results (5-15 minutes) +- Cache categories and locations +- Cache statistics/counters +- HTTP cache headers for static content + +### 8.2 Database Optimization +- Proper indexing (title, location, posted_at, category) +- Full-text indexes for search +- Query optimization +- Connection pooling +- Read replicas for heavy reads + +### 8.3 Queue Optimization +- Separate queues for priority tasks +- Queue workers tuning +- Failed job handling +- Queue monitoring + +### 8.4 Asset Optimization +- Image optimization and lazy loading +- CSS/JS minification +- CDN for static assets +- Gzip compression + +--- + +## Phase 9: SEO & Marketing + +### 9.1 SEO Optimization +- Dynamic meta tags for job pages +- Structured data (JSON-LD) for jobs +- XML sitemap generation +- Robots.txt optimization +- Canonical URLs +- Open Graph tags +- Fast page loads (Core Web Vitals) + +### 9.2 Content Strategy +- Job landing pages per category +- Location-specific landing pages +- Career advice blog (optional) +- Company profiles + +### 9.3 Social Sharing +- Share job buttons (LinkedIn, Twitter, Facebook) +- Auto-posting to social media (optional) +- Email sharing functionality + +--- + +## Phase 10: Testing & Quality Assurance + +### 10.1 Testing Strategy +- **Unit Tests** + - API client tests + - Transformer tests + - Service layer tests + - Repository tests + +- **Feature Tests** + - Job fetching workflow + - Search and filter functionality + - User authentication flow + - Job saving/bookmarking + +- **Integration Tests** + - API endpoint tests + - Database operations + - Queue processing + +- **Browser Tests (Dusk)** + - Critical user flows + - Search and apply workflow + - Responsive design testing + +### 10.2 Quality Checks +- Code style (Laravel Pint) +- Static analysis (PHPStan/Larastan) +- Security scanning +- Performance profiling + +--- + +## Phase 11: Deployment & DevOps + +### 11.1 Infrastructure +- Production server setup (VPS/Cloud) +- Database server (MySQL/PostgreSQL) +- Redis server for cache and queues +- SSL certificate (Let's Encrypt) +- CDN setup (Cloudflare/AWS CloudFront) + +### 11.2 CI/CD Pipeline +- GitHub Actions or GitLab CI +- Automated testing on push +- Automated deployment to staging +- Manual deployment to production +- Database migration automation + +### 11.3 Environment Configuration +- Environment-specific .env files +- Secrets management +- API key rotation strategy +- Backup strategy + +### 11.4 Monitoring & Maintenance +- Server monitoring (uptime, CPU, memory) +- Application monitoring (Laravel Telescope/Horizon) +- Log aggregation (ELK stack or cloud service) +- Automated backups +- Incident response plan + +--- + +## Phase 12: Launch & Post-Launch + +### 12.1 Pre-Launch Checklist +- [ ] All APIs configured and tested +- [ ] Database migrations completed +- [ ] Initial job data seeded +- [ ] Search functionality tested +- [ ] Mobile responsiveness verified +- [ ] SEO optimizations in place +- [ ] Analytics configured +- [ ] Error monitoring active +- [ ] Backup system tested +- [ ] SSL certificate installed + +### 12.2 Launch Tasks +- Initial data population (run fetchers) +- Submit sitemap to search engines +- Social media announcement +- Monitor for errors +- Performance monitoring + +### 12.3 Post-Launch Iterations +- Monitor user feedback +- Analyze usage patterns +- Add more job sources +- Improve search relevance +- Add requested features +- Performance optimization based on real data + +--- + +## Technical Stack Summary + +**Backend:** +- Laravel 13 (PHP 8.3) +- MySQL/PostgreSQL for primary database +- Redis for cache and queues +- Laravel Horizon for queue monitoring +- Laravel Scout for search (with Meilisearch/Algolia) + +**Frontend:** +- Blade templates + Alpine.js OR Inertia.js + Vue/React +- TailwindCSS +- Vite + +**DevOps:** +- Docker for development (Laravel Sail) +- GitHub Actions for CI/CD +- Server: Ubuntu 24.04 LTS +- Web Server: Nginx +- Process Manager: Supervisor (for queues) + +**Third-party Services:** +- Job APIs (multiple sources) +- Geocoding API (Google Maps/OpenStreetMap) +- Email service (SendGrid/Mailgun/SES) +- Error tracking (Sentry) +- Analytics (Google Analytics/Plausible) + +--- + +## Development Timeline (Estimated) + +- **Week 1-2:** Database design, migrations, seeders +- **Week 2-3:** API integration layer (3-5 sources) +- **Week 3-4:** Job fetching system, queue jobs, commands +- **Week 4-5:** Business logic, services, repositories +- **Week 5-6:** API endpoints and web controllers +- **Week 6-8:** Frontend development (all pages) +- **Week 8-9:** Search optimization, caching, performance +- **Week 9-10:** Testing, bug fixes, SEO +- **Week 10-11:** Deployment, monitoring, documentation +- **Week 11-12:** Beta testing, refinements, launch + +**Total: ~12 weeks for MVP** + +--- + +## Success Metrics + +- **Technical Metrics:** + - Jobs fetched per day: 1,000+ + - API success rate: >95% + - Search response time: <200ms + - Page load time: <2s + - Uptime: >99.5% + +- **Business Metrics:** + - Daily active users + - Job views per day + - Apply click-through rate + - User retention rate + - SEO rankings for target keywords + +--- + +## Future Enhancements + +- **Phase 2 Features:** + - Employer job posting (paid feature) + - Premium job listings + - Resume builder + - Application tracking system + - Company reviews + - Salary insights + - Job recommendations (ML-based) + - Mobile app (iOS/Android) + - API for third-party access + - Browser extension + - Slack/Discord bot integration + +- **Monetization Options:** + - Featured job listings + - Employer accounts + - Affiliate commissions + - Premium user features + - Job board white-labeling + - Sponsored content + +--- + +## Resources & Documentation + +- **Laravel Documentation:** https://laravel.com/docs/13.x +- **Job API Documentation:** + - Adzuna: https://developer.adzuna.com/ + - The Muse: https://www.themuse.com/developers/api/v2 + - Remotive: https://remotive.com/api + - USAJobs: https://developer.usajobs.gov/ + +- **Tools & Libraries:** + - Laravel Horizon: Queue monitoring + - Laravel Scout: Full-text search + - Laravel Telescope: Debugging + - Spatie packages: Various Laravel utilities + - GuzzleHTTP: HTTP requests + +--- + +## Notes + +- Start with 3-5 reliable free APIs, expand later +- Focus on data quality over quantity +- Implement robust error handling from day one +- Keep job data fresh (remove outdated listings) +- Consider GDPR/privacy regulations for user data +- Plan for scaling from the beginning +- Document all API integrations thoroughly + +--- + +**Last Updated:** April 2, 2026 +**Version:** 1.0 diff --git a/app/Console/Commands/TestJobApis.php b/app/Console/Commands/TestJobApis.php new file mode 100644 index 0000000..e17128a --- /dev/null +++ b/app/Console/Commands/TestJobApis.php @@ -0,0 +1,110 @@ +argument('source'); + $limit = (int) $this->option('limit'); + + $this->info('Testing Job API Integrations...'); + $this->newLine(); + + try { + if ($sourceName) { + // Test specific source + $this->testSingleSource($sourceName, $limit); + } else { + // Test all active sources + $this->testAllSources($limit); + } + + return self::SUCCESS; + + } catch (\Exception $e) { + $this->error("Failed to test APIs: {$e->getMessage()}"); + return self::FAILURE; + } + } + + private function testAllSources(int $limit): void + { + $clients = JobApiClientFactory::makeAll(); + + if (empty($clients)) { + $this->warn('No active job sources found. Run: php artisan db:seed --class=JobSourceSeeder'); + return; + } + + $this->info('Testing ' . count($clients) . ' active job sources...'); + $this->newLine(); + + foreach ($clients as $client) { + $this->testClient($client, $limit); + $this->newLine(); + } + } + + private function testSingleSource(string $sourceName, int $limit): void + { + try { + $client = JobApiClientFactory::makeByName($sourceName); + $this->testClient($client, $limit); + } catch (ApiException $e) { + $this->error("Failed to create client for '{$sourceName}': {$e->getMessage()}"); + } + } + + private function testClient($client, int $limit): void + { + $sourceName = $client->getSourceName(); + + $this->line("Testing: {$sourceName}"); + + try { + // Fetch sample jobs + $jobs = $client->fetchJobs(['limit' => $limit]); + + if (empty($jobs)) { + $this->warn(" No jobs returned from {$sourceName}"); + return; + } + + $this->info(" ✓ Successfully fetched " . count($jobs) . " jobs"); + + // Display sample jobs + $this->table( + ['ID', 'Title', 'Company', 'Location', 'Remote'], + collect($jobs)->take(3)->map(fn($job) => [ + substr($job->externalId, 0, 10) . '...', + substr($job->title, 0, 40), + substr($job->company, 0, 20), + substr($job->location ?? 'N/A', 0, 20), + $job->remoteAllowed ? 'Yes' : 'No', + ])->toArray() + ); + + } catch (ApiException $e) { + $this->error(" ✗ API Error: {$e->getMessage()}"); + if ($context = $e->getContext()) { + $this->line(" Context: " . json_encode($context, JSON_PRETTY_PRINT)); + } + } catch (\Exception $e) { + $this->error(" ✗ Unexpected Error: {$e->getMessage()}"); + $this->line(" " . $e->getTraceAsString()); + } + } +} diff --git a/app/Models/Job.php b/app/Models/Job.php new file mode 100644 index 0000000..fdbb241 --- /dev/null +++ b/app/Models/Job.php @@ -0,0 +1,111 @@ + 'datetime', + 'expires_at' => 'datetime', + 'remote_allowed' => 'boolean', + 'salary_min' => 'decimal:2', + 'salary_max' => 'decimal:2', + ]; + + /** + * Get the source that provides this job. + */ + public function source(): BelongsTo + { + return $this->belongsTo(JobSource::class, 'job_source_id'); + } + + /** + * Get the category that this job belongs to. + */ + public function category(): BelongsTo + { + return $this->belongsTo(JobCategory::class, 'job_category_id'); + } + + /** + * Get the location of this job. + */ + public function jobLocation(): BelongsTo + { + return $this->belongsTo(JobLocation::class, 'job_location_id'); + } + + /** + * Get the users who saved this job. + */ + public function savedByUsers(): BelongsToMany + { + return $this->belongsToMany(User::class, 'user_saved_jobs') + ->withTimestamps(); + } + + /** + * Scope a query to only include active jobs. + */ + public function scopeActive($query) + { + return $query->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } + + /** + * Scope a query to only include remote jobs. + */ + public function scopeRemote($query) + { + return $query->where('remote_allowed', true); + } + + /** + * Scope a query to filter by job type. + */ + public function scopeOfType($query, string $type) + { + return $query->where('job_type', $type); + } + + /** + * Scope a query to filter by experience level. + */ + public function scopeExperienceLevel($query, string $level) + { + return $query->where('experience_level', $level); + } +} diff --git a/app/Models/JobCategory.php b/app/Models/JobCategory.php new file mode 100644 index 0000000..41e18dd --- /dev/null +++ b/app/Models/JobCategory.php @@ -0,0 +1,32 @@ +hasMany(Job::class, 'job_category_id'); + } + + /** + * Get the job alerts for this category. + */ + public function jobAlerts(): HasMany + { + return $this->hasMany(UserJobAlert::class, 'job_category_id'); + } +} diff --git a/app/Models/JobLocation.php b/app/Models/JobLocation.php new file mode 100644 index 0000000..ea73854 --- /dev/null +++ b/app/Models/JobLocation.php @@ -0,0 +1,45 @@ + 'decimal:7', + 'longitude' => 'decimal:7', + ]; + + /** + * Get the jobs for this location. + */ + public function jobs(): HasMany + { + return $this->hasMany(Job::class, 'job_location_id'); + } + + /** + * Get the full location string. + */ + public function getFullLocationAttribute(): string + { + $parts = array_filter([ + $this->city, + $this->state, + $this->country, + ]); + + return implode(', ', $parts); + } +} diff --git a/app/Models/JobSource.php b/app/Models/JobSource.php new file mode 100644 index 0000000..e07558a --- /dev/null +++ b/app/Models/JobSource.php @@ -0,0 +1,41 @@ + 'boolean', + 'last_fetched_at' => 'datetime', + 'configuration' => 'array', + ]; + + /** + * Get the jobs from this source. + */ + public function jobs(): HasMany + { + return $this->hasMany(Job::class, 'job_source_id'); + } + + /** + * Scope a query to only include active sources. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..5fd8a7b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,8 @@ use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -29,4 +31,21 @@ class User extends Authenticatable 'password' => 'hashed', ]; } + + /** + * Get the jobs saved by this user. + */ + public function savedJobs(): BelongsToMany + { + return $this->belongsToMany(Job::class, 'user_saved_jobs') + ->withTimestamps(); + } + + /** + * Get the job alerts for this user. + */ + public function jobAlerts(): HasMany + { + return $this->hasMany(UserJobAlert::class); + } } diff --git a/app/Models/UserJobAlert.php b/app/Models/UserJobAlert.php new file mode 100644 index 0000000..936e5bc --- /dev/null +++ b/app/Models/UserJobAlert.php @@ -0,0 +1,48 @@ + 'boolean', + 'last_sent_at' => 'datetime', + ]; + + /** + * Get the user who created the alert. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the category for this alert. + */ + public function category(): BelongsTo + { + return $this->belongsTo(JobCategory::class, 'job_category_id'); + } + + /** + * Scope a query to only include active alerts. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/app/Models/UserSavedJob.php b/app/Models/UserSavedJob.php new file mode 100644 index 0000000..95fdcf0 --- /dev/null +++ b/app/Models/UserSavedJob.php @@ -0,0 +1,30 @@ +belongsTo(User::class); + } + + /** + * Get the saved job. + */ + public function job(): BelongsTo + { + return $this->belongsTo(Job::class); + } +} diff --git a/app/Services/JobApi/Clients/AdzunaClient.php b/app/Services/JobApi/Clients/AdzunaClient.php new file mode 100644 index 0000000..485b527 --- /dev/null +++ b/app/Services/JobApi/Clients/AdzunaClient.php @@ -0,0 +1,176 @@ +source->api_key && str_contains($this->source->api_key, ':')) { + [$this->appId, $this->appKey] = explode(':', $this->source->api_key, 2); + } else { + throw new ApiException( + 'Adzuna API key must be in format "app_id:app_key"', + $this->source->name + ); + } + + // Extract country from configuration if set + if (!empty($this->source->configuration['country'])) { + $this->country = $this->source->configuration['country']; + } + + return $client; + } + + /** + * Fetch jobs from Adzuna API + * + * @param array $params [ + * 'what' => string, // Job title or keywords + * 'where' => string, // Location + * 'results_per_page' => int, + * 'page' => int, + * 'category' => string, + * 'full_time' => int, // 1 for full-time only + * 'part_time' => int, // 1 for part-time only + * 'contract' => int, // 1 for contract only + * 'max_days_old' => int, // Jobs posted within last N days + * ] + * @return array + */ + public function fetchJobs(array $params = []): array + { + try { + $queryParams = $this->buildQueryParams($params); + + // Adzuna API endpoint structure: /v1/api/jobs/{country}/search/{page} + $page = $queryParams['page'] ?? 1; + unset($queryParams['page']); + + $endpoint = "search/{$page}"; + + $response = $this->makeRequest('GET', $endpoint, $queryParams); + + $data = $response->json(); + + if (!isset($data['results']) || !is_array($data['results'])) { + throw new ApiException( + 'Invalid response structure from Adzuna API', + $this->source->name, + ['response' => $data] + ); + } + + $this->updateLastFetched(); + + return $this->transformer->transformMany($data['results']); + + } catch (ApiException $e) { + throw $e; + } catch (\Throwable $e) { + throw new ApiException( + 'Failed to fetch jobs from Adzuna', + $this->source->name, + ['error' => $e->getMessage()], + 0, + $e + ); + } + } + + /** + * Build query parameters for the API request + */ + private function buildQueryParams(array $params): array + { + $queryParams = [ + 'app_id' => $this->appId, + 'app_key' => $this->appKey, + 'results_per_page' => min($params['results_per_page'] ?? 20, 50), + 'page' => $params['page'] ?? 1, + ]; + + if (!empty($params['what'])) { + $queryParams['what'] = $params['what']; + } + + if (!empty($params['where'])) { + $queryParams['where'] = $params['where']; + } + + if (isset($params['category'])) { + $queryParams['category'] = $params['category']; + } + + if (isset($params['full_time'])) { + $queryParams['full_time'] = (int) $params['full_time']; + } + + if (isset($params['part_time'])) { + $queryParams['part_time'] = (int) $params['part_time']; + } + + if (isset($params['contract'])) { + $queryParams['contract'] = (int) $params['contract']; + } + + if (isset($params['max_days_old'])) { + $queryParams['max_days_old'] = (int) $params['max_days_old']; + } + + return $queryParams; + } + + /** + * Override buildUrl to include country in path + */ + protected function buildUrl(string $endpoint): string + { + $baseUrl = rtrim($this->source->api_url, '/'); + $endpoint = ltrim($endpoint, '/'); + return "{$baseUrl}/{$this->country}/{$endpoint}"; + } + + /** + * Get supported countries + */ + public function getSupportedCountries(): array + { + return [ + 'au' => 'Australia', + 'at' => 'Austria', + 'br' => 'Brazil', + 'ca' => 'Canada', + 'fr' => 'France', + 'de' => 'Germany', + 'in' => 'India', + 'it' => 'Italy', + 'nl' => 'Netherlands', + 'nz' => 'New Zealand', + 'pl' => 'Poland', + 'ru' => 'Russia', + 'sg' => 'Singapore', + 'za' => 'South Africa', + 'ch' => 'Switzerland', + 'gb' => 'United Kingdom', + 'us' => 'United States', + ]; + } +} diff --git a/app/Services/JobApi/Clients/JobApiClient.php b/app/Services/JobApi/Clients/JobApiClient.php new file mode 100644 index 0000000..8a86252 --- /dev/null +++ b/app/Services/JobApi/Clients/JobApiClient.php @@ -0,0 +1,186 @@ +client = $this->buildHttpClient(); + $this->transformer = $this->getTransformer(); + } + + /** + * Build the HTTP client with base configuration + */ + protected function buildHttpClient(): PendingRequest + { + $client = Http::timeout(30) + ->retry($this->maxRetries, $this->retryDelay, function ($exception, $request) { + // Retry on server errors (5xx) but not on client errors (4xx) + if ($exception instanceof \Illuminate\Http\Client\RequestException) { + $statusCode = $exception->response?->status(); + return $statusCode >= 500 && $statusCode < 600; + } + return false; + }) + ->withHeaders($this->getHeaders()); + + if ($this->source->api_key) { + $client = $this->attachAuthentication($client); + } + + return $client; + } + + /** + * Get the transformer instance for this API + */ + abstract protected function getTransformer(): JobTransformer; + + /** + * Get default headers for API requests + */ + protected function getHeaders(): array + { + return [ + 'Accept' => 'application/json', + 'User-Agent' => 'JobifyGarden/1.0', + ]; + } + + /** + * Attach authentication to the HTTP client + */ + abstract protected function attachAuthentication(PendingRequest $client): PendingRequest; + + /** + * Fetch jobs from the API + * + * @param array $params Query parameters (keywords, location, page, etc.) + * @return array Array of standardized job data + */ + abstract public function fetchJobs(array $params = []): array; + + /** + * Make an API request with error handling and logging + */ + protected function makeRequest(string $method, string $endpoint, array $params = []): Response + { + $url = $this->buildUrl($endpoint); + + Log::info("API Request: {$this->source->name}", [ + 'method' => $method, + 'url' => $url, + 'params' => $params, + ]); + + try { + $response = match (strtoupper($method)) { + 'GET' => $this->client->get($url, $params), + 'POST' => $this->client->post($url, $params), + default => throw new ApiException( + "Unsupported HTTP method: {$method}", + $this->source->name + ), + }; + + if ($response->failed()) { + $this->handleFailedResponse($response); + } + + Log::info("API Response: {$this->source->name}", [ + 'status' => $response->status(), + 'url' => $url, + ]); + + return $response; + + } catch (\Illuminate\Http\Client\ConnectionException $e) { + throw new ApiException( + "Connection failed to {$this->source->name}", + $this->source->name, + ['error' => $e->getMessage()], + 0, + $e + ); + } + } + + /** + * Build full URL from endpoint + */ + protected function buildUrl(string $endpoint): string + { + $baseUrl = rtrim($this->source->api_url, '/'); + $endpoint = ltrim($endpoint, '/'); + return "{$baseUrl}/{$endpoint}"; + } + + /** + * Handle failed API responses + */ + protected function handleFailedResponse(Response $response): void + { + $statusCode = $response->status(); + + if ($statusCode === 429) { + $retryAfter = $response->header('Retry-After'); + throw new RateLimitException( + $this->source->name, + $retryAfter ? (int) $retryAfter : null, + ['response' => $response->body()] + ); + } + + throw new ApiException( + "API request failed with status {$statusCode}", + $this->source->name, + [ + 'status' => $statusCode, + 'response' => $response->body(), + ], + $statusCode + ); + } + + /** + * Get the source name + */ + public function getSourceName(): string + { + return $this->source->name; + } + + /** + * Check if the API is available + */ + public function isAvailable(): bool + { + return $this->source->is_active; + } + + /** + * Update the last fetched timestamp + */ + protected function updateLastFetched(): void + { + $this->source->update(['last_fetched_at' => now()]); + } +} diff --git a/app/Services/JobApi/Clients/RemotiveClient.php b/app/Services/JobApi/Clients/RemotiveClient.php new file mode 100644 index 0000000..a29189a --- /dev/null +++ b/app/Services/JobApi/Clients/RemotiveClient.php @@ -0,0 +1,119 @@ + string, // Job title keywords + * 'company_name' => string, + * 'category' => string, // e.g., 'software-dev', 'customer-support' + * 'limit' => int, // Number of results (default 50) + * ] + * @return array + */ + public function fetchJobs(array $params = []): array + { + try { + $queryParams = $this->buildQueryParams($params); + + $response = $this->makeRequest('GET', 'remote-jobs', $queryParams); + + $data = $response->json(); + + if (!isset($data['jobs']) || !is_array($data['jobs'])) { + throw new ApiException( + 'Invalid response structure from Remotive API', + $this->source->name, + ['response' => $data] + ); + } + + $this->updateLastFetched(); + + return $this->transformer->transformMany($data['jobs']); + + } catch (ApiException $e) { + throw $e; + } catch (\Throwable $e) { + throw new ApiException( + 'Failed to fetch jobs from Remotive', + $this->source->name, + ['error' => $e->getMessage()], + 0, + $e + ); + } + } + + /** + * Build query parameters for the API request + */ + private function buildQueryParams(array $params): array + { + $queryParams = []; + + if (!empty($params['search'])) { + $queryParams['search'] = $params['search']; + } + + if (!empty($params['company_name'])) { + $queryParams['company_name'] = $params['company_name']; + } + + if (!empty($params['category'])) { + $queryParams['category'] = $params['category']; + } + + if (!empty($params['limit'])) { + $queryParams['limit'] = min((int) $params['limit'], 100); + } + + return $queryParams; + } + + /** + * Get available job categories from Remotive + */ + public function getCategories(): array + { + // Remotive categories (as of API documentation) + return [ + 'software-dev', + 'customer-support', + 'design', + 'marketing', + 'sales', + 'product', + 'business', + 'data', + 'devops', + 'finance', + 'hr', + 'legal', + 'operations', + 'qa', + 'writing', + ]; + } +} diff --git a/app/Services/JobApi/Clients/TheMuseClient.php b/app/Services/JobApi/Clients/TheMuseClient.php new file mode 100644 index 0000000..b0690d8 --- /dev/null +++ b/app/Services/JobApi/Clients/TheMuseClient.php @@ -0,0 +1,145 @@ +source->api_key) { + $client = $client->withQueryParameters(['api_key' => $this->source->api_key]); + } + + return $client; + } + + /** + * Fetch jobs from The Muse API + * + * @param array $params [ + * 'page' => int, // Page number (default 0) + * 'descending' => bool, // Sort order (default true) + * 'category' => string, // Job category + * 'level' => string, // e.g., 'Entry Level', 'Mid Level', 'Senior Level' + * 'location' => string, // Location filter + * 'company' => string, // Company name or ID + * ] + * @return array + */ + public function fetchJobs(array $params = []): array + { + try { + $queryParams = $this->buildQueryParams($params); + + $response = $this->makeRequest('GET', 'jobs', $queryParams); + + $data = $response->json(); + + if (!isset($data['results']) || !is_array($data['results'])) { + throw new ApiException( + 'Invalid response structure from The Muse API', + $this->source->name, + ['response' => $data] + ); + } + + $this->updateLastFetched(); + + return $this->transformer->transformMany($data['results']); + + } catch (ApiException $e) { + throw $e; + } catch (\Throwable $e) { + throw new ApiException( + 'Failed to fetch jobs from The Muse', + $this->source->name, + ['error' => $e->getMessage()], + 0, + $e + ); + } + } + + /** + * Build query parameters for the API request + */ + private function buildQueryParams(array $params): array + { + $queryParams = [ + 'page' => $params['page'] ?? 0, + 'descending' => $params['descending'] ?? true, + ]; + + if (!empty($params['category'])) { + $queryParams['category'] = $params['category']; + } + + if (!empty($params['level'])) { + $queryParams['level'] = $params['level']; + } + + if (!empty($params['location'])) { + $queryParams['location'] = $params['location']; + } + + if (!empty($params['company'])) { + $queryParams['company'] = $params['company']; + } + + return $queryParams; + } + + /** + * Get available job categories from The Muse + */ + public function getCategories(): array + { + return [ + 'Account Management', + 'Business & Strategy', + 'Creative & Design', + 'Customer Service', + 'Data Science', + 'Editorial', + 'Education', + 'Engineering', + 'Finance', + 'Healthcare & Medicine', + 'HR & Recruiting', + 'Legal', + 'Marketing & PR', + 'Operations', + 'Project & Product Management', + 'Retail', + 'Sales', + 'Social Media & Community', + ]; + } + + /** + * Get available experience levels + */ + public function getLevels(): array + { + return [ + 'Internship', + 'Entry Level', + 'Mid Level', + 'Senior Level', + 'Executive', + ]; + } +} diff --git a/app/Services/JobApi/DTO/JobData.php b/app/Services/JobApi/DTO/JobData.php new file mode 100644 index 0000000..fc4e6e2 --- /dev/null +++ b/app/Services/JobApi/DTO/JobData.php @@ -0,0 +1,61 @@ + $this->externalId, + 'title' => $this->title, + 'company' => $this->company, + 'description' => $this->description, + 'location' => $this->location, + 'salary_min' => $this->salaryMin, + 'salary_max' => $this->salaryMax, + 'salary_currency' => $this->salaryCurrency, + 'job_type' => $this->jobType, + 'source_url' => $this->sourceUrl, + 'apply_url' => $this->applyUrl, + 'posted_at' => $this->postedAt, + 'expires_at' => $this->expiresAt, + 'remote_allowed' => $this->remoteAllowed, + 'experience_level' => $this->experienceLevel, + ]; + } + + /** + * Generate a fingerprint for deduplication + */ + public function generateFingerprint(): string + { + $data = strtolower(trim($this->title . '|' . $this->company . '|' . $this->location)); + return hash('sha256', $data); + } +} diff --git a/app/Services/JobApi/Exceptions/ApiException.php b/app/Services/JobApi/Exceptions/ApiException.php new file mode 100644 index 0000000..dd423be --- /dev/null +++ b/app/Services/JobApi/Exceptions/ApiException.php @@ -0,0 +1,27 @@ + $this->apiSource, + 'message' => $this->getMessage(), + 'code' => $this->getCode(), + ], $this->context ?? []); + } +} diff --git a/app/Services/JobApi/Exceptions/RateLimitException.php b/app/Services/JobApi/Exceptions/RateLimitException.php new file mode 100644 index 0000000..db7ff93 --- /dev/null +++ b/app/Services/JobApi/Exceptions/RateLimitException.php @@ -0,0 +1,19 @@ +retryAfter) { + $message .= ". Retry after {$this->retryAfter} seconds"; + } + + parent::__construct($message, $apiSource, $context, 429); + } +} diff --git a/app/Services/JobApi/Exceptions/TransformationException.php b/app/Services/JobApi/Exceptions/TransformationException.php new file mode 100644 index 0000000..581b35a --- /dev/null +++ b/app/Services/JobApi/Exceptions/TransformationException.php @@ -0,0 +1,14 @@ + RemotiveClient::class, + 'the-muse' => TheMuseClient::class, + 'adzuna' => AdzunaClient::class, + ]; + + /** + * Create an API client for the given source + * + * @param JobSource $source + * @return JobApiClient + * @throws ApiException + */ + public static function make(JobSource $source): JobApiClient + { + $sourceName = strtolower($source->name); + $normalizedName = self::normalizeSourceName($sourceName); + + if (!isset(self::CLIENT_MAP[$normalizedName])) { + throw new ApiException( + "No client implementation found for source: {$source->name}", + $source->name, + ['available_clients' => array_keys(self::CLIENT_MAP)] + ); + } + + $clientClass = self::CLIENT_MAP[$normalizedName]; + + return new $clientClass($source); + } + + /** + * Create clients for all active sources + * + * @return array + */ + public static function makeAll(): array + { + $sources = JobSource::active()->get(); + + return $sources->map(function (JobSource $source) { + try { + return self::make($source); + } catch (ApiException $e) { + // Log the error but continue with other sources + \Log::warning("Failed to create client for source: {$source->name}", [ + 'error' => $e->getMessage(), + ]); + return null; + } + })->filter()->values()->all(); + } + + /** + * Create a client by source name + * + * @param string $sourceName + * @return JobApiClient + * @throws ApiException + */ + public static function makeByName(string $sourceName): JobApiClient + { + $source = JobSource::where('name', $sourceName) + ->orWhere('name', 'LIKE', "%{$sourceName}%") + ->firstOrFail(); + + return self::make($source); + } + + /** + * Get all available client types + * + * @return array + */ + public static function getAvailableClients(): array + { + return array_keys(self::CLIENT_MAP); + } + + /** + * Normalize source name for lookup + */ + private static function normalizeSourceName(string $name): string + { + // Convert "Remotive", "remotive.io", "Remotive.io" to "remotive" + $name = strtolower($name); + $name = str_replace(['.io', '.com', ' '], '', $name); + + // Handle special cases + $name = match (true) { + str_contains($name, 'muse') => 'the-muse', + str_contains($name, 'remotive') => 'remotive', + str_contains($name, 'adzuna') => 'adzuna', + default => $name, + }; + + return $name; + } +} diff --git a/app/Services/JobApi/Transformers/AdzunaTransformer.php b/app/Services/JobApi/Transformers/AdzunaTransformer.php new file mode 100644 index 0000000..cac2007 --- /dev/null +++ b/app/Services/JobApi/Transformers/AdzunaTransformer.php @@ -0,0 +1,108 @@ +sanitizeDescription($data['description'] ?? ''), + location: $this->parseLocation($data), + salaryMin: $data['salary_min'] ?? null, + salaryMax: $data['salary_max'] ?? null, + salaryCurrency: 'USD', + jobType: $this->mapJobType($data['contract_type'] ?? null), + sourceUrl: $data['redirect_url'] ?? null, + applyUrl: $data['redirect_url'] ?? null, + postedAt: $this->parseDate($data['created'] ?? null), + expiresAt: null, + remoteAllowed: $this->isRemote($data), + experienceLevel: null, + category: $data['category']['label'] ?? null, + rawData: $data, + ); + } catch (\Throwable $e) { + throw new TransformationException( + 'Adzuna', + 'Failed to transform Adzuna job data: ' . $e->getMessage(), + ['data' => $data, 'error' => $e->getMessage()] + ); + } + } + + public function transformMany(array $jobs): array + { + return array_map(fn($job) => $this->transform($job), $jobs); + } + + private function sanitizeDescription(string $description): string + { + $clean = strip_tags($description); + $clean = html_entity_decode($clean, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $clean = preg_replace('/\s+/', ' ', $clean); + return trim($clean); + } + + private function parseLocation(array $data): ?string + { + $location = $data['location']['display_name'] ?? null; + + if (empty($location)) { + // Fallback to area + $area = $data['location']['area'] ?? []; + if (!empty($area)) { + return implode(', ', $area); + } + } + + return $location; + } + + private function isRemote(array $data): bool + { + $title = strtolower($data['title'] ?? ''); + $description = strtolower($data['description'] ?? ''); + $location = strtolower($data['location']['display_name'] ?? ''); + + return str_contains($title, 'remote') || + str_contains($description, 'remote') || + str_contains($location, 'remote'); + } + + private function parseDate(?string $date): ?DateTime + { + if (empty($date)) { + return null; + } + + try { + return new DateTime($date); + } catch (\Exception $e) { + return null; + } + } + + private function mapJobType(?string $type): ?string + { + if (empty($type)) { + return null; + } + + return match (strtolower($type)) { + 'permanent', 'full_time' => 'full-time', + 'part_time' => 'part-time', + 'contract' => 'contract', + 'temporary' => 'temporary', + default => 'full-time', + }; + } +} diff --git a/app/Services/JobApi/Transformers/JobTransformer.php b/app/Services/JobApi/Transformers/JobTransformer.php new file mode 100644 index 0000000..b8dddcb --- /dev/null +++ b/app/Services/JobApi/Transformers/JobTransformer.php @@ -0,0 +1,24 @@ + Array of standardized job data + */ + public function transformMany(array $jobs): array; +} diff --git a/app/Services/JobApi/Transformers/RemotiveTransformer.php b/app/Services/JobApi/Transformers/RemotiveTransformer.php new file mode 100644 index 0000000..773555e --- /dev/null +++ b/app/Services/JobApi/Transformers/RemotiveTransformer.php @@ -0,0 +1,121 @@ +sanitizeDescription($data['description'] ?? ''), + location: $this->parseLocation($data), + salaryMin: $this->parseSalary($data['salary'] ?? null, 'min'), + salaryMax: $this->parseSalary($data['salary'] ?? null, 'max'), + salaryCurrency: 'USD', + jobType: $this->mapJobType($data['job_type'] ?? null), + sourceUrl: $data['url'] ?? null, + applyUrl: $data['url'] ?? null, + postedAt: $this->parseDate($data['publication_date'] ?? null), + expiresAt: null, + remoteAllowed: true, // Remotive only has remote jobs + experienceLevel: $this->mapExperienceLevel($data['candidate_required_location'] ?? null), + category: $data['category'] ?? null, + rawData: $data, + ); + } catch (\Throwable $e) { + throw new TransformationException( + 'Remotive', + 'Failed to transform Remotive job data: ' . $e->getMessage(), + ['data' => $data, 'error' => $e->getMessage()] + ); + } + } + + public function transformMany(array $jobs): array + { + return array_map(fn($job) => $this->transform($job), $jobs); + } + + private function sanitizeDescription(string $description): string + { + // Remove HTML tags and clean up whitespace + $clean = strip_tags($description); + $clean = html_entity_decode($clean, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $clean = preg_replace('/\s+/', ' ', $clean); + return trim($clean); + } + + private function parseLocation(array $data): ?string + { + $location = $data['candidate_required_location'] ?? null; + + if (empty($location)) { + return 'Remote'; + } + + return $location; + } + + private function parseSalary(?string $salary, string $type): ?float + { + if (empty($salary)) { + return null; + } + + // Try to extract numbers from salary string + if (preg_match_all('/[\d,]+/', $salary, $matches)) { + $numbers = array_map(fn($n) => (float) str_replace(',', '', $n), $matches[0]); + + if (count($numbers) >= 2) { + return $type === 'min' ? min($numbers) : max($numbers); + } elseif (count($numbers) === 1) { + return $numbers[0]; + } + } + + return null; + } + + private function parseDate(?string $date): ?DateTime + { + if (empty($date)) { + return null; + } + + try { + return new DateTime($date); + } catch (\Exception $e) { + return null; + } + } + + private function mapJobType(?string $type): ?string + { + if (empty($type)) { + return 'full-time'; + } + + return match (strtolower($type)) { + 'full_time', 'full-time' => 'full-time', + 'part_time', 'part-time' => 'part-time', + 'contract' => 'contract', + 'freelance' => 'freelance', + default => 'full-time', + }; + } + + private function mapExperienceLevel(?string $location): ?string + { + // Remotive doesn't provide experience level directly + // This is a placeholder that could be enhanced with ML/NLP + return null; + } +} diff --git a/app/Services/JobApi/Transformers/TheMuseTransformer.php b/app/Services/JobApi/Transformers/TheMuseTransformer.php new file mode 100644 index 0000000..6f40420 --- /dev/null +++ b/app/Services/JobApi/Transformers/TheMuseTransformer.php @@ -0,0 +1,138 @@ +sanitizeDescription($data['contents'] ?? ''), + location: $this->parseLocation($data), + salaryMin: null, // The Muse doesn't provide salary info + salaryMax: null, + salaryCurrency: 'USD', + jobType: $this->mapJobType($data['type'] ?? null), + sourceUrl: $data['refs']['landing_page'] ?? null, + applyUrl: $data['refs']['landing_page'] ?? null, + postedAt: $this->parseDate($data['publication_date'] ?? null), + expiresAt: null, + remoteAllowed: $this->isRemote($data), + experienceLevel: $this->mapExperienceLevel($data['levels'] ?? []), + category: $this->parseCategory($data['categories'] ?? []), + rawData: $data, + ); + } catch (\Throwable $e) { + throw new TransformationException( + 'The Muse', + 'Failed to transform The Muse job data: ' . $e->getMessage(), + ['data' => $data, 'error' => $e->getMessage()] + ); + } + } + + public function transformMany(array $jobs): array + { + return array_map(fn($job) => $this->transform($job), $jobs); + } + + private function sanitizeDescription(string $description): string + { + $clean = strip_tags($description); + $clean = html_entity_decode($clean, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $clean = preg_replace('/\s+/', ' ', $clean); + return trim($clean); + } + + private function parseLocation(array $data): ?string + { + if (empty($data['locations'])) { + return null; + } + + $locations = array_map(fn($loc) => $loc['name'] ?? '', $data['locations']); + return implode(', ', array_filter($locations)); + } + + private function isRemote(array $data): bool + { + if (empty($data['locations'])) { + return false; + } + + foreach ($data['locations'] as $location) { + $name = strtolower($location['name'] ?? ''); + if (str_contains($name, 'remote') || str_contains($name, 'flexible')) { + return true; + } + } + + return false; + } + + private function parseDate(?string $date): ?DateTime + { + if (empty($date)) { + return null; + } + + try { + return new DateTime($date); + } catch (\Exception $e) { + return null; + } + } + + private function mapJobType(?string $type): ?string + { + if (empty($type)) { + return 'full-time'; + } + + return match (strtolower($type)) { + 'full-time', 'fulltime' => 'full-time', + 'part-time', 'parttime' => 'part-time', + 'contract' => 'contract', + 'temporary', 'temp' => 'temporary', + 'internship', 'intern' => 'internship', + 'freelance' => 'freelance', + default => 'full-time', + }; + } + + private function mapExperienceLevel(array $levels): ?string + { + if (empty($levels)) { + return null; + } + + // The Muse uses: Entry Level, Mid Level, Senior Level + $firstLevel = strtolower($levels[0]['name'] ?? ''); + + return match (true) { + str_contains($firstLevel, 'entry') => 'entry', + str_contains($firstLevel, 'mid') => 'mid', + str_contains($firstLevel, 'senior') => 'senior', + str_contains($firstLevel, 'executive') => 'executive', + default => null, + }; + } + + private function parseCategory(array $categories): ?string + { + if (empty($categories)) { + return null; + } + + // Return the first category name + return $categories[0]['name'] ?? null; + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..f2f83e3 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,56 @@ +services: + laravel.test: + build: + context: './vendor/laravel/sail/runtimes/8.5' + dockerfile: Dockerfile + args: + WWWGROUP: '${WWWGROUP}' + image: 'sail-8.5/app' + extra_hosts: + - 'host.docker.internal:host-gateway' + ports: + - '${APP_PORT:-80}:80' + - '${VITE_PORT:-5173}:${VITE_PORT:-5173}' + environment: + WWWUSER: '${WWWUSER}' + LARAVEL_SAIL: 1 + XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}' + XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}' + IGNITION_LOCAL_SITES_PATH: '${PWD}' + volumes: + - '.:/var/www/html' + networks: + - sail + depends_on: + - mysql + mysql: + image: 'mysql:8.4' + ports: + - '${FORWARD_DB_PORT:-3306}:3306' + environment: + MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}' + MYSQL_ROOT_HOST: '%' + MYSQL_DATABASE: '${DB_DATABASE}' + MYSQL_USER: '${DB_USERNAME}' + MYSQL_PASSWORD: '${DB_PASSWORD}' + MYSQL_ALLOW_EMPTY_PASSWORD: 1 + MYSQL_EXTRA_OPTIONS: '${MYSQL_EXTRA_OPTIONS:-}' + volumes: + - 'sail-mysql:/var/lib/mysql' + - './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh' + networks: + - sail + healthcheck: + test: + - CMD + - mysqladmin + - ping + - '-p${DB_PASSWORD}' + retries: 3 + timeout: 5s +networks: + sail: + driver: bridge +volumes: + sail-mysql: + driver: local diff --git a/config/queue.php b/config/queue.php index 79c2c0a..3f79886 100644 --- a/config/queue.php +++ b/config/queue.php @@ -38,7 +38,7 @@ return [ 'database' => [ 'driver' => 'database', 'connection' => env('DB_QUEUE_CONNECTION'), - 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'table' => env('DB_QUEUE_TABLE', 'queue_jobs'), 'queue' => env('DB_QUEUE', 'default'), 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), 'after_commit' => false, diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php index 425e705..83a182a 100644 --- a/database/migrations/0001_01_01_000002_create_jobs_table.php +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -11,7 +11,7 @@ return new class extends Migration */ public function up(): void { - Schema::create('jobs', function (Blueprint $table) { + Schema::create('queue_jobs', function (Blueprint $table) { $table->id(); $table->string('queue')->index(); $table->longText('payload'); @@ -50,7 +50,7 @@ return new class extends Migration */ public function down(): void { - Schema::dropIfExists('jobs'); + Schema::dropIfExists('queue_jobs'); Schema::dropIfExists('job_batches'); Schema::dropIfExists('failed_jobs'); } diff --git a/database/migrations/2026_04_02_200816_create_job_categories_table.php b/database/migrations/2026_04_02_200816_create_job_categories_table.php new file mode 100644 index 0000000..cb112d8 --- /dev/null +++ b/database/migrations/2026_04_02_200816_create_job_categories_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('icon')->nullable(); + $table->timestamps(); + + $table->index('slug'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('job_categories'); + } +}; diff --git a/database/migrations/2026_04_02_200824_create_job_locations_table.php b/database/migrations/2026_04_02_200824_create_job_locations_table.php new file mode 100644 index 0000000..a9d571e --- /dev/null +++ b/database/migrations/2026_04_02_200824_create_job_locations_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('city')->nullable(); + $table->string('state')->nullable(); + $table->string('country'); + $table->string('country_code', 2); + $table->decimal('latitude', 10, 7)->nullable(); + $table->decimal('longitude', 10, 7)->nullable(); + $table->timestamps(); + + $table->index(['country', 'state', 'city']); + $table->index('country_code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('job_locations'); + } +}; diff --git a/database/migrations/2026_04_02_202809_create_job_sources_table.php b/database/migrations/2026_04_02_202809_create_job_sources_table.php new file mode 100644 index 0000000..be85696 --- /dev/null +++ b/database/migrations/2026_04_02_202809_create_job_sources_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('name'); + $table->string('api_url'); + $table->text('api_key')->nullable(); + $table->boolean('is_active')->default(true); + $table->integer('rate_limit')->default(100); + $table->timestamp('last_fetched_at')->nullable(); + $table->json('configuration')->nullable(); + $table->timestamps(); + + $table->index('is_active'); + $table->index('last_fetched_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('job_sources'); + } +}; diff --git a/database/migrations/2026_04_02_202816_create_jobs_table.php b/database/migrations/2026_04_02_202816_create_jobs_table.php new file mode 100644 index 0000000..af5c7c3 --- /dev/null +++ b/database/migrations/2026_04_02_202816_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('external_id')->nullable(); + $table->string('title'); + $table->string('company'); + $table->text('description'); + $table->string('location')->nullable(); + $table->decimal('salary_min', 10, 2)->nullable(); + $table->decimal('salary_max', 10, 2)->nullable(); + $table->string('salary_currency', 3)->nullable()->default('USD'); + $table->enum('job_type', ['full-time', 'part-time', 'contract', 'temporary', 'internship', 'freelance'])->nullable(); + $table->foreignId('job_source_id')->constrained('job_sources')->onDelete('cascade'); + $table->string('source_url')->nullable(); + $table->string('apply_url')->nullable(); + $table->timestamp('posted_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->boolean('remote_allowed')->default(false); + $table->enum('experience_level', ['entry', 'mid', 'senior', 'lead', 'executive'])->nullable(); + $table->foreignId('job_category_id')->nullable()->constrained('job_categories')->onDelete('set null'); + $table->foreignId('job_location_id')->nullable()->constrained('job_locations')->onDelete('set null'); + $table->string('fingerprint')->nullable()->unique(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['job_source_id', 'external_id']); + $table->index('posted_at'); + $table->index('expires_at'); + $table->index('job_type'); + $table->index('experience_level'); + $table->index('remote_allowed'); + $table->index('job_category_id'); + $table->index('job_location_id'); + $table->fullText(['title', 'company', 'description']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + } +}; diff --git a/database/migrations/2026_04_02_202818_create_user_saved_jobs_table.php b/database/migrations/2026_04_02_202818_create_user_saved_jobs_table.php new file mode 100644 index 0000000..0bd08be --- /dev/null +++ b/database/migrations/2026_04_02_202818_create_user_saved_jobs_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('job_id')->constrained('jobs')->onDelete('cascade'); + $table->timestamps(); + + $table->unique(['user_id', 'job_id']); + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_saved_jobs'); + } +}; diff --git a/database/migrations/2026_04_02_202819_create_user_job_alerts_table.php b/database/migrations/2026_04_02_202819_create_user_job_alerts_table.php new file mode 100644 index 0000000..1e39f18 --- /dev/null +++ b/database/migrations/2026_04_02_202819_create_user_job_alerts_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + $table->string('keywords')->nullable(); + $table->string('location')->nullable(); + $table->foreignId('job_category_id')->nullable()->constrained('job_categories')->onDelete('set null'); + $table->enum('frequency', ['instant', 'daily', 'weekly'])->default('daily'); + $table->boolean('is_active')->default(true); + $table->timestamp('last_sent_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + $table->index('is_active'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_job_alerts'); + } +}; diff --git a/database/seeders/JobSourceSeeder.php b/database/seeders/JobSourceSeeder.php new file mode 100644 index 0000000..8fbd971 --- /dev/null +++ b/database/seeders/JobSourceSeeder.php @@ -0,0 +1,62 @@ + 'Remotive', + 'api_url' => 'https://remotive.com/api', + 'api_key' => null, // No API key required + 'is_active' => true, + 'rate_limit' => 60, // requests per hour + 'configuration' => [ + 'description' => 'Remote jobs API - Free, no authentication required', + 'docs' => 'https://remotive.com/api-documentation', + ], + ], + [ + 'name' => 'The Muse', + 'api_url' => 'https://www.themuse.com/api/public', + 'api_key' => null, // Optional API key for higher limits + 'is_active' => true, + 'rate_limit' => 100, // requests per hour + 'configuration' => [ + 'description' => 'The Muse job board API - Free tier available', + 'docs' => 'https://www.themuse.com/developers/api/v2', + ], + ], + [ + 'name' => 'Adzuna', + 'api_url' => 'https://api.adzuna.com/v1/api/jobs', + 'api_key' => env('ADZUNA_API_KEY'), // Format: app_id:app_key + 'is_active' => env('ADZUNA_API_KEY') ? true : false, + 'rate_limit' => 100, // requests per hour + 'configuration' => [ + 'country' => 'us', + 'description' => 'Adzuna job search API - Requires free API key', + 'docs' => 'https://developer.adzuna.com/overview', + 'signup' => 'https://developer.adzuna.com/signup', + ], + ], + ]; + + foreach ($sources as $source) { + JobSource::updateOrCreate( + ['name' => $source['name']], + $source + ); + } + + $this->command->info('Job sources seeded successfully!'); + } +} diff --git a/phpunit.xml b/phpunit.xml index e7f0a48..a85936a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,8 +23,7 @@ - - +