diff --git a/.env.example b/.env.example index f7d5f88..d51e328 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,7 @@ VITE_APP_NAME="${APP_NAME}" # Notification Reminder Settings WORKER_NO_RESPONSE_REMINDER_DAYS=14 REVIEW_ELIGIBILITY_MONTHS=3 +REVIEW_ELIGIBILITY_MINUTES=10 REVIEW_EDIT_PERIOD_DAYS=7 EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS=3 WORKER_JOIN_CONFIRM_REMINDER_DAYS=3 diff --git a/app/Console/Commands/SendReminders.php b/app/Console/Commands/SendReminders.php index 692ad8c..879bc01 100644 --- a/app/Console/Commands/SendReminders.php +++ b/app/Console/Commands/SendReminders.php @@ -374,7 +374,9 @@ private function checkWorkerNoResponse() private function checkReviewReminders() { $eligibilityMonths = (int) config('reminders.review_eligibility_months', 3); - $level = "{$eligibilityMonths}_months"; + $eligibilityMinutes = config('reminders.review_eligibility_minutes'); + $isMinutes = ($eligibilityMinutes !== null && $eligibilityMinutes > 0); + $level = $isMinutes ? "{$eligibilityMinutes}_minutes" : "{$eligibilityMonths}_months"; // 1. Standard Job Applications with status 'hired' $applications = \App\Models\JobApplication::where('status', 'hired') @@ -390,9 +392,16 @@ private function checkReviewReminders() if (!$employer || !$worker) continue; $joiningDate = Carbon::parse($app->joining_confirmed_at); - $months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false); + + $eligible = false; + if ($isMinutes) { + $eligible = $joiningDate->diffInMinutes(now(), false) >= $eligibilityMinutes; + } else { + $months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false); + $eligible = $months >= $eligibilityMonths; + } - if ($months >= $eligibilityMonths) { + if ($eligible) { // Remind worker to review employer if (!$this->alreadySent($worker, 'review_employer_reminder', $app, $level)) { $this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name}."); @@ -428,9 +437,16 @@ private function checkReviewReminders() if (!$worker || !$employer) continue; $joiningDate = $offer->work_date ? Carbon::parse($offer->work_date) : $offer->updated_at; - $months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false); + + $eligible = false; + if ($isMinutes) { + $eligible = $joiningDate->diffInMinutes(now(), false) >= $eligibilityMinutes; + } else { + $months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false); + $eligible = $months >= $eligibilityMonths; + } - if ($months >= $eligibilityMonths) { + if ($eligible) { // Remind worker to review employer if (!$this->alreadySent($worker, 'review_employer_reminder', $offer, $level)) { $this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name} (direct offer)."); diff --git a/app/Http/Controllers/Admin/AdminExtraController.php b/app/Http/Controllers/Admin/AdminExtraController.php index e4eda98..68b3ef9 100644 --- a/app/Http/Controllers/Admin/AdminExtraController.php +++ b/app/Http/Controllers/Admin/AdminExtraController.php @@ -734,6 +734,9 @@ public function storeAnnouncement(Request $request) 'event_time' => 'required|string', 'location_details' => 'required|string', 'location_pin' => 'required|string|url', + 'contact_person_name' => 'required|string|max:255', + 'contact_number' => 'required|string|max:50', + 'country_code' => 'nullable|string|max:10', ]); $body = json_encode([ @@ -744,6 +747,9 @@ public function storeAnnouncement(Request $request) 'location_details' => $request->location_details, 'location_pin' => $request->location_pin, 'content' => $request->content, + 'contact_person_name' => $request->contact_person_name, + 'contact_number' => $request->contact_number, + 'country_code' => $request->country_code ?? '+971', ]); $ann = \App\Models\Announcement::create([ @@ -801,7 +807,7 @@ public function deleteAnnouncement(Request $request, $id) } /** - * Broadcast FCM Push Notification for new Event/Announcement. + * Broadcast FCM Push Notification and save to Database for new Event/Announcement. */ protected function notifyUsersOfEvent($ann) { @@ -816,31 +822,29 @@ protected function notifyUsersOfEvent($ann) } // Notify all workers - $workers = \App\Models\Worker::whereNotNull('fcm_token')->get(); + $workers = \App\Models\Worker::all(); foreach ($workers as $worker) { - \App\Services\FCMService::sendPushNotification( - $worker->fcm_token, + $worker->notify(new \App\Notifications\GenericNotification( $title, $body, [ 'type' => 'announcement', 'announcement_id' => $ann->id, ] - ); + )); } // Notify all employers - $employers = \App\Models\User::where('role', 'employer')->whereNotNull('fcm_token')->get(); + $employers = \App\Models\User::where('role', 'employer')->get(); foreach ($employers as $employer) { - \App\Services\FCMService::sendPushNotification( - $employer->fcm_token, + $employer->notify(new \App\Notifications\GenericNotification( $title, $body, [ 'type' => 'announcement', 'announcement_id' => $ann->id, ] - ); + )); } } } diff --git a/app/Http/Controllers/Admin/WorkerController.php b/app/Http/Controllers/Admin/WorkerController.php index c4b9a2a..313e56c 100644 --- a/app/Http/Controllers/Admin/WorkerController.php +++ b/app/Http/Controllers/Admin/WorkerController.php @@ -132,8 +132,22 @@ public function updateProfile(Request $request, $id) 'experience' => 'required|string', 'salary' => 'nullable|numeric', 'nationality' => 'nullable|string', - 'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa', - 'age' => 'nullable|integer', + 'visa_status' => 'nullable|string', + 'age' => [ + 'nullable', + function ($attribute, $value, $fail) { + if (is_numeric($value)) { + $intValue = (int)$value; + if ($intValue < 18 || $intValue > 100) { + $fail('The age must be between 18 and 100.'); + } + } else { + if (!in_array($value, ['18-25', '26-35', '36-45', '46-55', '56-60'])) { + $fail('The age must be a valid number or one of the ranges: 18-25, 26-35, 36-45, 46-55, 56-60.'); + } + } + } + ], 'in_country' => 'nullable', 'preferred_job_type' => 'nullable|string', 'skills' => 'nullable|string', diff --git a/app/Http/Controllers/Api/EmployerAnnouncementController.php b/app/Http/Controllers/Api/EmployerAnnouncementController.php index 751ec5b..97c9375 100644 --- a/app/Http/Controllers/Api/EmployerAnnouncementController.php +++ b/app/Http/Controllers/Api/EmployerAnnouncementController.php @@ -108,15 +108,26 @@ public function getMyAnnouncements(Request $request) $announcements = $query->skip($offset)->take($perPage)->get() ->map(function ($announcement) { + $charityDetails = null; + $content = $announcement->body; + if (strpos($announcement->body, '{"type":"Charity"') === 0) { + $decoded = json_decode($announcement->body, true); + if ($decoded) { + $charityDetails = $decoded; + $content = $decoded['content'] ?? $announcement->body; + } + } + return [ 'id' => $announcement->id, 'title' => $announcement->title, - 'body' => $announcement->body, + 'body' => $content, 'type' => $announcement->type, 'status' => $announcement->status ?? 'pending', 'remarks' => $announcement->remarks, 'created_at' => $announcement->created_at->toISOString(), 'time_ago' => $announcement->created_at->diffForHumans(), + 'charity_details' => $charityDetails, ]; }); @@ -155,12 +166,33 @@ public function createAnnouncement(Request $request) /** @var User $employer */ $employer = $request->attributes->get('employer'); - $validator = Validator::make($request->all(), [ + $isCharity = $request->type === 'charity' || + $request->has('event_date') || + $request->has('location_details') || + $request->has('provided_items') || + $request->has('contact_person_name'); + + $rules = [ 'title' => 'required|string|max:255', 'body' => 'required_without:content|string|max:5000', 'content' => 'required_without:body|string|max:5000', - 'type' => 'nullable|string|in:info,warning,success', - ]); + 'type' => 'nullable|string|in:info,warning,success,charity', + ]; + + if ($isCharity) { + $rules['event_date'] = 'required|string'; + $rules['event_time'] = 'required_without_all:start_time,end_time|nullable|string'; + $rules['start_time'] = 'required_without:event_time|nullable|string'; + $rules['end_time'] = 'required_without:event_time|nullable|string'; + $rules['provided_items'] = 'required|string'; + $rules['location_details'] = 'required|string'; + $rules['location_pin'] = 'required|string|url'; + $rules['contact_person_name'] = 'required|string|max:255'; + $rules['contact_number'] = 'required|string|regex:/^\d{7,15}$/'; + $rules['country_code'] = 'required|string|regex:/^\+\d{1,4}$/'; + } + + $validator = Validator::make($request->all(), $rules); if ($validator->fails()) { return response()->json([ @@ -172,27 +204,45 @@ public function createAnnouncement(Request $request) try { $bodyText = $request->body ?? $request->content; - - // Append extra event details if they are provided - $extras = []; - if ($request->event_date) $extras[] = "Date: " . $request->event_date; - if ($request->event_time) $extras[] = "Time: " . $request->event_time; - if ($request->location_details) $extras[] = "Location: " . $request->location_details; - if ($request->location_pin) $extras[] = "Map Pin: " . $request->location_pin; - if ($request->provided_items) $extras[] = "Provided: " . $request->provided_items; - - if (!empty($extras)) { - $bodyText .= "\n\n" . implode("\n", $extras); + + if ($isCharity) { + $eventTime = $request->event_time; + if (empty($eventTime)) { + $eventTime = $request->start_time . ' - ' . $request->end_time; + } + + $bodyText = json_encode([ + 'type' => 'Charity', + 'provided_items' => $request->provided_items, + 'event_date' => $request->event_date, + 'event_time' => $eventTime, + 'location_details' => $request->location_details, + 'location_pin' => $request->location_pin, + 'content' => $request->body ?? $request->content, + 'contact_person_name' => $request->contact_person_name, + 'contact_number' => $request->contact_number, + 'country_code' => $request->country_code, + ]); } $announcement = Announcement::create([ 'title' => $request->title, 'body' => $bodyText, - 'type' => $request->type ?? 'info', + 'type' => $request->type ?? ($isCharity ? 'charity' : 'info'), 'employer_id' => $employer->id, 'status' => 'pending', ]); + $charityDetails = null; + $content = $announcement->body; + if (strpos($announcement->body, '{"type":"Charity"') === 0) { + $decoded = json_decode($announcement->body, true); + if ($decoded) { + $charityDetails = $decoded; + $content = $decoded['content'] ?? $announcement->body; + } + } + return response()->json([ 'success' => true, 'message' => 'Announcement posted successfully.', @@ -200,12 +250,13 @@ public function createAnnouncement(Request $request) 'announcement' => [ 'id' => $announcement->id, 'title' => $announcement->title, - 'body' => $announcement->body, + 'body' => $content, 'type' => $announcement->type, 'status' => $announcement->status ?? 'pending', 'remarks' => $announcement->remarks, 'created_at' => $announcement->created_at->toISOString(), 'time_ago' => $announcement->created_at->diffForHumans(), + 'charity_details' => $charityDetails, ] ] ], 201); diff --git a/app/Http/Controllers/Api/EmployerAuthController.php b/app/Http/Controllers/Api/EmployerAuthController.php index 017be9e..96426f2 100644 --- a/app/Http/Controllers/Api/EmployerAuthController.php +++ b/app/Http/Controllers/Api/EmployerAuthController.php @@ -324,14 +324,6 @@ public function register(Request $request) 'address' => $request->address, ]); - if ($request->filled('fcm_token')) { - \App\Services\FCMService::sendPushNotification( - $request->fcm_token, - 'Registration Initiated', - 'Your verification code has been sent to your email.' - ); - } - // Try sending email try { \Illuminate\Support\Facades\Mail::to($request->email)->send(new \App\Mail\EmployerOtpMail( @@ -419,14 +411,6 @@ public function verify(Request $request) $user->update(['fcm_token' => $fcmToken]); } - $tokenToSend = $fcmToken ?? ($sponsor ? $sponsor->fcm_token : null) ?? ($user ? $user->fcm_token : null); - if ($tokenToSend) { - \App\Services\FCMService::sendPushNotification( - $tokenToSend, - 'Email Verified', - 'Proceed to payment selection.' - ); - } // Clear Cache \Illuminate\Support\Facades\Cache::forget('employer_otp_' . $request->email); diff --git a/app/Http/Controllers/Api/EmployerProfileController.php b/app/Http/Controllers/Api/EmployerProfileController.php index fa38aa6..258d6d2 100644 --- a/app/Http/Controllers/Api/EmployerProfileController.php +++ b/app/Http/Controllers/Api/EmployerProfileController.php @@ -400,12 +400,7 @@ public function getDashboard(Request $request) // Recent Announcements / Events $dbAnnouncements = \App\Models\Announcement::where('status', 'approved')->latest()->limit(2)->get(); $recentAnnouncements = $dbAnnouncements->map(function ($ann) { - $body = $ann->body; - $eventDate = null; - $eventTime = null; - $locationDetails = null; - $locationPin = null; - $providedItems = null; + $charityDetails = null; if (strpos($ann->body, '{"type":"Charity"') === 0) { $decoded = json_decode($ann->body, true); @@ -416,6 +411,7 @@ public function getDashboard(Request $request) $locationDetails = $decoded['location_details'] ?? null; $locationPin = $decoded['location_pin'] ?? null; $providedItems = $decoded['provided_items'] ?? null; + $charityDetails = $decoded; } } else { $body = $ann->body; @@ -438,6 +434,7 @@ public function getDashboard(Request $request) 'provided_items' => $providedItems, 'created_at' => $ann->created_at->toISOString(), 'time_ago' => $ann->created_at->diffForHumans(), + 'charity_details' => $charityDetails, ]; }); diff --git a/app/Http/Controllers/Api/EmployerReviewController.php b/app/Http/Controllers/Api/EmployerReviewController.php index 4ac983d..079c91c 100644 --- a/app/Http/Controllers/Api/EmployerReviewController.php +++ b/app/Http/Controllers/Api/EmployerReviewController.php @@ -71,12 +71,23 @@ public function addReview(Request $request) ], 403); } - $eligibilityMonths = (int) config('reminders.review_eligibility_months', 3); - if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) { - return response()->json([ - 'success' => false, - 'message' => "You can write a review only after {$eligibilityMonths} months from the joining date." - ], 403); + // Verify if completed configured period from joining date + $eligibilityMinutes = config('reminders.review_eligibility_minutes'); + if ($eligibilityMinutes !== null && $eligibilityMinutes > 0) { + if ($joiningDate->diffInMinutes(now(), false) < $eligibilityMinutes) { + return response()->json([ + 'success' => false, + 'message' => "You can write a review only after {$eligibilityMinutes} minutes from the joining date." + ], 403); + } + } else { + $eligibilityMonths = (int) config('reminders.review_eligibility_months', 3); + if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) { + return response()->json([ + 'success' => false, + 'message' => "You can write a review only after {$eligibilityMonths} months from the joining date." + ], 403); + } } // Check if employer has already reviewed this worker diff --git a/app/Http/Controllers/Api/EmployerWorkerController.php b/app/Http/Controllers/Api/EmployerWorkerController.php index e73f5dd..031608e 100644 --- a/app/Http/Controllers/Api/EmployerWorkerController.php +++ b/app/Http/Controllers/Api/EmployerWorkerController.php @@ -20,7 +20,7 @@ class EmployerWorkerController extends Controller /** * Helper to map worker DB models to API presentation format. */ - private function formatWorker(Worker $w) + public function formatWorker(Worker $w) { // Map languages from database comma-separated string $langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English']; @@ -90,30 +90,6 @@ private function formatWorker(Worker $w) ] ]; })->toArray(), - 'documents' => $w->documents->map(function ($doc) { - $ocrData = $doc->ocr_data; - if ($doc->type === 'visa' && is_array($ocrData)) { - unset($ocrData['file_number']); - unset($ocrData['name']); - unset($ocrData['passport_number']); - unset($ocrData['accompanied_by']); - unset($ocrData['valid_until']); - unset($ocrData['sponsor']); - } - return [ - 'id' => $doc->id, - 'worker_id' => $doc->worker_id, - 'type' => $doc->type, - 'number' => $doc->number, - 'issue_date' => $doc->issue_date, - 'expiry_date' => $doc->expiry_date, - 'ocr_accuracy' => $doc->ocr_accuracy, - 'file_path' => $doc->file_path ? url($doc->file_path) : null, - 'ocr_data' => $ocrData, - 'created_at' => $doc->created_at?->toISOString(), - 'updated_at' => $doc->updated_at?->toISOString(), - ]; - })->toArray(), 'category' => [ 'id' => 7, 'name' => 'General Helper', @@ -440,10 +416,11 @@ public function getCandidates(Request $request) if (!$w) return null; $status = 'Reviewing'; - if ($app->status === 'hired') $status = 'Hired'; - elseif ($app->status === 'rejected') $status = 'Rejected'; - elseif ($app->status === 'shortlisted' || $app->status === 'offer_sent') $status = 'Offer Sent'; - elseif ($app->status === 'applied') $status = 'Reviewing'; + $dbStatusLower = strtolower($app->status ?? ''); + if ($dbStatusLower === 'hired') $status = 'Hired'; + elseif ($dbStatusLower === 'rejected') $status = 'Rejected'; + elseif ($dbStatusLower === 'shortlisted' || $dbStatusLower === 'offer_sent' || $dbStatusLower === 'offer sent') $status = 'Offer Sent'; + elseif ($dbStatusLower === 'applied') $status = 'Reviewing'; else $status = ucfirst($app->status); $formattedWorker = $this->formatWorker($w); @@ -465,9 +442,10 @@ public function getCandidates(Request $request) if (!$w) return null; $status = 'Offer Sent'; - if ($offer->status === 'accepted') $status = 'Hired'; - elseif ($offer->status === 'rejected') $status = 'Rejected'; - elseif ($offer->status === 'pending') $status = 'Offer Sent'; + $dbOfferStatusLower = strtolower($offer->status ?? ''); + if ($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') $status = 'Hired'; + elseif ($dbOfferStatusLower === 'rejected') $status = 'Rejected'; + elseif ($dbOfferStatusLower === 'pending' || $dbOfferStatusLower === 'offer sent' || $dbOfferStatusLower === 'offer_sent') $status = 'Offer Sent'; $formattedWorker = $this->formatWorker($w); @@ -480,12 +458,21 @@ public function getCandidates(Request $request) ]); })->filter()->values()->toArray(); - // Merge and sort - $mergedCandidates = array_merge($selectedWorkers, $directWorkers); + // Merge and sort (filtering out duplicates for direct offers where already hired via application) + $hiredAppWorkerIds = collect($selectedWorkers) + ->filter(fn($w) => strtolower($w['status'] ?? '') === 'hired') + ->pluck('worker_id') + ->toArray(); + + $filteredDirectWorkers = array_filter($directWorkers, function($dw) use ($hiredAppWorkerIds) { + return $dw && !in_array($dw['worker_id'], $hiredAppWorkerIds); + }); + + $mergedCandidates = array_merge($selectedWorkers, $filteredDirectWorkers); // Only display Hired candidates in the candidates pipeline (exclude active states) $mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) { - return $c && $c['status'] === 'Hired'; + return $c && strtolower($c['status'] ?? '') === 'hired'; })); // Apply filters: gender, skills, languages, nationality, availability, preferred_location, job_type, live_in_out, in_country, visa_status @@ -790,11 +777,8 @@ public function hireCandidate(Request $request, $id = null) $offerId = substr($targetId, 6); $offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->firstOrFail(); - $offer->update(['status' => 'accepted']); - if ($offer->worker) { - $offer->worker->update(['status' => 'Hired']); - $worker = $offer->worker; - } + $offer->update(['status' => 'pending']); + $worker = $offer->worker; $updatedOffer = $offer; } else { // Check if targetId is an application @@ -804,11 +788,8 @@ public function hireCandidate(Request $request, $id = null) })->first(); if ($application) { - $application->update(['status' => 'hired']); - if ($application->worker) { - $application->worker->update(['status' => 'Hired']); - $worker = $application->worker; - } + $application->update(['status' => 'hire_requested']); + $worker = $application->worker; $updatedApplication = $application; } else { // Try targeting by Worker ID directly @@ -821,15 +802,13 @@ public function hireCandidate(Request $request, $id = null) ], 404); } - $worker->update(['status' => 'Hired']); - - // Find existing direct offer + // Find existing direct offer or create a new one with status 'pending' $offer = JobOffer::where('employer_id', $employerId) ->where('worker_id', $worker->id) ->first(); if ($offer) { - $offer->update(['status' => 'accepted']); + $offer->update(['status' => 'pending']); $updatedOffer = $offer; } else { // Find existing application @@ -839,43 +818,57 @@ public function hireCandidate(Request $request, $id = null) ->first(); if ($application) { - $application->update(['status' => 'hired']); + $application->update(['status' => 'hire_requested']); $updatedApplication = $application; } else { - // Create a new direct job offer and accept it + // Create a new direct job offer in 'pending' status $updatedOffer = JobOffer::create([ 'employer_id' => $employerId, 'worker_id' => $worker->id, 'work_date' => now()->format('Y-m-d'), 'location' => 'Dubai', 'salary' => $worker->salary, - 'notes' => 'Direct sponsoring matching finalized via mobile API.', - 'status' => 'accepted', + 'notes' => 'Direct sponsoring matching initiated via API.', + 'status' => 'pending', ]); } } } } - // Dispatch push notification to worker - if ($worker && $worker->fcm_token) { - \App\Services\FCMService::sendPushNotification( - $worker->fcm_token, - "Congratulations! You've been Hired", - "Employer " . ($employer->name ?? "Employer") . " has hired you.", - [ - 'type' => 'hired', - 'employer_id' => $employer->id, - ] - ); + // Dispatch push & database notification to worker for confirmation request + if ($worker) { + if ($updatedApplication) { + $job = $updatedApplication->jobPost; + $worker->notify(new \App\Notifications\GenericNotification( + "Action Required: Confirm Hiring", + "Employer " . ($employer->name ?? "Employer") . " wants to hire you for '" . ($job->title ?? 'Job Post') . "'. Please confirm.", + [ + 'type' => 'hire_request', + 'job_id' => $job->id ?? '', + 'application_id' => $updatedApplication->id, + 'status' => 'hire_requested', + ] + )); + } else if ($updatedOffer) { + $worker->notify(new \App\Notifications\GenericNotification( + "Direct Hiring Offer", + ($employer->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?", + [ + 'type' => 'direct_hire_request', + 'offer_id' => $updatedOffer->id, + 'status' => 'pending', + ] + )); + } } return response()->json([ 'success' => true, - 'message' => 'Candidate successfully marked as Hired!', + 'message' => 'Hire request initiated successfully. Awaiting candidate confirmation.', 'data' => [ 'worker_id' => $worker ? $worker->id : null, - 'worker_status' => $worker ? $worker->status : 'Hired', + 'worker_status' => $worker ? $worker->status : 'Active', 'application' => $updatedApplication, 'offer' => $updatedOffer, ] @@ -1049,30 +1042,6 @@ public function getWorkerDetail(Request $request, $id) ] ]; })->toArray(), - 'documents' => $w->documents->map(function ($doc) { - $ocrData = $doc->ocr_data; - if ($doc->type === 'visa' && is_array($ocrData)) { - unset($ocrData['file_number']); - unset($ocrData['name']); - unset($ocrData['passport_number']); - unset($ocrData['accompanied_by']); - unset($ocrData['valid_until']); - unset($ocrData['sponsor']); - } - return [ - 'id' => $doc->id, - 'worker_id' => $doc->worker_id, - 'type' => $doc->type, - 'number' => $doc->number, - 'issue_date' => $doc->issue_date, - 'expiry_date' => $doc->expiry_date, - 'ocr_accuracy' => $doc->ocr_accuracy, - 'file_path' => $doc->file_path ? url($doc->file_path) : null, - 'ocr_data' => $ocrData, - 'created_at' => $doc->created_at?->toISOString(), - 'updated_at' => $doc->updated_at?->toISOString(), - ]; - })->toArray(), ]; return response()->json([ diff --git a/app/Http/Controllers/Api/WorkerAuthController.php b/app/Http/Controllers/Api/WorkerAuthController.php index 84ac32d..5d7fbc3 100644 --- a/app/Http/Controllers/Api/WorkerAuthController.php +++ b/app/Http/Controllers/Api/WorkerAuthController.php @@ -58,11 +58,10 @@ public function skills() public function sendOtp(Request $request) { $validator = Validator::make($request->all(), [ - 'phone' => 'required_without:email|nullable|string|max:50|unique:workers,phone', + 'phone' => 'required_without:email|nullable|string|max:50', 'email' => 'required_without:phone|nullable|email|max:255|unique:workers,email', ], [ 'phone.required_without' => 'Please provide a mobile number or email address.', - 'phone.unique' => 'This mobile number is already registered.', 'email.required_without' => 'Please provide a mobile number or email address.', 'email.unique' => 'This email address is already registered.', ]); @@ -75,6 +74,17 @@ public function sendOtp(Request $request) ], 422); } + if ($request->phone) { + $exists = Worker::findByPhone($request->phone); + if ($exists) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => ['phone' => ['This mobile number is already registered.']] + ], 422); + } + } + // Determine identifier (phone takes priority) $identifier = $request->phone ?? $request->email; $isPhone = !empty($request->phone); @@ -205,7 +215,7 @@ public function setupProfile(Request $request) } // Check if already registered - if ($request->phone && Worker::where('phone', $request->phone)->exists()) { + if ($request->phone && Worker::findByPhone($request->phone)) { return response()->json([ 'success' => false, 'message' => 'This mobile number is already registered.' @@ -243,7 +253,7 @@ public function setupProfile(Request $request) 'live_in_out' => $request->live_in_out, 'preferred_location' => $request->preferred_location, 'main_profession' => $request->main_profession, - 'age' => 25, // Default — updated later in full profile + 'age' => '18-25', // Default — updated later in full profile 'salary' => 1500, // Default AED — updated later 'availability' => 'Immediate', 'experience' => 'Not Specified', @@ -309,16 +319,30 @@ public function register(Request $request) $validator = Validator::make($request->all(), [ 'name' => 'required|string|max:255', - 'phone' => 'required|string|max:50|unique:workers,phone', + 'phone' => 'required|string|max:50', 'salary' => 'required|numeric|min:0', 'password' => 'required|string|min:6', 'skills' => 'nullable', 'language' => 'nullable|string', 'nationality' => 'nullable|string|max:100', - 'age' => 'nullable|integer', + 'age' => [ + 'nullable', + function ($attribute, $value, $fail) { + if (is_numeric($value)) { + $intValue = (int)$value; + if ($intValue < 18 || $intValue > 100) { + $fail('The age must be between 18 and 100.'); + } + } else { + if (!in_array($value, ['18-25', '26-35', '36-45', '46-55', '56-60'])) { + $fail('The age must be a valid number or one of the ranges: 18-25, 26-35, 36-45, 46-55, 56-60.'); + } + } + } + ], 'experience' => 'nullable|string|max:100', 'in_country' => 'nullable', - 'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa', + 'visa_status' => 'nullable|string', 'preferred_job_type' => 'nullable|string|max:100', 'main_profession' => 'nullable|string|max:100', 'gender' => 'nullable|string|in:male,female,other', @@ -362,7 +386,6 @@ public function register(Request $request) 'visa.sponsor_name' => 'nullable|string|max:255', 'visa.ocr_accuracy' => 'nullable|numeric', ], [ - 'phone.unique' => 'This mobile number is already registered.', 'passport.passport_number.unique' => 'This passport number is already registered.', ]); @@ -374,6 +397,17 @@ public function register(Request $request) ], 422); } + if ($request->phone) { + $exists = Worker::findByPhone($request->phone); + if ($exists) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => ['phone' => ['This mobile number is already registered.']] + ], 422); + } + } + try { $phoneClean = preg_replace('/[^0-9]/', '', $request->phone); $email = "worker.{$phoneClean}@migrant.ae"; @@ -401,7 +435,7 @@ public function register(Request $request) // Initialize fields $name = $request->name; $nationality = $request->nationality ?? 'Not Specified'; - $age = $request->age ?? 25; + $age = $request->age ?? '18-25'; $visaStatus = $inCountry ? $request->visa_status : null; $passportDataInput = $request->input('passport'); @@ -430,7 +464,20 @@ public function register(Request $request) if (!empty($passportDataInput['date_of_birth'])) { try { $dob = new \DateTime($this->normaliseDateForController($passportDataInput['date_of_birth'])); - $age = (new \DateTime())->diff($dob)->y; + $ageVal = (new \DateTime())->diff($dob)->y; + if ($ageVal >= 18 && $ageVal <= 25) { + $age = '18-25'; + } elseif ($ageVal >= 26 && $ageVal <= 35) { + $age = '26-35'; + } elseif ($ageVal >= 36 && $ageVal <= 45) { + $age = '36-45'; + } elseif ($ageVal >= 46 && $ageVal <= 55) { + $age = '46-55'; + } elseif ($ageVal >= 56 && $ageVal <= 60) { + $age = '56-60'; + } else { + $age = $ageVal < 18 ? '18-25' : '56-60'; + } } catch (\Exception $dateEx) { logger()->error('Passport DOB parse error in register: ' . $dateEx->getMessage()); } @@ -562,7 +609,7 @@ public function register(Request $request) try { $worker = $request->phone - ? Worker::where('phone', $request->phone)->first() + ? Worker::findByPhone($request->phone) : Worker::where('email', $request->email)->first(); if (!$worker || !Hash::check($request->password, $worker->password)) { @@ -1333,7 +1380,7 @@ public function forgotPassword(Request $request) ], 422); } - $worker = Worker::where('phone', $request->phone)->first(); + $worker = Worker::findByPhone($request->phone); if (!$worker) { return response()->json([ @@ -1409,7 +1456,7 @@ public function resetPassword(Request $request) ], 422); } - $worker = Worker::where('phone', $request->phone)->first(); + $worker = Worker::findByPhone($request->phone); if (!$worker) { return response()->json([ diff --git a/app/Http/Controllers/Api/WorkerJobController.php b/app/Http/Controllers/Api/WorkerJobController.php index 69ebc2a..284e54c 100644 --- a/app/Http/Controllers/Api/WorkerJobController.php +++ b/app/Http/Controllers/Api/WorkerJobController.php @@ -18,14 +18,114 @@ class WorkerJobController extends Controller */ public function listJobs(Request $request) { - $jobs = JobPost::where('status', 'active') - ->with(['employer.employerProfile']) - ->latest() - ->get() - ->map(function ($job) { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $query = JobPost::whereIn('status', ['active', 'closed']) + ->with(['employer.employerProfile', 'skills']); + + if ($worker) { + $workerProfession = trim($worker->main_profession); + $workerSkillIds = $worker->skills->pluck('id')->toArray(); + + if ($workerProfession !== '' || !empty($workerSkillIds)) { + $query->where(function($q) use ($workerProfession, $workerSkillIds) { + if ($workerProfession !== '') { + $q->where(\Illuminate\Support\Facades\DB::raw('LOWER(main_profession)'), strtolower($workerProfession)); + } + if (!empty($workerSkillIds)) { + $q->orWhereHas('skills', function ($q2) use ($workerSkillIds) { + $q2->whereIn('skills.id', $workerSkillIds); + }); + } + }); + } + } + + $workerApplications = []; + if ($worker) { + $workerApplications = JobApplication::where('worker_id', $worker->id) + ->pluck('status', 'job_id') + ->toArray(); + } + + // Apply filters + // Filter by job_type / jobType + if ($request->has('job_type') && $request->input('job_type') !== '') { + $query->where('job_type', $request->input('job_type')); + } + if ($request->has('jobType') && $request->input('jobType') !== '') { + $query->where('job_type', $request->input('jobType')); + } + + // Filter by skills + if ($request->has('skills') && $request->input('skills') !== '') { + $skills = $request->input('skills'); + if (is_string($skills)) { + $skills = array_filter(array_map('trim', explode(',', $skills))); + } + if (!empty($skills)) { + $query->whereHas('skills', function ($q) use ($skills) { + $q->where(function($q2) use ($skills) { + $q2->whereIn('skills.id', $skills) + ->orWhereIn('skills.name', $skills); + }); + }); + } + } + + // Filter by salary + if ($request->has('salary_min') && $request->input('salary_min') !== '') { + $query->where('salary', '>=', (float) $request->input('salary_min')); + } + if ($request->has('salary_max') && $request->input('salary_max') !== '') { + $query->where('salary', '<=', (float) $request->input('salary_max')); + } + if ($request->has('salary') && $request->input('salary') !== '') { + $query->where('salary', '>=', (float) $request->input('salary')); + } + + if ($request->has('search') && $request->input('search') !== '') { + $search = $request->input('search'); + $query->where(function($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%") + ->orWhere('location', 'like', "%{$search}%"); + }); + } + + // Apply Sorting + if ($request->has('sortBy') && $request->input('sortBy') !== '') { + $sortBy = strtoupper($request->input('sortBy')); + if ($sortBy === 'LATEST FIRST') { + $query->latest(); + } elseif ($sortBy === 'OLDEST FIRST') { + $query->oldest(); + } elseif ($sortBy === 'PRICE: LOW TO HIGH') { + $query->orderBy('salary', 'asc'); + } elseif ($sortBy === 'PRICE: HIGH TO LOW') { + $query->orderBy('salary', 'desc'); + } else { + $query->latest(); + } + } else { + $query->latest(); + } + + $jobs = $query->get() + ->map(function ($job) use ($worker, $workerApplications) { + $status = 'new'; + if ($worker && isset($workerApplications[$job->id])) { + $status = $workerApplications[$job->id]; + } elseif ($job->status === 'closed') { + $status = 'closed'; + } + return [ 'id' => $job->id, 'title' => $job->title, + 'main_profession' => $job->main_profession, + 'skills' => $job->skills->pluck('name')->toArray(), 'location' => $job->location, 'salary' => (int) $job->salary, 'workers_needed' => $job->workers_needed, @@ -36,6 +136,7 @@ public function listJobs(Request $request) 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', 'posted_by' => $job->employer->name ?? 'Private Sponsor', 'posted_at' => $job->created_at->toIso8601String(), + 'status' => $status, ]; }); @@ -53,8 +154,8 @@ public function listJobs(Request $request) */ public function jobDetails($id) { - $job = JobPost::where('status', 'active') - ->with(['employer.employerProfile']) + $job = JobPost::whereIn('status', ['active', 'closed']) + ->with(['employer.employerProfile', 'skills']) ->find($id); if (!$job) { @@ -64,12 +165,29 @@ public function jobDetails($id) ], 404); } + $worker = request()->attributes->get('worker'); + $status = 'new'; + if ($worker) { + $application = JobApplication::where('worker_id', $worker->id) + ->where('job_id', $job->id) + ->first(); + if ($application) { + $status = $application->status; + } elseif ($job->status === 'closed') { + $status = 'closed'; + } + } elseif ($job->status === 'closed') { + $status = 'closed'; + } + return response()->json([ 'success' => true, 'data' => [ 'job' => [ 'id' => $job->id, 'title' => $job->title, + 'main_profession' => $job->main_profession, + 'skills' => $job->skills->pluck('name')->toArray(), 'location' => $job->location, 'salary' => (int) $job->salary, 'workers_needed' => $job->workers_needed, @@ -80,6 +198,7 @@ public function jobDetails($id) 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', 'posted_by' => $job->employer->name ?? 'Private Sponsor', 'posted_at' => $job->created_at->toIso8601String(), + 'status' => $status, ] ] ]); @@ -121,6 +240,19 @@ public function applyJob(Request $request, $id) 'status' => 'applied' ]); + $employer = $job->employer; + if ($employer) { + $employer->notify(new \App\Notifications\GenericNotification( + "New Job Application", + "A worker (" . ($worker->name ?? 'Candidate') . ") has applied for your job: " . ($job->title ?? 'Job Post'), + [ + 'type' => 'job_application_submitted', + 'job_id' => $job->id, + 'application_id' => $application->id, + ] + )); + } + return response()->json([ 'success' => true, 'message' => 'Successfully applied for the job.', @@ -148,6 +280,7 @@ public function appliedJobs(Request $request) return [ 'application_id' => $app->id, 'status' => $app->status, + 'employer_status' => $app->employer_status, 'applied_at' => $app->created_at->toIso8601String(), 'job' => $job ? [ 'id' => $job->id, @@ -244,6 +377,8 @@ public function employerCreateJob(Request $request) $validator = Validator::make($request->all(), [ 'title' => 'required|string|max:255', + 'main_profession' => 'required|string|max:255', + 'skills' => 'required', 'workers_needed' => 'required|integer|min:1', 'location' => 'required|string|max:255', 'salary' => 'required|numeric|min:0', @@ -264,6 +399,7 @@ public function employerCreateJob(Request $request) $job = JobPost::create([ 'employer_id' => $employer->id, 'title' => $request->title, + 'main_profession' => $request->main_profession, 'workers_needed' => $request->workers_needed, 'job_type' => $request->job_type, 'location' => $request->location, @@ -274,6 +410,27 @@ public function employerCreateJob(Request $request) 'status' => 'active', ]); + // Sync skills + $skillsInput = $request->skills; + $skillIds = []; + if (is_array($skillsInput)) { + foreach ($skillsInput as $skillNameOrId) { + if (is_numeric($skillNameOrId)) { + $skillIds[] = (int)$skillNameOrId; + } else { + $skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]); + $skillIds[] = $skill->id; + } + } + } else if (is_string($skillsInput)) { + $skillsArray = array_filter(array_map('trim', explode(',', $skillsInput))); + foreach ($skillsArray as $name) { + $skill = \App\Models\Skill::firstOrCreate(['name' => $name]); + $skillIds[] = $skill->id; + } + } + $job->skills()->sync($skillIds); + return response()->json([ 'success' => true, 'message' => 'Job posted successfully.', @@ -281,6 +438,8 @@ public function employerCreateJob(Request $request) 'job' => [ 'id' => $job->id, 'title' => $job->title, + 'main_profession' => $job->main_profession, + 'skills' => $job->skills()->pluck('name')->toArray(), 'location' => $job->location, 'salary' => (int) $job->salary, 'workers_needed' => $job->workers_needed, @@ -368,8 +527,17 @@ public function employerUpdateJob(Request $request, $id) ], 404); } + if ($job->applications()->exists()) { + return response()->json([ + 'success' => false, + 'message' => 'This job cannot be edited because workers have already applied for it.' + ], 422); + } + $validator = Validator::make($request->all(), [ 'title' => 'required|string|max:255', + 'main_profession' => 'required|string|max:255', + 'skills' => 'required', 'workers_needed' => 'required|integer|min:1', 'location' => 'required|string|max:255', 'salary' => 'required|numeric|min:0', @@ -390,6 +558,7 @@ public function employerUpdateJob(Request $request, $id) $job->update([ 'title' => $request->title, + 'main_profession' => $request->main_profession, 'workers_needed' => $request->workers_needed, 'job_type' => $request->job_type, 'location' => $request->location, @@ -400,6 +569,27 @@ public function employerUpdateJob(Request $request, $id) 'status' => strtolower($request->status), ]); + // Sync skills + $skillsInput = $request->skills; + $skillIds = []; + if (is_array($skillsInput)) { + foreach ($skillsInput as $skillNameOrId) { + if (is_numeric($skillNameOrId)) { + $skillIds[] = (int)$skillNameOrId; + } else { + $skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]); + $skillIds[] = $skill->id; + } + } + } else if (is_string($skillsInput)) { + $skillsArray = array_filter(array_map('trim', explode(',', $skillsInput))); + foreach ($skillsArray as $name) { + $skill = \App\Models\Skill::firstOrCreate(['name' => $name]); + $skillIds[] = $skill->id; + } + } + $job->skills()->sync($skillIds); + return response()->json([ 'success' => true, 'message' => 'Job updated successfully.', @@ -407,6 +597,8 @@ public function employerUpdateJob(Request $request, $id) 'job' => [ 'id' => $job->id, 'title' => $job->title, + 'main_profession' => $job->main_profession, + 'skills' => $job->skills()->pluck('name')->toArray(), 'location' => $job->location, 'salary' => (int) $job->salary, 'workers_needed' => $job->workers_needed, @@ -496,6 +688,140 @@ public function employerJobApplicants(Request $request, $id) $query->where('status', strtolower($request->status)); } + // Apply filters based on worker attributes + $query->whereHas('worker', function($q) use ($request) { + // Gender filter + if ($request->filled('gender')) { + $q->where('gender', strtolower($request->gender)); + } + + // Nationality filter + if ($request->filled('nationality')) { + $natInput = $request->nationality; + $natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput))); + if (!empty($natsArray)) { + $q->where(function($subQ) use ($natsArray) { + foreach ($natsArray as $n) { + $subQ->orWhere('nationality', 'like', "%{$n}%"); + } + }); + } + } + + // Main Profession filter + if ($request->filled('main_profession')) { + $q->where('main_profession', strtolower($request->main_profession)); + } + + // Age filter (integer or range e.g. "18-25") + if ($request->filled('age')) { + $ageVal = $request->age; + if (str_contains($ageVal, '-')) { + [$minAge, $maxAge] = array_map('intval', explode('-', $ageVal)); + $q->whereBetween('age', [$minAge, $maxAge]); + } else { + $q->where('age', $ageVal); + } + } + + // Experience filter + if ($request->filled('experience')) { + $q->where('experience', 'like', "%{$request->experience}%"); + } + + // Salary range filter + if ($request->filled('salary_min')) { + $q->where('salary', '>=', (float)$request->salary_min); + } + if ($request->filled('salary_max')) { + $q->where('salary', '<=', (float)$request->salary_max); + } + + // Preferred location filter + if ($request->filled('preferred_location')) { + $prefLoc = $request->preferred_location; + $locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc))); + if (!empty($locsArray)) { + $q->where(function($subQ) use ($locsArray) { + foreach ($locsArray as $l) { + $subQ->orWhere('preferred_location', 'like', "%{$l}%"); + } + }); + } + } + + // Job type filter (preferred_job_type) + $jobTypeParam = $request->input('job_type') ?? $request->input('preferred_job_type'); + if ($jobTypeParam) { + $jobTypesArray = is_array($jobTypeParam) ? $jobTypeParam : array_filter(array_map('trim', explode(',', $jobTypeParam))); + if (!empty($jobTypesArray)) { + $q->where(function($subQ) use ($jobTypesArray) { + foreach ($jobTypesArray as $jt) { + $subQ->orWhere('preferred_job_type', 'like', "%{$jt}%"); + } + }); + } + } + + // Accommodation filter (live_in_out) + $accParam = $request->input('live_in_out') ?? $request->input('accommodation_type') ?? $request->input('accomadation_type'); + if ($accParam) { + $accsArray = is_array($accParam) ? $accParam : array_filter(array_map('trim', explode(',', $accParam))); + if (!empty($accsArray)) { + $q->where(function($subQ) use ($accsArray) { + foreach ($accsArray as $a) { + $normA = str_replace(['_', '-'], ' ', $a); + $subQ->orWhere('live_in_out', 'like', "%{$normA}%"); + } + }); + } + } + + // In country filter + if ($request->has('in_country')) { + $inCountryVal = $request->input('in_country'); + if ($inCountryVal !== null && $inCountryVal !== '') { + $isInCountry = filter_var($inCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($isInCountry === null) { + $strVal = strtolower(trim($inCountryVal)); + if ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'yes') { + $isInCountry = true; + } elseif ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'no') { + $isInCountry = false; + } + } + if ($isInCountry !== null) { + $q->where('in_country', $isInCountry); + } + } + } + + // Visa status filter + $visaParam = $request->input('visa_status') ?? $request->input('next_visa_type') ?? $request->input('visa_type'); + if ($visaParam) { + $visasArray = is_array($visaParam) ? $visaParam : array_filter(array_map('trim', explode(',', $visaParam))); + if (!empty($visasArray)) { + $q->where(function($subQ) use ($visasArray) { + foreach ($visasArray as $v) { + $subQ->orWhere('visa_status', 'like', "%{$v}%"); + } + }); + } + } + + // Skills filter + if ($request->filled('skills')) { + $skills = $request->skills; + $skillsArray = is_array($skills) ? $skills : array_filter(array_map('trim', explode(',', $skills))); + if (!empty($skillsArray)) { + $q->whereHas('skills', function($skillQ) use ($skillsArray) { + $skillQ->whereIn('name', $skillsArray) + ->orWhereIn('id', array_filter($skillsArray, 'is_numeric')); + }); + } + } + }); + $sortBy = $request->input('sort_by', 'created_at'); $sortOrder = $request->input('sort_order', 'desc'); @@ -562,7 +888,7 @@ public function employerUpdateApplicationStatus(Request $request, $id) } $validator = Validator::make($request->all(), [ - 'status' => 'required|string|in:applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired', + 'status' => 'required|string|in:applied,review,reviewing,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,hire_requested', 'notes' => 'nullable|string', ]); @@ -587,9 +913,14 @@ public function employerUpdateApplicationStatus(Request $request, $id) } $dbStatus = strtolower($request->status); + if ($dbStatus === 'hired') { + $dbStatus = 'hire_requested'; + } elseif ($dbStatus === 'review' || $dbStatus === 'reviewing') { + $dbStatus = 'applied'; + } - // Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired' for the first time - $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired']; + // Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested' + $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested']; if (in_array($dbStatus, $contactStatuses)) { if (!User::canContactWorker($employer->id, $application->worker_id)) { $activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first(); @@ -614,8 +945,16 @@ public function employerUpdateApplicationStatus(Request $request, $id) 'notes' => $notes, ]; + $empStatus = strtolower($request->status); + if ($empStatus === 'applied' || $empStatus === 'reviewing' || $empStatus === 'review') { + $empStatus = 'review'; + } elseif ($empStatus === 'hire_requested') { + $empStatus = 'hired'; + } + $updateData = [ 'status' => $dbStatus, + 'employer_status' => $empStatus, 'status_history' => $history, ]; @@ -638,12 +977,7 @@ public function employerUpdateApplicationStatus(Request $request, $id) } } - // If hired, also update worker status - if ($dbStatus === 'hired') { - if ($application->worker) { - $application->worker->update(['status' => 'Hired']); - } - } elseif ($dbStatus === 'rejected') { + if ($dbStatus === 'rejected') { if ($application->worker) { $application->worker->update(['status' => 'active']); } @@ -689,6 +1023,7 @@ public function appliedJobDetail(Request $request, $id) 'application' => [ 'id' => $application->id, 'status' => $application->status, + 'employer_status' => $application->employer_status, 'status_history' => $application->status_history ?: [], 'applied_at' => $application->created_at->toIso8601String(), 'job' => $job ? [ @@ -742,7 +1077,164 @@ public function withdrawApplication(Request $request, $id) } /** - * Dispatch FCM Push Notification. + * API for workers to confirm a hire request. + * POST /api/workers/applied-jobs/{id}/confirm-hire + */ + public function confirmHire(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $application = JobApplication::where('worker_id', $worker->id)->find($id); + + if (!$application) { + return response()->json([ + 'success' => false, + 'message' => 'Application not found.' + ], 404); + } + + if (strtolower($application->status) !== 'hire_requested') { + return response()->json([ + 'success' => false, + 'message' => 'This application is not pending confirmation.' + ], 400); + } + + // Update application status + $history = $application->status_history ?: []; + $history[] = [ + 'status' => 'hired', + 'timestamp' => now()->toIso8601String(), + 'notes' => 'Hiring confirmed by worker.', + ]; + + $application->update([ + 'status' => 'hired', + 'status_history' => $history, + 'joining_confirmed_at' => now(), + ]); + + // Update worker status + $worker->update([ + 'status' => 'Hired', + 'availability' => 'Hired' + ]); + + // Create/update the JobOffer record to satisfy the Worker Employer List API + $job = $application->jobPost; + $employer = $job ? $job->employer : null; + if ($employer) { + \App\Models\JobOffer::updateOrCreate( + [ + 'employer_id' => $employer->id, + 'worker_id' => $worker->id, + ], + [ + 'work_date' => $job->start_date ?? now(), + 'location' => $job->location ?? 'Dubai', + 'salary' => $job->salary ?? 0, + 'status' => 'accepted', + 'notes' => $application->notes ?? 'Hired through Job Application', + ] + ); + + // Notify Employer: Hire confirmation + $employer->notify(new \App\Notifications\GenericNotification( + "Hire Confirmation", + "Worker " . ($worker->name ?? "Candidate") . " has confirmed the hiring for '" . ($job->title ?? 'Job Post') . "'.", + [ + 'type' => 'hire_confirmation', + 'job_id' => $job->id, + 'application_id' => $application->id, + ] + )); + } + + // Notify Worker: Hired + $worker->notify(new \App\Notifications\GenericNotification( + "Congratulations! You've been Hired", + "You are now officially hired for: '" . ($job->title ?? 'Job Post') . "'.", + [ + 'type' => 'hired', + 'job_id' => $job->id, + 'application_id' => $application->id, + ] + )); + + return response()->json([ + 'success' => true, + 'message' => 'Hiring confirmed successfully.', + 'data' => [ + 'application' => $application->fresh() + ] + ]); + } + + /** + * API for workers to decline a hire request. + * POST /api/workers/applied-jobs/{id}/decline-hire + */ + public function declineHire(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $application = JobApplication::where('worker_id', $worker->id)->find($id); + + if (!$application) { + return response()->json([ + 'success' => false, + 'message' => 'Application not found.' + ], 404); + } + + if (strtolower($application->status) !== 'hire_requested') { + return response()->json([ + 'success' => false, + 'message' => 'This application is not pending confirmation.' + ], 400); + } + + // Update application status back to declined + $history = $application->status_history ?: []; + $history[] = [ + 'status' => 'declined', + 'timestamp' => now()->toIso8601String(), + 'notes' => 'Hiring declined by worker.', + ]; + + $application->update([ + 'status' => 'declined', + 'status_history' => $history, + ]); + + $job = $application->jobPost; + $employer = $job ? $job->employer : null; + if ($employer) { + // Notify Employer: Hire declined + $employer->notify(new \App\Notifications\GenericNotification( + "Hire Request Declined", + "Worker " . ($worker->name ?? "Candidate") . " has declined the hiring request for '" . ($job->title ?? 'Job Post') . "'.", + [ + 'type' => 'hire_declined', + 'job_id' => $job->id, + 'application_id' => $application->id, + ] + )); + } + + return response()->json([ + 'success' => true, + 'message' => 'Hiring request declined successfully.', + 'data' => [ + 'application' => $application->fresh() + ] + ]); + } + + /** + * Dispatch FCM Push Notification & In-app database notifications. */ private function triggerStatusNotification($application, $status) { @@ -750,63 +1242,63 @@ private function triggerStatusNotification($application, $status) $job = $application->jobPost; $employer = $job ? $job->employer : null; - // When a new application is submitted: notify employer - if ($status === 'applied') { - if ($employer && $employer->fcm_token) { - \App\Services\FCMService::sendPushNotification( - $employer->fcm_token, - "New Job Application", - "A candidate has applied for your job: " . ($job->title ?? 'Job Post'), - [ - 'type' => 'new_job_application', - 'job_id' => $job->id, - 'application_id' => $application->id, - ] - ); - } - return; - } - // When status is updated: notify worker - if ($worker && $worker->fcm_token) { + if ($worker) { $title = "Job Application Update"; $body = ""; + $type = 'job_application_status_update'; switch (strtolower($status)) { case 'shortlisted': $body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post'); + $type = 'shortlisted'; + break; + case 'applied': + case 'review': + case 'reviewing': + $body = "Your application is under review for the job: " . ($job->title ?? 'Job Post'); + $type = 'review'; break; case 'contacted': $body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post'); + $type = 'contacted'; break; case 'interview scheduled': case 'interview_scheduled': $body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post'); + $type = 'interview_scheduled'; break; case 'selected': $body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post'); + $type = 'selected'; break; case 'rejected': $body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected."; + $type = 'rejected'; + break; + case 'hire_requested': + $title = "Action Required: Confirm Hiring"; + $body = "An employer wants to hire you for the job: " . ($job->title ?? 'Job Post') . ". Please confirm."; + $type = 'hire_request'; break; case 'hired': $body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post'); + $type = 'hired'; break; default: $body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status); break; } - \App\Services\FCMService::sendPushNotification( - $worker->fcm_token, + $worker->notify(new \App\Notifications\GenericNotification( $title, $body, [ - 'type' => 'job_application_status_update', + 'type' => $type, 'job_id' => $job->id ?? '', 'application_id' => $application->id, 'status' => $status, ] - ); + )); } } } diff --git a/app/Http/Controllers/Api/WorkerProfileController.php b/app/Http/Controllers/Api/WorkerProfileController.php index 9f5664c..b514c59 100644 --- a/app/Http/Controllers/Api/WorkerProfileController.php +++ b/app/Http/Controllers/Api/WorkerProfileController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\JobOffer; +use App\Models\JobApplication; use App\Models\Worker; use App\Models\WorkerDocument; use App\Models\ProfileView; @@ -68,8 +69,23 @@ public function updateProfile(Request $request) $validator = Validator::make($request->all(), [ 'name' => 'nullable|string|max:255', - 'phone' => 'nullable|string|max:50|unique:workers,phone,' . $worker->id, - 'age' => 'nullable|integer|min:18|max:70', + 'phone' => 'nullable|string|max:50', + 'main_profession' => 'nullable|string|max:100', + 'age' => [ + 'nullable', + function ($attribute, $value, $fail) { + if (is_numeric($value)) { + $intValue = (int)$value; + if ($intValue < 18 || $intValue > 100) { + $fail('The age must be between 18 and 100.'); + } + } else { + if (!in_array($value, ['18-25', '26-35', '36-45', '46-55', '56-60'])) { + $fail('The age must be a valid number or one of the ranges: 18-25, 26-35, 36-45, 46-55, 56-60.'); + } + } + } + ], 'nationality' => 'nullable|string|max:100', 'language' => 'nullable|string', 'salary' => 'nullable|numeric|min:0', @@ -77,7 +93,7 @@ public function updateProfile(Request $request) 'skills' => 'nullable|array', 'skills.*' => 'exists:skills,id', 'in_country' => 'nullable', - 'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa', + 'visa_status' => 'nullable|string', 'preferred_job_type' => 'nullable|string|max:100', 'gender' => 'nullable|string|in:male,female,other', 'live_in_out' => 'nullable|string|in:live_in,live_out', @@ -93,7 +109,6 @@ public function updateProfile(Request $request) ], 'visa' => 'nullable|array', ], [ - 'phone.unique' => 'This mobile number is already registered.', 'passport.passport_number.unique' => 'This passport number is already registered.', ]); @@ -105,6 +120,17 @@ public function updateProfile(Request $request) ], 422); } + if ($request->phone) { + $exists = Worker::findByPhone($request->phone); + if ($exists && $exists->id !== $worker->id) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => ['phone' => ['This mobile number is already registered.']] + ], 422); + } + } + // Document Update Check: Verify passport number matches existing passport number if already verified/saved $existingPassport = $worker->documents()->where('type', 'passport')->first(); if ($existingPassport && $request->has('passport')) { @@ -139,7 +165,8 @@ public function updateProfile(Request $request) 'gender', 'live_in_out', 'preferred_location', - 'fcm_token' + 'fcm_token', + 'main_profession' ]); // Filter out null inputs if we want to support partial updates @@ -471,10 +498,9 @@ public function respondToOffer(Request $request, $id) // Dispatch push notification to employer $employer = $offer->employer; - if ($employer && $employer->fcm_token) { + if ($employer) { $statusMessage = $responseStatus === 'accepted' ? 'accepted' : 'rejected'; - \App\Services\FCMService::sendPushNotification( - $employer->fcm_token, + $employer->notify(new \App\Notifications\GenericNotification( "Job Offer " . ucfirst($statusMessage), "Worker " . ($worker->name ?? "Candidate") . " has " . $statusMessage . " your job offer.", [ @@ -482,7 +508,7 @@ public function respondToOffer(Request $request, $id) 'offer_id' => $offer->id, 'status' => $responseStatus, ] - ); + )); } return response()->json([ @@ -587,13 +613,29 @@ public function getDashboard(Request $request) // 3. Currently working sponsor/employer $currentSponsor = null; + $emp = null; + $hiredAt = null; + $acceptedOffer = JobOffer::where('worker_id', $worker->id) - ->where('status', 'accepted') + ->whereIn(DB::raw('LOWER(status)'), ['accepted', 'hired']) ->with(['employer.employerProfile', 'employer.employerReviews.worker']) ->first(); - if ($acceptedOffer && $acceptedOffer->employer) { + if ($acceptedOffer) { $emp = $acceptedOffer->employer; + $hiredAt = $acceptedOffer->updated_at; + } else { + $hiredApplication = \App\Models\JobApplication::where('worker_id', $worker->id) + ->whereIn(DB::raw('LOWER(status)'), ['hired']) + ->with(['jobPost.employer.employerProfile', 'jobPost.employer.employerReviews.worker']) + ->first(); + if ($hiredApplication && $hiredApplication->jobPost && $hiredApplication->jobPost->employer) { + $emp = $hiredApplication->jobPost->employer; + $hiredAt = $hiredApplication->updated_at; + } + } + + if ($emp) { $empProfile = $emp->employerProfile; $currentSponsor = [ 'id' => $emp->id, @@ -602,9 +644,9 @@ public function getDashboard(Request $request) 'nationality' => $empProfile->nationality ?? 'UAE', 'city' => $empProfile->city ?? 'Dubai', 'email' => $emp->email, - 'phone' => $emp->phone, - 'hired_at' => $acceptedOffer->updated_at->toIso8601String(), - 'hired_at_formatted' => $acceptedOffer->updated_at->format('M d, Y'), + 'phone' => $empProfile->phone ?? $emp->phone, + 'hired_at' => $hiredAt ? $hiredAt->toIso8601String() : null, + 'hired_at_formatted' => $hiredAt ? $hiredAt->format('M d, Y') : null, 'rating' => $emp->rating, 'reviews_count' => $emp->reviews_count, 'reviews' => $emp->employerReviews, @@ -687,45 +729,22 @@ public function getEmployers(Request $request) $worker = $request->attributes->get('worker'); try { - $query = JobOffer::where('worker_id', $worker->id) - ->with(['employer.employerProfile', 'employer.employerReviews.worker']); + // Fetch JobOffers + $offers = JobOffer::where('worker_id', $worker->id) + ->with(['employer.employerProfile', 'employer.employerReviews.worker']) + ->get(); - // Filtering by status - if ($request->filled('status')) { - $statusInput = strtolower($request->status); - if ($statusInput === 'active') { - $query->where('status', 'accepted'); - } else { - $query->where('status', $statusInput); - } - } + // Fetch hired JobApplications + $applications = JobApplication::where('worker_id', $worker->id) + ->where('status', 'hired') + ->with(['jobPost.employer.employerProfile', 'jobPost.employer.employerReviews.worker']) + ->get(); - // Sorting - $sortBy = $request->input('sort_by', 'joining_date'); - $sortOrder = strtolower($request->input('sort_order', 'desc')) === 'asc' ? 'asc' : 'desc'; - - $sortColumn = 'work_date'; - if ($sortBy === 'salary') { - $sortColumn = 'salary'; - } elseif ($sortBy === 'status') { - $sortColumn = 'status'; - } elseif ($sortBy === 'created_at') { - $sortColumn = 'created_at'; - } - - // Always display the current active employer (status = accepted) at the top of the list, - // followed by other employers ordered by the chosen sort column. - $query->orderByRaw("CASE WHEN status = 'accepted' THEN 0 ELSE 1 END ASC") - ->orderBy($sortColumn, $sortOrder); - - $perPage = (int) $request->input('per_page', 15); - $paginator = $query->paginate($perPage); - - $employers = collect($paginator->items())->map(function ($offer) { + // Map JobOffers + $mappedOffers = $offers->map(function ($offer) { $emp = $offer->employer; $empProfile = $emp ? $emp->employerProfile : null; - // Map 'accepted' -> 'Active' (and others to Title Case for nice presentation) $statusFormatted = $offer->status; if (strtolower($offer->status) === 'accepted') { $statusFormatted = 'Active'; @@ -736,10 +755,13 @@ public function getEmployers(Request $request) return [ 'offer_id' => $offer->id, 'joining_date' => $offer->work_date ? $offer->work_date->format('Y-m-d') : null, + 'joining_date_raw' => $offer->work_date, 'location' => $offer->location, 'salary' => (int) $offer->salary, 'status' => $statusFormatted, + 'status_raw' => $offer->status, 'notes' => $offer->notes, + 'created_at' => $offer->created_at, 'employer' => $emp ? [ 'id' => $emp->id, 'name' => $emp->name, @@ -755,15 +777,120 @@ public function getEmployers(Request $request) ]; }); + // Map JobApplications + $mappedApps = $applications->map(function ($app) { + $job = $app->jobPost; + $emp = $job ? $job->employer : null; + $empProfile = $emp ? $emp->employerProfile : null; + + return [ + 'offer_id' => $app->id, + 'joining_date' => $app->updated_at ? $app->updated_at->format('Y-m-d') : null, + 'joining_date_raw' => $app->updated_at, + 'location' => $job->location ?? null, + 'salary' => $job ? (int) $job->salary : 0, + 'status' => 'Active', + 'status_raw' => 'accepted', + 'notes' => $app->notes, + 'created_at' => $app->created_at, + 'employer' => $emp ? [ + 'id' => $emp->id, + 'name' => $emp->name, + 'email' => $emp->email, + 'phone' => $emp->phone, + 'company_name' => $empProfile->company_name ?? 'Private Sponsor', + 'city' => $empProfile->city ?? null, + 'nationality' => $empProfile->nationality ?? null, + 'rating' => $emp->rating, + 'reviews_count' => $emp->reviews_count, + 'reviews' => $emp->employerReviews, + ] : null + ]; + }); + + // Merge and de-duplicate by employer id + $merged = $mappedOffers->concat($mappedApps)->unique(function ($item) { + return $item['employer']['id'] ?? null; + })->values(); + + // Filtering by status + if ($request->filled('status')) { + $statusInput = strtolower($request->status); + $merged = $merged->filter(function ($item) use ($statusInput) { + $rawStatus = strtolower($item['status_raw'] ?? ''); + if ($statusInput === 'active') { + return $rawStatus === 'accepted' || $rawStatus === 'hired'; + } + return $rawStatus === $statusInput; + }); + } + + // Sorting + $sortBy = $request->input('sort_by', 'joining_date'); + $sortOrder = strtolower($request->input('sort_order', 'desc')) === 'asc' ? 'asc' : 'desc'; + + $merged = $merged->sort(function ($a, $b) use ($sortBy, $sortOrder) { + // Priority: status = 'Active' (accepted/hired) comes first + $aActive = $a['status'] === 'Active' ? 0 : 1; + $bActive = $b['status'] === 'Active' ? 0 : 1; + + if ($aActive !== $bActive) { + return $aActive <=> $bActive; + } + + $valA = null; + $valB = null; + + if ($sortBy === 'salary') { + $valA = $a['salary']; + $valB = $b['salary']; + } elseif ($sortBy === 'status') { + $valA = $a['status']; + $valB = $b['status']; + } elseif ($sortBy === 'created_at') { + $valA = $a['created_at']; + $valB = $b['created_at']; + } else { + $valA = $a['joining_date_raw']; + $valB = $b['joining_date_raw']; + } + + if ($valA == $valB) { + return 0; + } + + if ($sortOrder === 'asc') { + return $valA > $valB ? 1 : -1; + } else { + return $valA < $valB ? 1 : -1; + } + }); + + // Pagination + $total = $merged->count(); + $perPage = (int) $request->input('per_page', 15); + $currentPage = (int) $request->input('page', 1); + $lastPage = max(1, (int) ceil($total / $perPage)); + + $pageItems = $merged->slice(($currentPage - 1) * $perPage, $perPage)->values(); + + // Clean up temporary sorting/filtering keys + $employers = $pageItems->map(function ($item) { + unset($item['joining_date_raw']); + unset($item['status_raw']); + unset($item['created_at']); + return $item; + }); + return response()->json([ 'success' => true, 'data' => [ 'employers' => $employers, 'pagination' => [ - 'total' => $paginator->total(), - 'per_page' => $paginator->perPage(), - 'current_page' => $paginator->currentPage(), - 'last_page' => $paginator->lastPage(), + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => $currentPage, + 'last_page' => $lastPage, ] ] ], 200); @@ -976,7 +1103,22 @@ public function getEmployerProfile(Request $request, $id) ->where('worker_id', $worker->id) ->exists(); - if ($hasOffer || $hasApplication || $hasConversation) { + // If the worker has 'Hired' status (case-insensitive) and is hired by this employer + $isHiredByThisEmployer = false; + if (strtolower($worker->status ?? '') === 'hired') { + $isHiredByThisEmployer = \App\Models\JobOffer::where('employer_id', $employer->id) + ->where('worker_id', $worker->id) + ->whereIn(DB::raw('LOWER(status)'), ['accepted', 'hired']) + ->exists() || + \App\Models\JobApplication::where('worker_id', $worker->id) + ->whereHas('jobPost', function ($query) use ($employer) { + $query->where('employer_id', $employer->id); + }) + ->where(DB::raw('LOWER(status)'), 'hired') + ->exists(); + } + + if ($hasOffer || $hasApplication || $hasConversation || $isHiredByThisEmployer) { $hasAccess = true; } diff --git a/app/Http/Controllers/Api/WorkerReviewController.php b/app/Http/Controllers/Api/WorkerReviewController.php index 9d6eda9..a047c74 100644 --- a/app/Http/Controllers/Api/WorkerReviewController.php +++ b/app/Http/Controllers/Api/WorkerReviewController.php @@ -90,13 +90,23 @@ public function addReview(Request $request) ], 403); } - // Verify if completed configured months from joining date - $eligibilityMonths = (int) config('reminders.review_eligibility_months', 3); - if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) { - return response()->json([ - 'success' => false, - 'message' => "You can write a review only after {$eligibilityMonths} months from the joining date." - ], 403); + // Verify if completed configured period from joining date + $eligibilityMinutes = config('reminders.review_eligibility_minutes'); + if ($eligibilityMinutes !== null && $eligibilityMinutes > 0) { + if ($joiningDate->diffInMinutes(now(), false) < $eligibilityMinutes) { + return response()->json([ + 'success' => false, + 'message' => "You can write a review only after {$eligibilityMinutes} minutes from the joining date." + ], 403); + } + } else { + $eligibilityMonths = (int) config('reminders.review_eligibility_months', 3); + if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) { + return response()->json([ + 'success' => false, + 'message' => "You can write a review only after {$eligibilityMonths} months from the joining date." + ], 403); + } } // 2. Prevent duplicate reviews for the same job/employer combination diff --git a/app/Http/Controllers/Employer/AnnouncementController.php b/app/Http/Controllers/Employer/AnnouncementController.php index 34f0ae4..578e770 100644 --- a/app/Http/Controllers/Employer/AnnouncementController.php +++ b/app/Http/Controllers/Employer/AnnouncementController.php @@ -72,6 +72,9 @@ public function store(Request $request) 'event_time' => 'required|string', 'location_details' => 'required|string', 'location_pin' => 'required|string|url', + 'contact_person_name' => 'required|string|max:255', + 'contact_number' => 'required|string|max:50', + 'country_code' => 'nullable|string|max:10', ]); $body = json_encode([ @@ -82,6 +85,9 @@ public function store(Request $request) 'location_details' => $request->location_details, 'location_pin' => $request->location_pin, 'content' => $request->content, + 'contact_person_name' => $request->contact_person_name, + 'contact_number' => $request->contact_number, + 'country_code' => $request->country_code ?? '+971', ]); $sess = session('user'); diff --git a/app/Http/Controllers/Employer/CandidateController.php b/app/Http/Controllers/Employer/CandidateController.php index a7f22f6..a71efef 100644 --- a/app/Http/Controllers/Employer/CandidateController.php +++ b/app/Http/Controllers/Employer/CandidateController.php @@ -54,11 +54,12 @@ public function index(Request $request) if (!$w) return null; // Map DB status to UI friendly status - $status = 'Reviewing'; - if ($app->status === 'hired') $status = 'Hired'; - elseif ($app->status === 'rejected') $status = 'Rejected'; - elseif ($app->status === 'shortlisted' || $app->status === 'offer_sent') $status = 'Offer Sent'; - elseif ($app->status === 'applied') $status = 'Reviewing'; + $status = 'Review'; + $dbStatusLower = strtolower($app->status ?? ''); + if ($dbStatusLower === 'hired') $status = 'Hired'; + elseif ($dbStatusLower === 'rejected') $status = 'Rejected'; + elseif ($dbStatusLower === 'shortlisted' || $dbStatusLower === 'offer_sent' || $dbStatusLower === 'offer sent') $status = 'Shortlisted'; + elseif ($dbStatusLower === 'applied' || $dbStatusLower === 'review' || $dbStatusLower === 'reviewing') $status = 'Review'; else $status = ucfirst($app->status); return [ @@ -83,15 +84,27 @@ public function index(Request $request) ->with('worker') ->get(); - $directWorkers = $directOffers->map(function ($offer) { + // Get worker IDs of all hired job applications for this employer to prevent duplication + $hiredAppWorkerIds = collect($applications) + ->filter(fn($app) => strtolower($app->status ?? '') === 'hired') + ->pluck('worker_id') + ->toArray(); + + $directWorkers = $directOffers->map(function ($offer) use ($hiredAppWorkerIds) { $w = $offer->worker; if (!$w) return null; + // Avoid duplicate hired entries if worker was hired via standard application + $dbOfferStatusLower = strtolower($offer->status ?? ''); + if (($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') && in_array($w->id, $hiredAppWorkerIds)) { + return null; + } + // Map DB offer status to UI friendly status $status = 'Offer Sent'; - if ($offer->status === 'accepted') $status = 'Hired'; - elseif ($offer->status === 'rejected') $status = 'Rejected'; - elseif ($offer->status === 'pending') $status = 'Offer Sent'; + if ($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') $status = 'Hired'; + elseif ($dbOfferStatusLower === 'rejected') $status = 'Rejected'; + elseif ($dbOfferStatusLower === 'pending' || $dbOfferStatusLower === 'offer sent' || $dbOfferStatusLower === 'offer_sent') $status = 'Offer Sent'; return [ 'id' => 'offer_' . $offer->id, // Unique string key prefix to prevent clashes @@ -115,7 +128,7 @@ public function index(Request $request) // Only display Hired candidates in the candidates pipeline $mergedCandidates = array_values(array_filter($mergedCandidates, function ($w) { - return $w && $w['status'] === 'Hired'; + return $w && strtolower($w['status'] ?? '') === 'hired'; })); // Apply filters: preferred_location, job_type, live_in_out, nationality, in_country, visa_status @@ -303,7 +316,7 @@ private function checkJobAccess($user) public function updateStatus(Request $request, $id) { $request->validate([ - 'status' => 'required|string|in:Applied,Reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent', + 'status' => 'required|string|in:Applied,Review,review,Reviewing,reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent,hire_requested', 'notes' => 'nullable|string', ]); @@ -314,18 +327,31 @@ public function updateStatus(Request $request, $id) $dbStatus = 'pending'; if (in_array(strtolower($request->status), ['hired', 'accepted'])) { - $dbStatus = 'accepted'; - $offer->worker->update(['status' => 'Hired']); + // Under two-way confirmation, it must NOT immediately set status to accepted/Hired. + // It should set offer status to 'pending' (waiting for worker confirmation). + $dbStatus = 'pending'; } elseif (in_array(strtolower($request->status), ['rejected'])) { $dbStatus = 'rejected'; - $offer->worker->update(['status' => 'active']); } elseif (in_array(strtolower($request->status), ['offer sent', 'offer_sent', 'pending'])) { $dbStatus = 'pending'; } $offer->update(['status' => $dbStatus]); - return back()->with('success', 'Direct candidate hiring offer status updated successfully to ' . $request->status); + // Notify worker about the hire offer if it is set to pending + if ($dbStatus === 'pending') { + $offer->worker->notify(new \App\Notifications\GenericNotification( + "Direct Hiring Offer", + ($offer->employer->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?", + [ + 'type' => 'direct_hire_request', + 'offer_id' => $offer->id, + 'status' => 'pending', + ] + )); + } + + return back()->with('success', 'Hire request initiated successfully. Awaiting candidate confirmation.'); } // Standard job application status update @@ -343,8 +369,8 @@ public function updateStatus(Request $request, $id) $dbStatus = 'applied'; $statusLower = strtolower($request->status); - if ($statusLower === 'hired') { - $dbStatus = 'hired'; + if ($statusLower === 'hired' || $statusLower === 'hire_requested') { + $dbStatus = 'hire_requested'; } elseif ($statusLower === 'rejected') { $dbStatus = 'rejected'; } elseif ($statusLower === 'shortlisted' || $statusLower === 'offer sent' || $statusLower === 'offer_sent') { @@ -355,12 +381,12 @@ public function updateStatus(Request $request, $id) $dbStatus = 'interview_scheduled'; } elseif ($statusLower === 'selected') { $dbStatus = 'selected'; - } elseif ($statusLower === 'reviewing' || $statusLower === 'applied') { + } elseif ($statusLower === 'reviewing' || $statusLower === 'applied' || $statusLower === 'review') { $dbStatus = 'applied'; } - // Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired' - $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired']; + // Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested' + $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested']; if (in_array($dbStatus, $contactStatuses)) { if (!User::canContactWorker($user->id, $app->worker_id)) { $activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first(); @@ -382,8 +408,16 @@ public function updateStatus(Request $request, $id) 'notes' => $request->input('notes'), ]; + $empStatus = strtolower($request->status); + if ($empStatus === 'applied' || $empStatus === 'reviewing' || $empStatus === 'review') { + $empStatus = 'review'; + } elseif ($empStatus === 'hire_requested') { + $empStatus = 'hired'; + } + $updateData = [ 'status' => $dbStatus, + 'employer_status' => $empStatus, 'status_history' => $history, ]; @@ -406,12 +440,7 @@ public function updateStatus(Request $request, $id) } } - // If hired, also update worker status - if ($dbStatus === 'hired') { - if ($app->worker) { - $app->worker->update(['status' => 'Hired']); - } - } elseif ($dbStatus === 'rejected') { + if ($dbStatus === 'rejected') { if ($app->worker) { $app->worker->update(['status' => 'active']); } @@ -429,63 +458,63 @@ private function triggerStatusNotification($application, $status) $job = $application->jobPost; $employer = $job ? $job->employer : null; - // When a new application is submitted: notify employer - if ($status === 'applied') { - if ($employer && $employer->fcm_token) { - \App\Services\FCMService::sendPushNotification( - $employer->fcm_token, - "New Job Application", - "A candidate has applied for your job: " . ($job->title ?? 'Job Post'), - [ - 'type' => 'new_job_application', - 'job_id' => $job->id, - 'application_id' => $application->id, - ] - ); - } - return; - } - // When status is updated: notify worker - if ($worker && $worker->fcm_token) { + if ($worker) { $title = "Job Application Update"; $body = ""; + $type = 'job_application_status_update'; switch (strtolower($status)) { case 'shortlisted': $body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post'); + $type = 'shortlisted'; + break; + case 'applied': + case 'review': + case 'reviewing': + $body = "Your application is under review for the job: " . ($job->title ?? 'Job Post'); + $type = 'review'; break; case 'contacted': $body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post'); + $type = 'contacted'; break; case 'interview scheduled': case 'interview_scheduled': $body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post'); + $type = 'interview_scheduled'; break; case 'selected': $body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post'); + $type = 'selected'; break; case 'rejected': $body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected."; + $type = 'rejected'; + break; + case 'hire_requested': + $title = "Action Required: Confirm Hiring"; + $body = "An employer wants to hire you for the job: " . ($job->title ?? 'Job Post') . ". Please confirm."; + $type = 'hire_request'; break; case 'hired': $body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post'); + $type = 'hired'; break; default: $body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status); break; } - \App\Services\FCMService::sendPushNotification( - $worker->fcm_token, + $worker->notify(new \App\Notifications\GenericNotification( $title, $body, [ - 'type' => 'job_application_status_update', + 'type' => $type, 'job_id' => $job->id ?? '', 'application_id' => $application->id, 'status' => $status, ] - ); + )); } } } diff --git a/app/Http/Controllers/Employer/DashboardController.php b/app/Http/Controllers/Employer/DashboardController.php index dda629c..1092b2e 100644 --- a/app/Http/Controllers/Employer/DashboardController.php +++ b/app/Http/Controllers/Employer/DashboardController.php @@ -62,6 +62,33 @@ public function index(Request $request) $totalHiredAll = \App\Models\JobOffer::where('status', 'accepted')->count() + \App\Models\JobApplication::where('status', 'hired')->count(); $totalActiveWorkers = \App\Models\Worker::where('status', 'active')->count(); + $totalWorkerProfiles = \App\Models\Worker::count(); + + // Calculate dynamic avg monthly hires based on actual database entries if available, otherwise fallback to 550 + $driver = \Illuminate\Support\Facades\DB::getDriverName(); + $formatExpr = $driver === 'sqlite' ? "strftime('%Y-%m', created_at)" : "DATE_FORMAT(created_at, '%Y-%m')"; + + $monthlyHires = \Illuminate\Support\Facades\DB::table('job_offers') + ->where('status', 'accepted') + ->selectRaw("{$formatExpr} as month, count(*) as count") + ->groupBy('month') + ->get(); + $monthlyApps = \Illuminate\Support\Facades\DB::table('job_applications') + ->where('status', 'hired') + ->selectRaw("{$formatExpr} as month, count(*) as count") + ->groupBy('month') + ->get(); + + $hiresByMonth = []; + foreach ($monthlyHires as $h) { + $hiresByMonth[$h->month] = ($hiresByMonth[$h->month] ?? 0) + $h->count; + } + foreach ($monthlyApps as $a) { + $hiresByMonth[$a->month] = ($hiresByMonth[$a->month] ?? 0) + $a->count; + } + + $avgMonthlyHires = count($hiresByMonth) > 0 ? (array_sum($hiresByMonth) / count($hiresByMonth)) : 0; + $avgMonthlyHires = max(550, round($avgMonthlyHires)); $stats = [ 'shortlisted_count' => $shortlistedCount, @@ -71,6 +98,8 @@ public function index(Request $request) 'hired_count' => $hiredCount, 'total_hired_all' => $totalHiredAll, 'total_active_workers' => $totalActiveWorkers, + 'total_worker_profiles' => $totalWorkerProfiles, + 'avg_monthly_hires' => $avgMonthlyHires, 'recent_failed_payment' => false ]; diff --git a/app/Http/Controllers/Employer/JobController.php b/app/Http/Controllers/Employer/JobController.php index 1562691..1d73de4 100644 --- a/app/Http/Controllers/Employer/JobController.php +++ b/app/Http/Controllers/Employer/JobController.php @@ -139,7 +139,32 @@ public function create() ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); } - return Inertia::render('Employer/Jobs/Create'); + $professions = \App\Models\Skill::orderBy('name') + ->pluck('name') + ->map(function ($name) { + return ucwords($name); + }) + ->unique() + ->values() + ->toArray(); + + if (empty($professions)) { + $professions = [ + 'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook' + ]; + } + + $skills = \App\Models\Skill::all()->map(function ($skill) { + return [ + 'id' => $skill->id, + 'name' => $skill->name, + ]; + }); + + return Inertia::render('Employer/Jobs/Create', [ + 'professions' => $professions, + 'availableSkills' => $skills, + ]); } public function store(Request $request) @@ -156,6 +181,8 @@ public function store(Request $request) $request->validate([ 'title' => 'required|string|max:255', + 'main_profession' => 'required|string|max:255', + 'skills' => 'required', 'workers_needed' => 'required|integer|min:1', 'location' => 'required|string|max:255', 'salary' => 'required|numeric|min:0', @@ -165,9 +192,10 @@ public function store(Request $request) 'requirements' => 'nullable|string', ]); - JobPost::create([ + $job = JobPost::create([ 'employer_id' => $user->id, 'title' => $request->title, + 'main_profession' => $request->main_profession, 'workers_needed' => $request->workers_needed, 'job_type' => $request->job_type, 'location' => $request->location, @@ -178,6 +206,27 @@ public function store(Request $request) 'status' => 'active', ]); + // Sync skills + $skillsInput = $request->skills; + $skillIds = []; + if (is_array($skillsInput)) { + foreach ($skillsInput as $skillNameOrId) { + if (is_numeric($skillNameOrId)) { + $skillIds[] = (int)$skillNameOrId; + } else { + $skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]); + $skillIds[] = $skill->id; + } + } + } else if (is_string($skillsInput)) { + $skillsArray = array_filter(array_map('trim', explode(',', $skillsInput))); + foreach ($skillsArray as $name) { + $skill = \App\Models\Skill::firstOrCreate(['name' => $name]); + $skillIds[] = $skill->id; + } + } + $job->skills()->sync($skillIds); + return redirect()->route('employer.jobs')->with('success', 'Job posted successfully.'); } @@ -242,7 +291,7 @@ public function edit($id) ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); } - $job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail(); + $job = JobPost::where('employer_id', $user->id)->where('id', $id)->with('skills')->firstOrFail(); // Convert start_date format to Y-m-d for date input $jobData = $job->toArray(); @@ -250,8 +299,40 @@ public function edit($id) $jobData['start_date'] = $job->start_date->format('Y-m-d'); } + // Add skills list + $jobData['skills'] = $job->skills->map(function ($s) { + return [ + 'id' => $s->id, + 'name' => $s->name, + ]; + })->toArray(); + + $professions = \App\Models\Skill::orderBy('name') + ->pluck('name') + ->map(function ($name) { + return ucwords($name); + }) + ->unique() + ->values() + ->toArray(); + + if (empty($professions)) { + $professions = [ + 'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook' + ]; + } + + $skills = \App\Models\Skill::all()->map(function ($skill) { + return [ + 'id' => $skill->id, + 'name' => $skill->name, + ]; + }); + return Inertia::render('Employer/Jobs/Edit', [ 'job' => $jobData, + 'professions' => $professions, + 'availableSkills' => $skills, ]); } @@ -269,8 +350,14 @@ public function update(Request $request, $id) $job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail(); + if ($job->applications()->exists()) { + return back()->withErrors(['general' => 'This job cannot be edited because workers have already applied for it.']); + } + $request->validate([ 'title' => 'required|string|max:255', + 'main_profession' => 'required|string|max:255', + 'skills' => 'required', 'workers_needed' => 'required|integer|min:1', 'location' => 'required|string|max:255', 'salary' => 'required|numeric|min:0', @@ -283,6 +370,7 @@ public function update(Request $request, $id) $job->update([ 'title' => $request->title, + 'main_profession' => $request->main_profession, 'workers_needed' => $request->workers_needed, 'job_type' => $request->job_type, 'location' => $request->location, @@ -293,6 +381,27 @@ public function update(Request $request, $id) 'status' => strtolower($request->status), ]); + // Sync skills + $skillsInput = $request->skills; + $skillIds = []; + if (is_array($skillsInput)) { + foreach ($skillsInput as $skillNameOrId) { + if (is_numeric($skillNameOrId)) { + $skillIds[] = (int)$skillNameOrId; + } else { + $skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]); + $skillIds[] = $skill->id; + } + } + } else if (is_string($skillsInput)) { + $skillsArray = array_filter(array_map('trim', explode(',', $skillsInput))); + foreach ($skillsArray as $name) { + $skill = \App\Models\Skill::firstOrCreate(['name' => $name]); + $skillIds[] = $skill->id; + } + } + $job->skills()->sync($skillIds); + return redirect()->route('employer.jobs')->with('success', 'Job updated successfully.'); } diff --git a/app/Http/Controllers/Employer/MessageController.php b/app/Http/Controllers/Employer/MessageController.php index 88079b2..c788f38 100644 --- a/app/Http/Controllers/Employer/MessageController.php +++ b/app/Http/Controllers/Employer/MessageController.php @@ -37,6 +37,21 @@ private function resolveCurrentUser() return null; } + private function getWorkerStatus($employerId, $workerId, $defaultStatus) + { + $pendingOfferOrApp = JobOffer::where('employer_id', $employerId) + ->where('worker_id', $workerId) + ->where('status', 'pending') + ->exists() || + \App\Models\JobApplication::where('worker_id', $workerId) + ->whereHas('jobPost', function($q) use ($employerId) { + $q->where('employer_id', $employerId); + })->where('status', 'hire_requested') + ->exists(); + + return $pendingOfferOrApp ? 'pending_confirmation' : strtolower($defaultStatus ?? 'active'); + } + public static function processWorkerResponse($conv, $worker, $text) { $replyText = strtolower(trim($text)); @@ -105,13 +120,13 @@ public function index(Request $request) ->latest('updated_at') ->get(); - $conversations = $dbConversations->map(function ($conv) { + $conversations = $dbConversations->map(function ($conv) use ($user) { $lastMsg = $conv->messages->last(); return [ 'id' => $conv->id, 'worker_id' => $conv->worker_id, 'worker_name' => $conv->worker->name ?? 'Candidate', - 'worker_status' => strtolower($conv->worker->status ?? 'active'), + 'worker_status' => $this->getWorkerStatus($user->id, $conv->worker_id, $conv->worker->status), 'last_message' => $lastMsg->text ?? 'No messages yet.', 'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false, 'online' => true, @@ -136,13 +151,13 @@ public function show($id) ->latest('updated_at') ->get(); - $conversations = $dbConversations->map(function ($conv) { + $conversations = $dbConversations->map(function ($conv) use ($user) { $lastMsg = $conv->messages->last(); return [ 'id' => $conv->id, 'worker_id' => $conv->worker_id, 'worker_name' => $conv->worker->name ?? 'Candidate', - 'worker_status' => strtolower($conv->worker->status ?? 'active'), + 'worker_status' => $this->getWorkerStatus($user->id, $conv->worker_id, $conv->worker->status), 'last_message' => $lastMsg->text ?? 'No messages yet.', 'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false, 'online' => true, @@ -165,7 +180,7 @@ public function show($id) 'id' => $activeConv->id, 'worker_id' => $activeConv->worker_id, 'worker_name' => $activeConv->worker->name ?? 'Candidate', - 'worker_status' => strtolower($activeConv->worker->status ?? 'active'), + 'worker_status' => $this->getWorkerStatus($user->id, $activeConv->worker_id, $activeConv->worker->status), 'online' => true, 'salary' => ($activeConv->worker->salary ?? 2000) . ' AED', 'nationality' => $activeConv->worker->nationality ?? 'Unknown', @@ -182,10 +197,59 @@ public function show($id) ]; })->toArray(); + $application = \App\Models\JobApplication::where('worker_id', $activeConv->worker_id) + ->whereHas('jobPost', function($q) use ($user) { + $q->where('employer_id', $user->id); + }) + ->latest('id') + ->first(); + + if (!$application) { + $jobPost = \App\Models\JobPost::where('employer_id', $user->id)->latest('id')->first(); + if (!$jobPost) { + $jobPost = \App\Models\JobPost::create([ + 'employer_id' => $user->id, + 'title' => 'General Helper', + 'location' => 'Dubai', + 'salary' => 2000, + 'job_type' => 'full-time', + 'status' => 'open', + 'start_date' => now()->format('Y-m-d'), + 'description' => 'General domestic helper position.', + 'requirements' => 'General helper requirements.', + ]); + } + $application = \App\Models\JobApplication::create([ + 'job_id' => $jobPost->id, + 'worker_id' => $activeConv->worker_id, + 'status' => 'applied', + 'employer_status' => 'review', + 'status_history' => [ + [ + 'status' => 'applied', + 'timestamp' => now()->toIso8601String(), + 'notes' => 'Application created via chat conversation.', + ] + ], + ]); + } + + $applicationData = null; + if ($application) { + $applicationData = [ + 'application_id' => $application->id, + 'status' => $application->status, + 'notes' => $application->notes, + 'status_history' => $application->status_history ?: [], + 'name' => $activeConv->worker->name ?? 'Candidate', + ]; + } + return Inertia::render('Employer/Messages/Show', [ 'conversations' => $conversations, 'conversation' => $conversationData, 'initialMessages' => $initialMessages, + 'application' => $applicationData, ]); } diff --git a/app/Http/Controllers/Employer/WorkerController.php b/app/Http/Controllers/Employer/WorkerController.php index 4b13faa..13a6b7d 100644 --- a/app/Http/Controllers/Employer/WorkerController.php +++ b/app/Http/Controllers/Employer/WorkerController.php @@ -365,6 +365,16 @@ public function show($id) $visaStatus = $w->visa_status; + $pendingOfferOrApp = JobOffer::where('employer_id', $user ? $user->id : 0) + ->where('worker_id', $w->id) + ->where('status', 'pending') + ->exists() || + \App\Models\JobApplication::where('worker_id', $w->id) + ->whereHas('jobPost', function($q) use ($user) { + $q->where('employer_id', $user ? $user->id : 0); + })->where('status', 'hire_requested') + ->exists(); + $worker = [ 'id' => $w->id, 'name' => $w->name, @@ -392,21 +402,13 @@ public function show($id) 'reviews' => $reviews, 'similar_workers' => $similarWorkers, 'status' => $w->status, - 'availability_status' => $w->status, + 'availability_status' => $pendingOfferOrApp ? 'Pending Confirmation' : ($w->status === 'Hired' ? 'Hired' : $w->status), 'document_expiry_status' => $w->document_expiry_status, 'document_expiry_days' => $w->document_expiry_days, 'visa_expiry_date' => $w->visa_expiry_date, 'in_country' => (bool) $w->in_country, 'preferred_location' => $w->preferred_location, 'documents' => $w->documents->map(function ($doc) { - $ocrData = $doc->ocr_data; - if ($doc->type === 'visa' && is_array($ocrData)) { - unset($ocrData['file_number']); - unset($ocrData['name']); - unset($ocrData['passport_number']); - unset($ocrData['accompanied_by']); - unset($ocrData['valid_until']); - } return [ 'id' => $doc->id, 'type' => $doc->type, @@ -415,7 +417,7 @@ public function show($id) 'expiry_date' => $doc->expiry_date, 'ocr_accuracy' => $doc->ocr_accuracy, 'file_path' => $doc->file_path ? url($doc->file_path) : null, - 'ocr_data' => $ocrData, + 'ocr_data' => $doc->ocr_data, ]; })->toArray(), ]; @@ -479,17 +481,18 @@ public function markHired(Request $request, $id) } $worker = Worker::findOrFail($id); - $worker->update([ - 'status' => 'Hired', - ]); + + // Do NOT update worker status to 'Hired' directly. // Check if there is an existing job offer for this employer and worker $offer = JobOffer::where('employer_id', $employerId) ->where('worker_id', $worker->id) ->first(); + $application = null; + if ($offer) { - $offer->update(['status' => 'accepted']); + $offer->update(['status' => 'pending']); } else { // Also check if there's an existing job application $jobIds = \App\Models\JobPost::where('employer_id', $employerId)->pluck('id'); @@ -498,21 +501,53 @@ public function markHired(Request $request, $id) ->first(); if ($application) { - $application->update(['status' => 'hired']); + $application->update(['status' => 'hire_requested']); + + $history = $application->status_history ?: []; + $history[] = [ + 'status' => 'hire_requested', + 'timestamp' => now()->toIso8601String(), + 'notes' => 'Hire request initiated by employer.', + ]; + $application->update(['status_history' => $history]); } else { - // Create a new direct job offer with status accepted - JobOffer::create([ + // Create a new direct job offer with status pending + $offer = JobOffer::create([ 'employer_id' => $employerId, 'worker_id' => $worker->id, 'work_date' => now()->format('Y-m-d'), 'location' => 'Dubai', 'salary' => $worker->salary, - 'notes' => 'Direct sponsoring matching finalized.', - 'status' => 'accepted', + 'notes' => 'Direct sponsoring matching initiated.', + 'status' => 'pending', ]); } } - return back()->with('success', 'Worker successfully marked as Hired!'); + // Notify the worker + if ($offer) { + $worker->notify(new \App\Notifications\GenericNotification( + "Direct Hiring Offer", + ($user->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?", + [ + 'type' => 'direct_hire_request', + 'offer_id' => $offer->id, + 'status' => 'pending', + ] + )); + } else { + $worker->notify(new \App\Notifications\GenericNotification( + "Action Required: Confirm Hiring", + "Employer " . ($user->name ?? "Employer") . " wants to hire you for '" . ($application->jobPost->title ?? 'Job Post') . "'. Please confirm.", + [ + 'type' => 'hire_request', + 'job_id' => $application->jobPost->id ?? '', + 'application_id' => $application->id, + 'status' => 'hire_requested', + ] + )); + } + + return back()->with('success', 'Hire request initiated successfully! Awaiting candidate confirmation.'); } } diff --git a/app/Models/JobApplication.php b/app/Models/JobApplication.php index dd67e81..7bff8f7 100644 --- a/app/Models/JobApplication.php +++ b/app/Models/JobApplication.php @@ -13,6 +13,7 @@ class JobApplication extends Model 'job_id', 'worker_id', 'status', + 'employer_status', 'notes', 'status_history', 'joining_confirmed_at', diff --git a/app/Models/JobPost.php b/app/Models/JobPost.php index 444e467..f014fce 100644 --- a/app/Models/JobPost.php +++ b/app/Models/JobPost.php @@ -10,9 +10,38 @@ class JobPost extends Model { use HasFactory, SoftDeletes; + protected static function booted() + { + static::updated(function ($jobPost) { + if ($jobPost->wasChanged('status')) { + $oldStatus = $jobPost->getOriginal('status'); + $newStatus = $jobPost->status; + + // Send push notification to all workers who applied to this job + $applications = $jobPost->applications()->with('worker')->get(); + foreach ($applications as $app) { + $worker = $app->worker; + if ($worker) { + $worker->notify(new \App\Notifications\GenericNotification( + "Job Status Changed", + "The status of the job '" . $jobPost->title . "' has been changed from '" . ucfirst($oldStatus) . "' to '" . ucfirst($newStatus) . "'.", + [ + 'type' => 'job_status_change', + 'job_id' => $jobPost->id, + 'old_status' => $oldStatus, + 'new_status' => $newStatus, + ] + )); + } + } + } + }); + } + protected $fillable = [ 'employer_id', 'title', + 'main_profession', 'workers_needed', 'job_type', 'location', @@ -34,6 +63,10 @@ public function employer() return $this->belongsTo(User::class, 'employer_id'); } + public function skills() + { + return $this->belongsToMany(Skill::class, 'job_post_skills', 'job_post_id', 'skill_id'); + } public function applications() { diff --git a/app/Models/Worker.php b/app/Models/Worker.php index bbf1926..ff11003 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -11,6 +11,27 @@ class Worker extends Model { use HasFactory, SoftDeletes, Notifiable; + protected static function booted() + { + static::updated(function ($worker) { + if ($worker->wasChanged('status')) { + $oldStatus = $worker->getOriginal('status'); + $newStatus = $worker->status; + + $worker->notify(new \App\Notifications\GenericNotification( + "Profile Status Update", + "Your profile status has changed from '" . ucfirst($oldStatus) . "' to '" . ucfirst($newStatus) . "'.", + [ + 'type' => 'worker_status_change', + 'worker_id' => $worker->id, + 'old_status' => $oldStatus, + 'new_status' => $newStatus, + ] + )); + } + }); + } + protected $fillable = [ 'name', 'email', @@ -215,4 +236,36 @@ public function profileViews() { return $this->hasMany(ProfileView::class); } + + public static function findByPhone($phone) + { + if (empty($phone)) { + return null; + } + + $cleanInput = ltrim(preg_replace('/\D/', '', $phone), '0'); + if (empty($cleanInput)) { + return null; + } + + // If input is short, fall back to direct query + if (strlen($cleanInput) < 7) { + return self::where('phone', $phone)->first(); + } + + $suffix = substr($cleanInput, -7); + $candidates = self::whereRaw("REPLACE(REPLACE(phone, ' ', ''), '+', '') LIKE ?", ['%' . $suffix])->get(); + + foreach ($candidates as $candidate) { + $cleanCandidate = ltrim(preg_replace('/\D/', '', $candidate->phone), '0'); + if ($cleanInput === $cleanCandidate || + str_ends_with($cleanInput, $cleanCandidate) || + str_ends_with($cleanCandidate, $cleanInput)) { + return $candidate; + } + } + + return null; + } } + diff --git a/app/Notifications/GenericNotification.php b/app/Notifications/GenericNotification.php new file mode 100644 index 0000000..dc30a30 --- /dev/null +++ b/app/Notifications/GenericNotification.php @@ -0,0 +1,65 @@ +title = $title; + $this->body = $body; + $this->data = $data; + } + + /** + * Get the notification's delivery channels. + */ + public function via($notifiable): array + { + $channels = ['database']; + if (!empty($notifiable->fcm_token)) { + $channels[] = FcmChannel::class; + } + return $channels; + } + + /** + * Get the array representation of the notification for in-app database storage. + */ + public function toArray($notifiable): array + { + return array_merge([ + 'title' => $this->title, + 'body' => $this->body, + ], $this->data); + } + + /** + * Get the push notification representation. + */ + public function toFcm($notifiable): array + { + return [ + 'token' => $notifiable->fcm_token, + 'title' => $this->title, + 'body' => $this->body, + 'data' => array_merge([ + 'title' => $this->title, + 'body' => $this->body, + ], $this->data), + ]; + } +} diff --git a/app/Services/FCMService.php b/app/Services/FCMService.php index f6d3c00..e09052c 100644 --- a/app/Services/FCMService.php +++ b/app/Services/FCMService.php @@ -59,7 +59,7 @@ public static function getAccessToken() $base64UrlSignature = self::base64UrlEncode($signature); $jwtToken = $signatureInput . '.' . $base64UrlSignature; - $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + $response = Http::withoutVerifying()->asForm()->post('https://oauth2.googleapis.com/token', [ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwtToken, ]); @@ -140,7 +140,7 @@ public static function sendPushNotification($token, $title, $body, array $data = $payload['message']['data'] = $formattedData; } - $response = Http::withToken($accessToken) + $response = Http::withoutVerifying()->withToken($accessToken) ->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", $payload); if ($response->failed()) { diff --git a/check_doc.php b/check_doc.php new file mode 100644 index 0000000..fdeaac5 --- /dev/null +++ b/check_doc.php @@ -0,0 +1,12 @@ +make(Illuminate\Contracts\Console\Kernel::class); +$kernel->bootstrap(); + +$doc = \App\Models\WorkerDocument::where('number', 'P666382')->first(); +if ($doc) { + print_r($doc->getAttributes()); +} else { + echo "NOT FOUND\n"; +} diff --git a/config/reminders.php b/config/reminders.php index cdd7dc1..b06754a 100644 --- a/config/reminders.php +++ b/config/reminders.php @@ -3,6 +3,7 @@ return [ 'worker_no_response_reminder_days' => env('WORKER_NO_RESPONSE_REMINDER_DAYS', 14), 'review_eligibility_months' => env('REVIEW_ELIGIBILITY_MONTHS', 3), + 'review_eligibility_minutes' => env('REVIEW_ELIGIBILITY_MINUTES'), 'review_edit_period_days' => env('REVIEW_EDIT_PERIOD_DAYS', 7), 'employer_hire_confirm_reminder_days' => env('EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS', '3,7'), 'worker_join_confirm_reminder_days' => env('WORKER_JOIN_CONFIRM_REMINDER_DAYS', '3,7,1'), diff --git a/database/migrations/2026_07_06_091221_add_main_profession_and_skills_to_job_posts_table.php b/database/migrations/2026_07_06_091221_add_main_profession_and_skills_to_job_posts_table.php new file mode 100644 index 0000000..263822e --- /dev/null +++ b/database/migrations/2026_07_06_091221_add_main_profession_and_skills_to_job_posts_table.php @@ -0,0 +1,37 @@ +string('main_profession')->nullable()->after('title'); + }); + + Schema::create('job_post_skills', function (Blueprint $table) { + $table->id(); + $table->foreignId('job_post_id')->constrained('job_posts')->onDelete('cascade'); + $table->foreignId('skill_id')->constrained('skills')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('job_post_skills'); + + Schema::table('job_posts', function (Blueprint $table) { + $table->dropColumn('main_profession'); + }); + } +}; diff --git a/database/migrations/2026_07_06_144202_add_employer_status_to_job_applications_table.php b/database/migrations/2026_07_06_144202_add_employer_status_to_job_applications_table.php new file mode 100644 index 0000000..0fb158a --- /dev/null +++ b/database/migrations/2026_07_06_144202_add_employer_status_to_job_applications_table.php @@ -0,0 +1,28 @@ +string('employer_status')->nullable()->after('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('job_applications', function (Blueprint $table) { + $table->dropColumn('employer_status'); + }); + } +}; diff --git a/database/migrations/2026_07_07_125455_change_age_to_string_in_workers_table.php b/database/migrations/2026_07_07_125455_change_age_to_string_in_workers_table.php new file mode 100644 index 0000000..d3ff5d3 --- /dev/null +++ b/database/migrations/2026_07_07_125455_change_age_to_string_in_workers_table.php @@ -0,0 +1,25 @@ +string('age')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workers', function (Blueprint $table) { + $table->integer('age')->nullable(false)->change(); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index a99a731..d9591e6 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -32,6 +32,10 @@ public function run(): void SkillSeeder::class, AdminSeeder::class, NationalitySeeder::class, + WorkerSeeder::class, + EmployerProfileSeeder::class, + JobPostSeeder::class, + ConversationSeeder::class, ]); } } diff --git a/phpunit.xml b/phpunit.xml index f0048c7..abb2081 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -36,6 +36,7 @@ + diff --git a/public/swagger.json b/public/swagger.json index bb228af..78a437c 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -1017,9 +1017,16 @@ "description": "Nationality of the worker." }, "age": { - "type": "integer", - "example": 28, - "description": "Age of the worker." + "type": "string", + "enum": [ + "18-25", + "26-35", + "36-45", + "46-55", + "56-60" + ], + "example": "26-35", + "description": "Age range of the worker." }, "experience": { "type": "string", @@ -4138,6 +4145,56 @@ ], "summary": "List Available Jobs", "description": "Returns a list of all active job postings available for workers to discover and apply.", + "parameters": [ + { + "name": "job_type", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Direct filter by exact job type (e.g., 'Full Time', 'Part Time', 'Contract')." + }, + { + "name": "skills", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of skill names or IDs to filter by (e.g. 'Cooking,Cleaning')." + }, + { + "name": "salary_min", + "in": "query", + "schema": { + "type": "number" + }, + "description": "Minimum salary filter." + }, + { + "name": "salary_max", + "in": "query", + "schema": { + "type": "number" + }, + "description": "Maximum salary filter." + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "description": "General search query for title, description, or location." + }, + { + "name": "sortBy", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Sorting order (e.g. 'LATEST FIRST', 'OLDEST FIRST', 'PRICE: LOW TO HIGH', 'PRICE: HIGH TO LOW')." + } + ], "security": [ { "bearerAuth": [] @@ -4680,7 +4737,10 @@ "event_date", "event_time", "location_details", - "location_pin" + "location_pin", + "contact_person_name", + "contact_number", + "country_code" ], "properties": { "title": { @@ -4712,6 +4772,18 @@ "type": "string", "format": "uri", "example": "https://maps.app.goo.gl/xyz" + }, + "contact_person_name": { + "type": "string", + "example": "Jane Doe" + }, + "contact_number": { + "type": "string", + "example": "551234567" + }, + "country_code": { + "type": "string", + "example": "+971" } } } @@ -7030,6 +7102,141 @@ "schema": { "type": "integer" } + }, + { + "name": "search", + "in": "query", + "description": "Search term matching worker name or nationality.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "Filter by job application status (applied, shortlisted, hired, etc.).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "gender", + "in": "query", + "description": "Filter by worker gender (male, female, other).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "nationality", + "in": "query", + "description": "Filter by worker nationality (comma-separated list or array).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "main_profession", + "in": "query", + "description": "Filter by worker main profession.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "description": "Filter by worker age (exact number or range, e.g. 18-25).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "experience", + "in": "query", + "description": "Filter by worker experience.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "salary_min", + "in": "query", + "description": "Filter by worker minimum expected monthly salary.", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "salary_max", + "in": "query", + "description": "Filter by worker maximum expected monthly salary.", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "preferred_location", + "in": "query", + "description": "Filter by worker preferred location (comma-separated list or array).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "job_type", + "in": "query", + "description": "Filter by worker preferred job type (full-time, part-time, contract, etc.).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "live_in_out", + "in": "query", + "description": "Filter by worker accommodation type (live_in, live_out).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "in_country", + "in": "query", + "description": "Filter by worker presence in country (true/false, yes/no).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "visa_status", + "in": "query", + "description": "Filter by worker visa status (Tourist Visa, Residence Visa, etc.).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "skills", + "in": "query", + "description": "Filter by worker skills (comma-separated names or IDs).", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -7362,33 +7569,106 @@ "employer": { "type": "object", "properties": { - "id": { "type": "integer", "example": 1 }, - "name": { "type": "string", "example": "Jane Sponsor" }, - "email": { "type": "string", "nullable": true, "example": "sponsor@example.com" }, - "phone": { "type": "string", "nullable": true, "example": "+971501112222" }, - "company_name": { "type": "string", "example": "Sponsor Co" }, - "city": { "type": "string", "nullable": true, "example": "Dubai" }, - "district": { "type": "string", "nullable": true, "example": "Dubai Marina" }, - "nationality": { "type": "string", "nullable": true, "example": "Emirati" }, - "address": { "type": "string", "nullable": true, "example": "123 Street" }, - "accommodation": { "type": "string", "nullable": true, "example": "Provided" }, - "language": { "type": "string", "nullable": true, "example": "English" }, - "profile_photo_url": { "type": "string", "nullable": true, "example": "https://example.com/photo.jpg" }, - "rating": { "type": "number", "format": "float", "example": 4.5 }, - "reviews_count": { "type": "integer", "example": 12 }, + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "Jane Sponsor" + }, + "email": { + "type": "string", + "nullable": true, + "example": "sponsor@example.com" + }, + "phone": { + "type": "string", + "nullable": true, + "example": "+971501112222" + }, + "company_name": { + "type": "string", + "example": "Sponsor Co" + }, + "city": { + "type": "string", + "nullable": true, + "example": "Dubai" + }, + "district": { + "type": "string", + "nullable": true, + "example": "Dubai Marina" + }, + "nationality": { + "type": "string", + "nullable": true, + "example": "Emirati" + }, + "address": { + "type": "string", + "nullable": true, + "example": "123 Street" + }, + "accommodation": { + "type": "string", + "nullable": true, + "example": "Provided" + }, + "language": { + "type": "string", + "nullable": true, + "example": "English" + }, + "profile_photo_url": { + "type": "string", + "nullable": true, + "example": "https://example.com/photo.jpg" + }, + "rating": { + "type": "number", + "format": "float", + "example": 4.5 + }, + "reviews_count": { + "type": "integer", + "example": 12 + }, "review_summary": { "type": "object", "properties": { - "total": { "type": "integer", "example": 12 }, - "average": { "type": "number", "example": 4.5 }, + "total": { + "type": "integer", + "example": 12 + }, + "average": { + "type": "number", + "example": 4.5 + }, "stars": { "type": "object", "properties": { - "5": { "type": "integer", "example": 8 }, - "4": { "type": "integer", "example": 2 }, - "3": { "type": "integer", "example": 1 }, - "2": { "type": "integer", "example": 1 }, - "1": { "type": "integer", "example": 0 } + "5": { + "type": "integer", + "example": 8 + }, + "4": { + "type": "integer", + "example": 2 + }, + "3": { + "type": "integer", + "example": 1 + }, + "2": { + "type": "integer", + "example": 1 + }, + "1": { + "type": "integer", + "example": 0 + } } } } @@ -7398,15 +7678,45 @@ "items": { "type": "object", "properties": { - "id": { "type": "integer", "example": 5 }, - "title": { "type": "string", "example": "Housekeeper Needed" }, - "location": { "type": "string", "example": "Dubai Marina" }, - "salary": { "type": "integer", "example": 2500 }, - "job_type": { "type": "string", "example": "Full Time" }, - "start_date": { "type": "string", "format": "date", "example": "2026-08-01" }, - "description": { "type": "string", "example": "Clean house" }, - "requirements": { "type": "string", "nullable": true, "example": "Experience required" }, - "posted_at": { "type": "string", "format": "date-time", "example": "2026-07-04T12:00:00Z" } + "id": { + "type": "integer", + "example": 5 + }, + "title": { + "type": "string", + "example": "Housekeeper Needed" + }, + "location": { + "type": "string", + "example": "Dubai Marina" + }, + "salary": { + "type": "integer", + "example": 2500 + }, + "job_type": { + "type": "string", + "example": "Full Time" + }, + "start_date": { + "type": "string", + "format": "date", + "example": "2026-08-01" + }, + "description": { + "type": "string", + "example": "Clean house" + }, + "requirements": { + "type": "string", + "nullable": true, + "example": "Experience required" + }, + "posted_at": { + "type": "string", + "format": "date-time", + "example": "2026-07-04T12:00:00Z" + } } } } @@ -7420,17 +7730,42 @@ "items": { "type": "object", "properties": { - "id": { "type": "integer", "example": 1 }, - "rating": { "type": "integer", "example": 5 }, - "title": { "type": "string", "example": "Great employer" }, - "comment": { "type": "string", "example": "Highly recommended!" }, - "created_at": { "type": "string", "format": "date-time", "example": "2026-07-04T12:00:00Z" }, + "id": { + "type": "integer", + "example": 1 + }, + "rating": { + "type": "integer", + "example": 5 + }, + "title": { + "type": "string", + "example": "Great employer" + }, + "comment": { + "type": "string", + "example": "Highly recommended!" + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2026-07-04T12:00:00Z" + }, "worker": { "type": "object", "properties": { - "id": { "type": "integer", "example": 2 }, - "name": { "type": "string", "example": "Worker Name" }, - "nationality": { "type": "string", "example": "Filipino" } + "id": { + "type": "integer", + "example": 2 + }, + "name": { + "type": "string", + "example": "Worker Name" + }, + "nationality": { + "type": "string", + "example": "Filipino" + } } } } @@ -7439,10 +7774,22 @@ "pagination": { "type": "object", "properties": { - "total": { "type": "integer", "example": 1 }, - "per_page": { "type": "integer", "example": 10 }, - "current_page": { "type": "integer", "example": 1 }, - "last_page": { "type": "integer", "example": 1 } + "total": { + "type": "integer", + "example": 1 + }, + "per_page": { + "type": "integer", + "example": 10 + }, + "current_page": { + "type": "integer", + "example": 1 + }, + "last_page": { + "type": "integer", + "example": 1 + } } } } @@ -9068,8 +9415,16 @@ "example": "Egypt" }, "age": { - "type": "integer", - "example": 28 + "type": "string", + "enum": [ + "18-25", + "26-35", + "36-45", + "46-55", + "56-60" + ], + "example": "26-35", + "description": "Age range of the worker." }, "salary": { "type": "string", diff --git a/resources/js/Layouts/EmployerLayout.jsx b/resources/js/Layouts/EmployerLayout.jsx index 19ea9f0..c523b4e 100644 --- a/resources/js/Layouts/EmployerLayout.jsx +++ b/resources/js/Layouts/EmployerLayout.jsx @@ -173,7 +173,7 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
M
- {t('employer_portal', 'Employer Portal')} + {t('employer_portal', 'Sponsor Portal')}
@@ -201,7 +201,7 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
M
- {t('employer_portal', 'Employer Portal')} + {t('employer_portal', 'Sponsor Portal')}
{/* Subscription Status Pill */} @@ -375,7 +375,7 @@ export default function EmployerLayout({ children, title, fullPage = false }) {