diff --git a/app/Enums/ExperienceLevel.php b/app/Enums/ExperienceLevel.php new file mode 100644 index 0000000..60c0020 --- /dev/null +++ b/app/Enums/ExperienceLevel.php @@ -0,0 +1,11 @@ +where('application_deadline', '>=', now()->toDateString()) - ->with(['employer.employerProfile', 'category', 'technologies']) - ->latest() - ->paginate(20); + ->with(['employer.employerProfile', 'category', 'technologies']); + + // Keyword search on title and description + if ($q = trim((string) $request->input('q', ''))) { + $query->where(function ($sub) use ($q): void { + $term = '%'.mb_strtolower($q).'%'; + $sub->whereRaw('LOWER(title) LIKE ?', [$term]) + ->orWhereRaw('LOWER(description) LIKE ?', [$term]); + }); + } + + // Experience level filter + if ($level = $request->input('experience_level')) { + $query->where('experience_level', $level); + } + + // Sorting + match ($request->input('sort', 'newest')) { + 'deadline' => $query->orderBy('application_deadline'), + default => $query->latest(), + }; + + $perPage = min(max(1, (int) $request->input('per_page', 20)), 100); + $jobs = $query->paginate($perPage); return response()->json([ 'data' => $jobs->map(fn (Job $job) => [ @@ -31,6 +53,7 @@ public function index(): JsonResponse 'salary_range' => $job->salary_range, 'work_type' => $job->work_type?->value, 'location' => $job->location, + 'experience_level' => $job->experience_level?->value, 'application_deadline' => $job->application_deadline?->toDateString(), 'category' => $job->category ? ['id' => $job->category->id, 'name' => $job->category->name] : null, 'technologies' => $job->technologies->map(fn ($t) => ['id' => $t->id, 'name' => $t->name])->values(), @@ -64,6 +87,7 @@ public function show(Job $job): JsonResponse 'salary_range' => $job->salary_range, 'work_type' => $job->work_type?->value, 'location' => $job->location, + 'experience_level' => $job->experience_level?->value, 'application_deadline' => $job->application_deadline?->toDateString(), 'category' => $job->category ? ['id' => $job->category->id, 'name' => $job->category->name] : null, 'technologies' => $job->technologies->map(fn ($t) => ['id' => $t->id, 'name' => $t->name])->values(), diff --git a/app/Http/Controllers/Search/SearchController.php b/app/Http/Controllers/Search/SearchController.php new file mode 100644 index 0000000..debd704 --- /dev/null +++ b/app/Http/Controllers/Search/SearchController.php @@ -0,0 +1,121 @@ +where('application_deadline', '>=', now()->toDateString()) + ->with(['employer.employerProfile', 'category', 'technologies']); + + // Keyword search + if ($q = trim((string) $request->input('q', ''))) { + $query->where(function ($sub) use ($q): void { + $term = '%'.mb_strtolower($q).'%'; + $sub->whereRaw('LOWER(title) LIKE ?', [$term]) + ->orWhereRaw('LOWER(description) LIKE ?', [$term]); + }); + } + + // Experience level + if ($level = $request->input('experience_level')) { + $query->where('experience_level', $level); + } + + // Location (partial, case-insensitive) + if ($location = trim((string) $request->input('location', ''))) { + $query->whereRaw('LOWER(location) LIKE ?', ['%'.mb_strtolower($location).'%']); + } + + // Work type + if ($workType = $request->input('work_type')) { + $query->where('work_type', $workType); + } + + // Category (slug or numeric id) + if ($category = $request->input('category')) { + $query->whereHas('category', function ($sub) use ($category): void { + is_numeric($category) + ? $sub->where('id', $category) + : $sub->where('slug', $category); + }); + } + + // Date posted from + if ($dateFrom = $request->input('date_from')) { + $query->whereDate('created_at', '>=', $dateFrom); + } + + // Sorting + match ($request->input('sort', 'newest')) { + 'deadline' => $query->orderBy('application_deadline'), + default => $query->latest(), + }; + + $perPage = min(max(1, (int) $request->input('per_page', 20)), 100); + $jobs = $query->paginate($perPage); + + return response()->json([ + 'data' => $jobs->map(fn (Job $job) => [ + 'id' => $job->id, + 'title' => $job->title, + 'description' => $job->description, + 'salary_range' => $job->salary_range, + 'work_type' => $job->work_type?->value, + 'location' => $job->location, + 'experience_level' => $job->experience_level?->value, + 'application_deadline' => $job->application_deadline?->toDateString(), + 'created_at' => $job->created_at?->toDateString(), + 'category' => $job->category + ? ['id' => $job->category->id, 'name' => $job->category->name, 'slug' => $job->category->slug] + : null, + 'technologies' => $job->technologies + ->map(fn ($t) => ['id' => $t->id, 'name' => $t->name]) + ->values(), + 'employer' => [ + 'company_name' => $job->employer?->employerProfile?->company_name ?? 'Unknown', + 'logo_url' => $job->employer?->employerProfile?->logo_url, + ], + ])->values(), + 'meta' => [ + 'current_page' => $jobs->currentPage(), + 'last_page' => $jobs->lastPage(), + 'per_page' => $jobs->perPage(), + 'total' => $jobs->total(), + ], + ]); + } + + /** + * Return the seeded location reference list for autocomplete. + */ + public function locations(): JsonResponse + { + return response()->json([ + 'data' => Location::orderBy('name')->get(['id', 'name', 'slug']), + ]); + } +} diff --git a/app/Models/Job.php b/app/Models/Job.php index 61439b1..78e0228 100644 --- a/app/Models/Job.php +++ b/app/Models/Job.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\ExperienceLevel; use App\Enums\JobStatus; use App\Enums\WorkType; use Database\Factories\JobFactory; @@ -20,15 +21,16 @@ class Job extends Model protected $fillable = [ 'employer_id', 'title', 'description', 'requirements', - 'salary_range', 'work_type', 'location', 'category_id', + 'salary_range', 'work_type', 'location', 'experience_level', 'category_id', 'status', 'rejection_reason', 'views_count', 'application_deadline', ]; protected function casts(): array { return [ - 'status' => JobStatus::class, - 'work_type' => WorkType::class, + 'status' => JobStatus::class, + 'work_type' => WorkType::class, + 'experience_level' => ExperienceLevel::class, 'application_deadline' => 'date', ]; } diff --git a/app/Models/Location.php b/app/Models/Location.php new file mode 100644 index 0000000..5b390ef --- /dev/null +++ b/app/Models/Location.php @@ -0,0 +1,10 @@ +string('experience_level')->nullable()->after('location'); + }); + } + + public function down(): void + { + Schema::table('job_listings', function (Blueprint $table): void { + $table->dropColumn('experience_level'); + }); + } +}; diff --git a/database/migrations/2026_05_30_200002_create_locations_table.php b/database/migrations/2026_05_30_200002_create_locations_table.php new file mode 100644 index 0000000..2f2cfe1 --- /dev/null +++ b/database/migrations/2026_05_30_200002_create_locations_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('locations'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 09a665d..5cc81c2 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,5 +18,6 @@ public function run(): void $this->call(Dev5Seeder::class); $this->call(CategoryTechnologySeeder::class); $this->call(EmployerApplicationReviewSeeder::class); + $this->call(LocationSeeder::class); } } diff --git a/database/seeders/LocationSeeder.php b/database/seeders/LocationSeeder.php new file mode 100644 index 0000000..105b830 --- /dev/null +++ b/database/seeders/LocationSeeder.php @@ -0,0 +1,33 @@ + Str::slug($city)], + ['name' => $city], + ); + } + } +} diff --git a/routes/api.php b/routes/api.php index 968ff91..e793b2d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -21,6 +21,7 @@ use App\Http\Controllers\Payment\PayPalController; use App\Http\Controllers\Payment\StripeWebhookController; use App\Http\Controllers\Payment\StripeCheckoutController; +use App\Http\Controllers\Search\SearchController; use App\Models\Category; use App\Models\Technology; use App\Models\User; @@ -175,3 +176,12 @@ Route::patch('applications/{application}/notes', [EmployerApplicationController::class, 'updateNotes']); Route::get('applications/{application}/resume', [EmployerApplicationController::class, 'streamResume']); }); + +// ── Dev 3: Search ───────────────────────────────────────────────────────────── +// Public: location reference list (for autocomplete, no auth required) +Route::get('v1/search/locations', [SearchController::class, 'locations']); + +// Candidate-scoped: full search with filters +Route::middleware(['auth', 'role:candidate'])->group(function () { + Route::get('v1/search/jobs', [SearchController::class, 'jobs']); +});