From f1ac2c8436639fa192cfc8b67a53dc571ed8e9d7 Mon Sep 17 00:00:00 2001 From: mohanmd Date: Fri, 3 Jul 2026 14:04:03 +0530 Subject: [PATCH] sponsor mobile number in event, worker api field --- .env.example | 8 + app/Console/Commands/SendReminders.php | 195 +++- .../Controllers/Admin/WorkerController.php | 2 +- .../Api/EmployerNotificationController.php | 113 +++ .../Api/EmployerReviewController.php | 62 ++ .../Api/EmployerWorkerController.php | 4 +- .../Controllers/Api/SponsorController.php | 38 +- .../Controllers/Api/WorkerAuthController.php | 17 +- .../Api/WorkerNotificationController.php | 113 +++ .../Api/WorkerProfileController.php | 117 ++- .../Api/WorkerReviewController.php | 20 +- .../Employer/CandidateController.php | 9 + .../Controllers/Employer/WorkerController.php | 2 +- app/Models/Worker.php | 42 +- .../EmployerReviewReminderNotification.php | 103 +++ .../WorkerNoResponseNotification.php | 108 +++ .../WorkerReviewReminderNotification.php | 103 +++ config/reminders.php | 10 + phpunit.xml | 8 + public/swagger.json | 850 +++++++++++++++++- .../js/Pages/Employer/SelectedCandidates.jsx | 37 +- resources/js/Pages/Employer/Workers/Index.jsx | 2 - routes/api.php | 17 + tests/Feature/EmployerWorkerFilterApiTest.php | 23 +- tests/Feature/EmployerWorkerWebFilterTest.php | 60 +- tests/Feature/NewRemindersAndSettingsTest.php | 369 ++++++++ tests/Feature/NotificationApiTest.php | 248 +++++ tests/Feature/SponsorAuthApiTest.php | 103 ++- tests/Feature/WorkerEmployersApiTest.php | 239 +++++ tests/Feature/WorkerJourneyApiTest.php | 6 +- 30 files changed, 2946 insertions(+), 82 deletions(-) create mode 100644 app/Http/Controllers/Api/EmployerNotificationController.php create mode 100644 app/Http/Controllers/Api/WorkerNotificationController.php create mode 100644 app/Notifications/EmployerReviewReminderNotification.php create mode 100644 app/Notifications/WorkerNoResponseNotification.php create mode 100644 app/Notifications/WorkerReviewReminderNotification.php create mode 100644 config/reminders.php create mode 100644 tests/Feature/NewRemindersAndSettingsTest.php create mode 100644 tests/Feature/NotificationApiTest.php create mode 100644 tests/Feature/WorkerEmployersApiTest.php diff --git a/.env.example b/.env.example index 35836ad..f7d5f88 100644 --- a/.env.example +++ b/.env.example @@ -69,3 +69,11 @@ EMIRATES_BACK_API_URL= SPONSOR_LICENSE_API_URL= VITE_APP_NAME="${APP_NAME}" + +# Notification Reminder Settings +WORKER_NO_RESPONSE_REMINDER_DAYS=14 +REVIEW_ELIGIBILITY_MONTHS=3 +REVIEW_EDIT_PERIOD_DAYS=7 +EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS=3 +WORKER_JOIN_CONFIRM_REMINDER_DAYS=3 +DOCUMENT_EXPIRY_REMINDER_DAYS=30,7,1 diff --git a/app/Console/Commands/SendReminders.php b/app/Console/Commands/SendReminders.php index 519e4ab..692ad8c 100644 --- a/app/Console/Commands/SendReminders.php +++ b/app/Console/Commands/SendReminders.php @@ -46,17 +46,22 @@ public function handle() $this->info('6. Checking Pending Direct Job Offers...'); $this->checkJobOffers(); + $this->info('7. Checking Worker No Response...'); + $this->checkWorkerNoResponse(); + + $this->info('8. Checking Review Reminders...'); + $this->checkReviewReminders(); + $this->info('Automated reminder system run complete!'); } - private function getReminderLevel(int $days): ?string + private function getReminderLevel(int $days, string $configKey, string $default): ?string { - if ($days === 7) { - return '7_days'; - } elseif ($days === 3) { - return '3_days'; - } elseif ($days === 1) { - return '1_day'; + $value = config($configKey, $default); + $configuredDays = array_map('intval', array_filter(explode(',', $value), 'strlen')); + + if (in_array($days, $configuredDays)) { + return $days === 1 ? '1_day' : "{$days}_days"; } return null; } @@ -96,7 +101,7 @@ private function checkWorkerDocuments() $expiry = Carbon::parse($doc->expiry_date); $days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false); - $level = $this->getReminderLevel($days); + $level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1'); if (!$level) continue; if ($this->alreadySent($worker, 'document_expiry', $doc, $level)) continue; @@ -123,7 +128,7 @@ private function checkEmployerProfiles() $expiry = Carbon::parse($profile->emirates_id_expiry); $days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false); - $level = $this->getReminderLevel($days); + $level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1'); if (!$level) continue; if ($this->alreadySent($user, 'document_expiry', $profile, $level)) continue; @@ -149,7 +154,7 @@ private function checkSponsors() $expiry = Carbon::parse($sponsor->emirates_id_expiry); $days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false); - $level = $this->getReminderLevel($days); + $level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1'); if ($level && !$this->alreadySent($sponsor, 'document_expiry_emirates', $sponsor, $level)) { $this->info("Sending {$level} reminder to sponsor {$sponsor->full_name} for Emirates ID expiry."); $sponsor->notify(new \App\Notifications\DocumentExpiryNotification( @@ -167,7 +172,7 @@ private function checkSponsors() $expiry = Carbon::parse($sponsor->license_expiry); $days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false); - $level = $this->getReminderLevel($days); + $level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1'); if ($level && !$this->alreadySent($sponsor, 'document_expiry_license', $sponsor, $level)) { $this->info("Sending {$level} reminder to sponsor {$sponsor->full_name} for License expiry."); $sponsor->notify(new \App\Notifications\DocumentExpiryNotification( @@ -199,7 +204,7 @@ private function checkHireConfirmations() $startDate = Carbon::parse($job->start_date); $days = (int) now()->startOfDay()->diffInDays($startDate->startOfDay(), false); - $level = $this->getReminderLevel($days); + $level = $this->getReminderLevel($days, 'reminders.employer_hire_confirm_reminder_days', '3,7'); if (!$level) continue; if ($this->alreadySent($employer, 'hire_confirmation', $app, $level)) continue; @@ -235,7 +240,7 @@ private function checkJoiningConfirmations() $startDate = Carbon::parse($job->start_date); $days = (int) now()->startOfDay()->diffInDays($startDate->startOfDay(), false); - $level = $this->getReminderLevel($days); + $level = $this->getReminderLevel($days, 'reminders.worker_join_confirm_reminder_days', '3,7'); if (!$level) continue; if ($this->alreadySent($worker, 'joining_confirmation', $app, $level)) continue; @@ -269,7 +274,7 @@ private function checkJobOffers() $workDate = Carbon::parse($offer->work_date); $days = (int) now()->startOfDay()->diffInDays($workDate->startOfDay(), false); - $level = $this->getReminderLevel($days); + $level = $this->getReminderLevel($days, 'reminders.worker_join_confirm_reminder_days', '3,7'); if (!$level) continue; if ($this->alreadySent($worker, 'pending_confirmation', $offer, $level)) continue; @@ -288,4 +293,166 @@ private function checkJobOffers() $this->logReminder($worker, 'pending_confirmation', $offer, $level, 'all'); } } + + private function checkWorkerNoResponse() + { + $noResponseDays = (int) config('reminders.worker_no_response_reminder_days', 14); + + // 1. Pending direct Job Offers + $offers = \App\Models\JobOffer::where('status', 'pending') + ->with(['worker', 'employer']) + ->get(); + + foreach ($offers as $offer) { + $worker = $offer->worker; + if (!$worker) continue; + $employer = $offer->employer; + $employerName = $employer ? $employer->name : 'Employer'; + + $daysSinceCreation = (int) Carbon::parse($offer->created_at)->startOfDay()->diffInDays(now()->startOfDay(), false); + + if ($daysSinceCreation !== $noResponseDays) continue; + + $level = $daysSinceCreation === 1 ? '1_day' : "{$daysSinceCreation}_days"; + + if ($this->alreadySent($worker, 'worker_no_response_offer', $offer, $level)) continue; + + $this->info("Sending no-response reminder to worker {$worker->name} for pending direct offer from {$employerName}."); + $worker->notify(new \App\Notifications\WorkerNoResponseNotification( + 'worker_no_response_offer', + "Action Required: Pending Job Offer", + "You have a pending job offer from {$employerName} sent on {$offer->created_at->toDateString()} that you have not responded to.", + $daysSinceCreation, + get_class($offer), + $offer->id + )); + + $this->logReminder($worker, 'worker_no_response_offer', $offer, $level, 'all'); + } + + // 2. Chat conversations where the last message is from the employer + $conversations = \App\Models\Conversation::with(['employer', 'worker'])->get(); + + foreach ($conversations as $conv) { + $worker = $conv->worker; + if (!$worker) continue; + $employer = $conv->employer; + $employerName = $employer ? $employer->name : 'Employer'; + + $lastMessage = $conv->messages()->latest()->first(); + if (!$lastMessage) continue; + + $isEmployerSender = false; + if ($lastMessage->sender_type === 'App\Models\User' || $lastMessage->sender_id == $conv->employer_id) { + $isEmployerSender = true; + } + + if (!$isEmployerSender) continue; + + $daysSinceMessage = (int) Carbon::parse($lastMessage->created_at)->startOfDay()->diffInDays(now()->startOfDay(), false); + + if ($daysSinceMessage !== $noResponseDays) continue; + + $level = $daysSinceMessage === 1 ? '1_day' : "{$daysSinceMessage}_days"; + + if ($this->alreadySent($worker, 'worker_no_response_chat', $lastMessage, $level)) continue; + + $this->info("Sending no-response chat reminder to worker {$worker->name} for conversation with {$employerName}."); + $worker->notify(new \App\Notifications\WorkerNoResponseNotification( + 'worker_no_response_chat', + "New Messages: Reply to {$employerName}", + "You have a message from {$employerName} that you have not responded to for {$daysSinceMessage} days.", + $daysSinceMessage, + get_class($lastMessage), + $lastMessage->id + )); + + $this->logReminder($worker, 'worker_no_response_chat', $lastMessage, $level, 'all'); + } + } + + private function checkReviewReminders() + { + $eligibilityMonths = (int) config('reminders.review_eligibility_months', 3); + $level = "{$eligibilityMonths}_months"; + + // 1. Standard Job Applications with status 'hired' + $applications = \App\Models\JobApplication::where('status', 'hired') + ->whereNotNull('joining_confirmed_at') + ->with(['jobPost.employer', 'worker']) + ->get(); + + foreach ($applications as $app) { + $job = $app->jobPost; + if (!$job) continue; + $employer = $job->employer; + $worker = $app->worker; + if (!$employer || !$worker) continue; + + $joiningDate = Carbon::parse($app->joining_confirmed_at); + $months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false); + + if ($months >= $eligibilityMonths) { + // 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}."); + $worker->notify(new \App\Notifications\WorkerReviewReminderNotification( + $employer->name, + $app->id, + $level + )); + $this->logReminder($worker, 'review_employer_reminder', $app, $level, 'all'); + } + + // Remind employer to review worker + if (!$this->alreadySent($employer, 'review_worker_reminder', $app, $level)) { + $this->info("Sending review reminder to employer {$employer->name} to review worker {$worker->name}."); + $employer->notify(new \App\Notifications\EmployerReviewReminderNotification( + $worker->name, + $app->id, + $level + )); + $this->logReminder($employer, 'review_worker_reminder', $app, $level, 'all'); + } + } + } + + // 2. Direct Job Offers with status 'accepted' + $offers = \App\Models\JobOffer::where('status', 'accepted') + ->with(['worker', 'employer']) + ->get(); + + foreach ($offers as $offer) { + $worker = $offer->worker; + $employer = $offer->employer; + if (!$worker || !$employer) continue; + + $joiningDate = $offer->work_date ? Carbon::parse($offer->work_date) : $offer->updated_at; + $months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false); + + if ($months >= $eligibilityMonths) { + // 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)."); + $worker->notify(new \App\Notifications\WorkerReviewReminderNotification( + $employer->name, + $offer->id, + $level + )); + $this->logReminder($worker, 'review_employer_reminder', $offer, $level, 'all'); + } + + // Remind employer to review worker + if (!$this->alreadySent($employer, 'review_worker_reminder', $offer, $level)) { + $this->info("Sending review reminder to employer {$employer->name} to review worker {$worker->name} (direct offer)."); + $employer->notify(new \App\Notifications\EmployerReviewReminderNotification( + $worker->name, + $offer->id, + $level + )); + $this->logReminder($employer, 'review_worker_reminder', $offer, $level, 'all'); + } + } + } + } } diff --git a/app/Http/Controllers/Admin/WorkerController.php b/app/Http/Controllers/Admin/WorkerController.php index bcb8a78..c4b9a2a 100644 --- a/app/Http/Controllers/Admin/WorkerController.php +++ b/app/Http/Controllers/Admin/WorkerController.php @@ -132,7 +132,7 @@ public function updateProfile(Request $request, $id) 'experience' => 'required|string', 'salary' => 'nullable|numeric', 'nationality' => 'nullable|string', - 'visa_status' => 'nullable|string', + 'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa', 'age' => 'nullable|integer', 'in_country' => 'nullable', 'preferred_job_type' => 'nullable|string', diff --git a/app/Http/Controllers/Api/EmployerNotificationController.php b/app/Http/Controllers/Api/EmployerNotificationController.php new file mode 100644 index 0000000..fe53372 --- /dev/null +++ b/app/Http/Controllers/Api/EmployerNotificationController.php @@ -0,0 +1,113 @@ +attributes->get('employer'); + + $filter = $request->query('filter', 'all'); + $perPage = (int) $request->query('per_page', 15); + + if ($filter === 'read') { + $query = $employer->readNotifications(); + } elseif ($filter === 'unread') { + $query = $employer->unreadNotifications(); + } else { + $query = $employer->notifications(); + } + + $paginator = $query->paginate($perPage); + + $notifications = collect($paginator->items())->map(function ($notification) { + return [ + 'id' => $notification->id, + 'type' => $notification->type, + 'data' => $notification->data, + 'read_at' => $notification->read_at ? $notification->read_at->toIso8601String() : null, + 'created_at' => $notification->created_at->toIso8601String(), + 'time_ago' => $notification->created_at->diffForHumans(), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'notifications' => $notifications, + 'pagination' => [ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ] + ] + ], 200); + } + + /** + * Mark a single notification or all unread notifications as read. + * PUT /api/employers/notifications/{id}/read or PUT /api/employers/notifications/read + */ + public function markAsRead(Request $request, $id = null) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if ($id) { + $notification = $employer->notifications()->where('id', $id)->first(); + if (!$notification) { + return response()->json([ + 'success' => false, + 'message' => 'Notification not found.' + ], 404); + } + $notification->markAsRead(); + } else { + $employer->unreadNotifications->markAsRead(); + } + + return response()->json([ + 'success' => true, + 'message' => 'Notification(s) marked as read successfully.' + ], 200); + } + + /** + * Delete a single notification or all notifications. + * DELETE /api/employers/notifications/{id} or DELETE /api/employers/notifications + */ + public function destroy(Request $request, $id = null) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if ($id) { + $notification = $employer->notifications()->where('id', $id)->first(); + if (!$notification) { + return response()->json([ + 'success' => false, + 'message' => 'Notification not found.' + ], 404); + } + $notification->delete(); + } else { + $employer->notifications()->delete(); + } + + return response()->json([ + 'success' => true, + 'message' => 'Notification(s) deleted successfully.' + ], 200); + } +} diff --git a/app/Http/Controllers/Api/EmployerReviewController.php b/app/Http/Controllers/Api/EmployerReviewController.php index 3b817ff..4ac983d 100644 --- a/app/Http/Controllers/Api/EmployerReviewController.php +++ b/app/Http/Controllers/Api/EmployerReviewController.php @@ -35,12 +35,65 @@ public function addReview(Request $request) } try { + // 1. Validate confirmed hire and completed joining, and review eligibility period + $hasConfirmedHire = false; + $joiningDate = null; + + // Check standard JobApplication where the job belongs to this employer and worker matches + $app = \App\Models\JobApplication::where('worker_id', $request->worker_id) + ->where('status', 'hired') + ->whereNotNull('joining_confirmed_at') + ->whereHas('jobPost', function ($query) use ($employer) { + $query->where('employer_id', $employer->id); + }) + ->first(); + + if ($app) { + $hasConfirmedHire = true; + $joiningDate = \Carbon\Carbon::parse($app->joining_confirmed_at); + } else { + // Check direct JobOffer + $offer = \App\Models\JobOffer::where('worker_id', $request->worker_id) + ->where('employer_id', $employer->id) + ->where('status', 'accepted') + ->first(); + + if ($offer) { + $hasConfirmedHire = true; + $joiningDate = $offer->work_date ? \Carbon\Carbon::parse($offer->work_date) : $offer->updated_at; + } + } + + if (!$hasConfirmedHire) { + return response()->json([ + 'success' => false, + 'message' => 'You can only review workers with a confirmed hire and completed joining.' + ], 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); + } + // Check if employer has already reviewed this worker $existingReview = Review::where('employer_id', $employer->id) ->where('worker_id', $request->worker_id) ->first(); if ($existingReview) { + // Check if the edit window has expired + $editPeriodDays = (int) config('reminders.review_edit_period_days', 7); + if ($existingReview->created_at->addDays($editPeriodDays)->isPast()) { + return response()->json([ + 'success' => false, + 'message' => "The {$editPeriodDays}-day edit window for this review has expired." + ], 403); + } + // If they already have a review, we can update it or direct them to the edit endpoint. // To be robust, let's update it directly and inform them it was updated (acting as an edit). $existingReview->update([ @@ -118,6 +171,15 @@ public function editReview(Request $request, $id) ], 404); } + // Check if the edit window has expired + $editPeriodDays = (int) config('reminders.review_edit_period_days', 7); + if ($review->created_at->addDays($editPeriodDays)->isPast()) { + return response()->json([ + 'success' => false, + 'message' => "The {$editPeriodDays}-day edit window for this review has expired." + ], 403); + } + $review->update([ 'rating' => $request->rating, 'comment' => $request->comment, diff --git a/app/Http/Controllers/Api/EmployerWorkerController.php b/app/Http/Controllers/Api/EmployerWorkerController.php index a603573..e73f5dd 100644 --- a/app/Http/Controllers/Api/EmployerWorkerController.php +++ b/app/Http/Controllers/Api/EmployerWorkerController.php @@ -944,8 +944,8 @@ public function getWorkerDetail(Request $request, $id) ]; // Visa status - $visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa']; - $visaStatus = $visaStatusesList[$w->id % 5]; + $visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa']; + $visaStatus = $visaStatusesList[$w->id % 3]; $photo = null; diff --git a/app/Http/Controllers/Api/SponsorController.php b/app/Http/Controllers/Api/SponsorController.php index 641b1e0..53f69dc 100644 --- a/app/Http/Controllers/Api/SponsorController.php +++ b/app/Http/Controllers/Api/SponsorController.php @@ -229,15 +229,18 @@ public function postCharityEvent(Request $request) } $validator = \Illuminate\Support\Facades\Validator::make($request->all(), [ - 'title' => 'required|string|max:255', - 'body' => 'required|string', - 'type' => 'nullable|string|in:charity,info,warning,success', - 'event_date' => 'required|string', - 'start_time' => 'required|string', - 'end_time' => 'required|string', - 'provided_items' => 'required|string', - 'location_details' => 'required|string', - 'location_pin' => 'required|string|url', + 'title' => 'required|string|max:255', + 'body' => 'required|string', + 'type' => 'nullable|string|in:charity,info,warning,success', + 'event_date' => 'required|string', + 'start_time' => 'required|string', + 'end_time' => 'required|string', + 'provided_items' => 'required|string', + 'location_details' => 'required|string', + 'location_pin' => 'required|string|url', + 'contact_person_name' => 'required|string|max:255', + 'contact_number' => 'required|string|regex:/^\d{7,15}$/', + 'country_code' => 'required|string|regex:/^\+\d{1,4}$/', ]); if ($validator->fails()) { @@ -252,13 +255,16 @@ public function postCharityEvent(Request $request) $eventTime = $request->start_time . ' - ' . $request->end_time; $bodyJson = 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, + '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, + 'contact_person_name' => $request->contact_person_name, + 'contact_number' => $request->contact_number, + 'country_code' => $request->country_code, ]); $event = Announcement::create([ diff --git a/app/Http/Controllers/Api/WorkerAuthController.php b/app/Http/Controllers/Api/WorkerAuthController.php index 1c545e7..4ce6c8c 100644 --- a/app/Http/Controllers/Api/WorkerAuthController.php +++ b/app/Http/Controllers/Api/WorkerAuthController.php @@ -318,7 +318,7 @@ public function register(Request $request) 'age' => 'nullable|integer', 'experience' => 'nullable|string|max:100', 'in_country' => 'nullable', - 'visa_status' => 'nullable|string|max:100', + 'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa', 'preferred_job_type' => 'nullable|string|max:100', 'main_profession' => 'nullable|string|max:100', 'gender' => 'nullable|string|in:male,female,other', @@ -1247,6 +1247,19 @@ private function normaliseDateForController(?string $raw): ?string private function cleanVisaData(array $visaDataInput): array { $rawVisaType = $visaDataInput['visa_type'] ?? null; + if ($rawVisaType) { + $normalized = strtolower(trim($rawVisaType)); + if (str_contains($normalized, 'residence')) { + $rawVisaType = 'Residence Visa'; + } elseif (str_contains($normalized, 'employment')) { + $rawVisaType = 'Employment Visa'; + } else { + $rawVisaType = 'Tourist Visa'; + } + } else { + $rawVisaType = 'Tourist Visa'; + } + $rawEntryPermitNo = $visaDataInput['entry_permit_no'] ?? $visaDataInput['file_number'] ?? $visaDataInput['id_number'] ?? $visaDataInput['number'] ?? null; $rawIssueDate = $visaDataInput['issue_date'] ?? null; $rawValidUntil = $visaDataInput['valid_until'] ?? $visaDataInput['expiry_date'] ?? null; @@ -1277,7 +1290,7 @@ private function cleanVisaData(array $visaDataInput): array return [ 'document_type' => 'uae_visa', 'country' => 'United Arab Emirates', - 'visa_type' => $rawVisaType ?: 'ENTRY PERMIT', + 'visa_type' => $rawVisaType, 'entry_permit_no' => $rawEntryPermitNo ?: '', 'issue_date' => $rawIssueDate ?: '', 'valid_until' => $rawValidUntil ?: '', diff --git a/app/Http/Controllers/Api/WorkerNotificationController.php b/app/Http/Controllers/Api/WorkerNotificationController.php new file mode 100644 index 0000000..c07267b --- /dev/null +++ b/app/Http/Controllers/Api/WorkerNotificationController.php @@ -0,0 +1,113 @@ +attributes->get('worker'); + + $filter = $request->query('filter', 'all'); + $perPage = (int) $request->query('per_page', 15); + + if ($filter === 'read') { + $query = $worker->readNotifications(); + } elseif ($filter === 'unread') { + $query = $worker->unreadNotifications(); + } else { + $query = $worker->notifications(); + } + + $paginator = $query->paginate($perPage); + + $notifications = collect($paginator->items())->map(function ($notification) { + return [ + 'id' => $notification->id, + 'type' => $notification->type, + 'data' => $notification->data, + 'read_at' => $notification->read_at ? $notification->read_at->toIso8601String() : null, + 'created_at' => $notification->created_at->toIso8601String(), + 'time_ago' => $notification->created_at->diffForHumans(), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'notifications' => $notifications, + 'pagination' => [ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ] + ] + ], 200); + } + + /** + * Mark a single notification or all unread notifications as read. + * PUT /api/workers/notifications/{id}/read or PUT /api/workers/notifications/read + */ + public function markAsRead(Request $request, $id = null) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + if ($id) { + $notification = $worker->notifications()->where('id', $id)->first(); + if (!$notification) { + return response()->json([ + 'success' => false, + 'message' => 'Notification not found.' + ], 404); + } + $notification->markAsRead(); + } else { + $worker->unreadNotifications->markAsRead(); + } + + return response()->json([ + 'success' => true, + 'message' => 'Notification(s) marked as read successfully.' + ], 200); + } + + /** + * Delete a single notification or all notifications. + * DELETE /api/workers/notifications/{id} or DELETE /api/workers/notifications + */ + public function destroy(Request $request, $id = null) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + if ($id) { + $notification = $worker->notifications()->where('id', $id)->first(); + if (!$notification) { + return response()->json([ + 'success' => false, + 'message' => 'Notification not found.' + ], 404); + } + $notification->delete(); + } else { + $worker->notifications()->delete(); + } + + return response()->json([ + 'success' => true, + 'message' => 'Notification(s) deleted successfully.' + ], 200); + } +} diff --git a/app/Http/Controllers/Api/WorkerProfileController.php b/app/Http/Controllers/Api/WorkerProfileController.php index 5eded75..fbc5b4f 100644 --- a/app/Http/Controllers/Api/WorkerProfileController.php +++ b/app/Http/Controllers/Api/WorkerProfileController.php @@ -77,7 +77,7 @@ public function updateProfile(Request $request) 'skills' => 'nullable|array', 'skills.*' => 'exists:skills,id', 'in_country' => 'nullable', - 'visa_status' => 'nullable|string|max:100', + 'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa', 'preferred_job_type' => 'nullable|string|max:100', 'gender' => 'nullable|string|in:male,female,other', 'live_in_out' => 'nullable|string|in:live_in,live_out', @@ -658,6 +658,7 @@ public function getDashboard(Request $request) 'employer_contacted' => $employerContacted, 'profile_viewed' => $profileViewed, 'currently_working_sponsor' => $currentSponsor, + 'current_employer' => $currentSponsor, 'latest_charity_events_list' => $charityEvents ] ], 200); @@ -673,6 +674,105 @@ public function getDashboard(Request $request) } } + /** + * Get list of employers associated with the worker. + * GET /api/workers/employers + */ + public function getEmployers(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + try { + $query = JobOffer::where('worker_id', $worker->id) + ->with(['employer.employerProfile']); + + // Filtering by status + if ($request->filled('status')) { + $statusInput = strtolower($request->status); + if ($statusInput === 'active') { + $query->where('status', 'accepted'); + } else { + $query->where('status', $statusInput); + } + } + + // 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) { + $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'; + } else { + $statusFormatted = ucfirst($offer->status); + } + + return [ + 'offer_id' => $offer->id, + 'joining_date' => $offer->work_date ? $offer->work_date->format('Y-m-d') : null, + 'location' => $offer->location, + 'salary' => (int) $offer->salary, + 'status' => $statusFormatted, + 'notes' => $offer->notes, + '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, + ] : null + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'employers' => $employers, + 'pagination' => [ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ] + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Worker Employers API Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while loading the employers list.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + /** * Change worker's password. * @@ -765,6 +865,19 @@ private function normaliseDateForController(?string $raw): ?string private function cleanVisaData(array $visaDataInput): array { $rawVisaType = $visaDataInput['visa_type'] ?? null; + if ($rawVisaType) { + $normalized = strtolower(trim($rawVisaType)); + if (str_contains($normalized, 'residence')) { + $rawVisaType = 'Residence Visa'; + } elseif (str_contains($normalized, 'employment')) { + $rawVisaType = 'Employment Visa'; + } else { + $rawVisaType = 'Tourist Visa'; + } + } else { + $rawVisaType = 'Tourist Visa'; + } + $rawEntryPermitNo = $visaDataInput['entry_permit_no'] ?? $visaDataInput['file_number'] ?? $visaDataInput['id_number'] ?? $visaDataInput['number'] ?? null; $rawIssueDate = $visaDataInput['issue_date'] ?? null; $rawValidUntil = $visaDataInput['valid_until'] ?? $visaDataInput['expiry_date'] ?? null; @@ -795,7 +908,7 @@ private function cleanVisaData(array $visaDataInput): array return [ 'document_type' => 'uae_visa', 'country' => 'United Arab Emirates', - 'visa_type' => $rawVisaType ?: 'ENTRY PERMIT', + 'visa_type' => $rawVisaType, 'entry_permit_no' => $rawEntryPermitNo ?: '', 'issue_date' => $rawIssueDate ?: '', 'valid_until' => $rawValidUntil ?: '', diff --git a/app/Http/Controllers/Api/WorkerReviewController.php b/app/Http/Controllers/Api/WorkerReviewController.php index 89bfd6b..590551b 100644 --- a/app/Http/Controllers/Api/WorkerReviewController.php +++ b/app/Http/Controllers/Api/WorkerReviewController.php @@ -84,11 +84,12 @@ public function addReview(Request $request) ], 403); } - // Verify if completed 3 months from joining date - if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < 3) { + // 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 3 months from the joining date.' + 'message' => "You can write a review only after {$eligibilityMonths} months from the joining date." ], 403); } @@ -169,11 +170,12 @@ public function editReview(Request $request, $id) ], 404); } - // Check if the 7-day edit window has expired - if ($review->created_at->addDays(7)->isPast()) { + // Check if the edit window has expired + $editPeriodDays = (int) config('reminders.review_edit_period_days', 7); + if ($review->created_at->addDays($editPeriodDays)->isPast()) { return response()->json([ 'success' => false, - 'message' => 'The 7-day edit window for this review has expired.' + 'message' => "The {$editPeriodDays}-day edit window for this review has expired." ], 403); } @@ -224,7 +226,8 @@ public function viewReview(Request $request, $id) ], 404); } - $editable = !$review->created_at->addDays(7)->isPast(); + $editPeriodDays = (int) config('reminders.review_edit_period_days', 7); + $editable = !$review->created_at->addDays($editPeriodDays)->isPast(); return response()->json([ 'success' => true, @@ -278,7 +281,8 @@ public function getReviews(Request $request) $reviews = $query->skip($offset)->take($perPage)->get() ->map(function ($rev) { - $editable = !$rev->created_at->addDays(7)->isPast(); + $editPeriodDays = (int) config('reminders.review_edit_period_days', 7); + $editable = !$rev->created_at->addDays($editPeriodDays)->isPast(); return [ 'id' => $rev->id, diff --git a/app/Http/Controllers/Employer/CandidateController.php b/app/Http/Controllers/Employer/CandidateController.php index 0993973..a7f22f6 100644 --- a/app/Http/Controllers/Employer/CandidateController.php +++ b/app/Http/Controllers/Employer/CandidateController.php @@ -74,6 +74,7 @@ public function index(Request $request) 'live_in_out' => $w->live_in_out, 'in_country' => (bool)$w->in_country, 'visa_status' => $w->visa_status, + 'main_profession' => $w->main_profession, ]; })->filter()->values()->toArray(); @@ -105,6 +106,7 @@ public function index(Request $request) 'live_in_out' => $w->live_in_out, 'in_country' => (bool)$w->in_country, 'visa_status' => $w->visa_status, + 'main_profession' => $w->main_profession, ]; })->filter()->values()->toArray(); @@ -265,6 +267,13 @@ public function index(Request $request) })); } + if ($request->filled('main_profession')) { + $mainProfession = strtolower($request->main_profession); + $mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($mainProfession) { + return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession; + })); + } + $nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500])); $nationalitiesData = json_decode($nationalitiesResponse->getContent(), true); $allNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray(); diff --git a/app/Http/Controllers/Employer/WorkerController.php b/app/Http/Controllers/Employer/WorkerController.php index c1d429f..4b13faa 100644 --- a/app/Http/Controllers/Employer/WorkerController.php +++ b/app/Http/Controllers/Employer/WorkerController.php @@ -281,7 +281,7 @@ public function index(Request $request) 'languages' => ['All Languages', 'English', 'Arabic', 'Hindi', 'Tagalog'], 'workTypes' => ['All Types', 'full-time', 'part-time', 'live-in', 'live-out'], 'skills' => ['All Skills', 'cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'], - 'visaStatuses' => ['All Visa Statuses', 'Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa'], + 'visaStatuses' => ['All Visa Statuses', 'Residence Visa', 'Tourist Visa', 'Employment Visa'], ]; return Inertia::render('Employer/Workers/Index', [ diff --git a/app/Models/Worker.php b/app/Models/Worker.php index 3275e6f..d83713c 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -73,16 +73,48 @@ public function getPassportStatusAttribute() public function getVisaStatusAttribute() { if ($this->attributes['visa_status'] ?? null) { - return $this->attributes['visa_status']; + $val = $this->attributes['visa_status']; + $normalized = strtolower(trim($val)); + if (str_contains($normalized, 'residence')) { + return 'Residence Visa'; + } elseif (str_contains($normalized, 'employment')) { + return 'Employment Visa'; + } else { + return 'Tourist Visa'; + } } $visa = $this->documents->where('type', 'visa')->first(); if (!$visa) { - return 'Visa Pending'; + return 'Residence Visa'; } - if ($visa->expiry_date && \Carbon\Carbon::parse($visa->expiry_date)->isPast()) { - return 'Cancelled Visa'; + $ocrData = $visa->ocr_data; + $vType = $ocrData['visa_type'] ?? null; + if ($vType) { + $normalized = strtolower(trim($vType)); + if (str_contains($normalized, 'residence')) { + return 'Residence Visa'; + } elseif (str_contains($normalized, 'employment')) { + return 'Employment Visa'; + } + } + return 'Tourist Visa'; + } + + public function setVisaStatusAttribute($value) + { + if (empty($value)) { + $this->attributes['visa_status'] = null; + return; + } + + $normalized = strtolower(trim($value)); + if (str_contains($normalized, 'residence')) { + $this->attributes['visa_status'] = 'Residence Visa'; + } elseif (str_contains($normalized, 'employment')) { + $this->attributes['visa_status'] = 'Employment Visa'; + } else { + $this->attributes['visa_status'] = 'Tourist Visa'; } - return 'Residence Visa'; } public function getEmiratesIdStatusAttribute() diff --git a/app/Notifications/EmployerReviewReminderNotification.php b/app/Notifications/EmployerReviewReminderNotification.php new file mode 100644 index 0000000..6a241e2 --- /dev/null +++ b/app/Notifications/EmployerReviewReminderNotification.php @@ -0,0 +1,103 @@ +workerName = $workerName; + $this->applicationId = $applicationId; + $this->reviewLevel = $reviewLevel; + } + + /** + * Get the notification's delivery channels. + */ + public function via($notifiable): array + { + $channels = ['database']; + + // Determine if email channel should be included + $emailEnabled = true; + if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) { + $emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true); + } + if ($emailEnabled && !empty($notifiable->email)) { + $channels[] = 'mail'; + } + + // Determine if push channel should be included + $pushEnabled = true; + if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) { + $pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true); + } + if ($pushEnabled && !empty($notifiable->fcm_token)) { + $channels[] = FcmChannel::class; + } + + return $channels; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail($notifiable): MailMessage + { + $name = $notifiable->name ?? $notifiable->full_name ?? 'User'; + return (new MailMessage) + ->subject("Review reminder: Rate your experience with {$this->workerName}") + ->greeting("Hello {$name},") + ->line("It has been {$this->reviewLevel} since you hired {$this->workerName}.") + ->line("Please take a moment to review and share your experience with {$this->workerName}.") + ->action('Submit Review', url('/dashboard/reviews')) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification for in-app storage. + */ + public function toArray($notifiable): array + { + return [ + 'title' => "Review reminder: {$this->workerName}", + 'body' => "Please take a moment to review and share your experience with {$this->workerName}.", + 'worker_name' => $this->workerName, + 'application_id' => $this->applicationId, + 'review_level' => $this->reviewLevel, + 'type' => 'review_worker_reminder', + ]; + } + + /** + * Get the push notification representation. + */ + public function toFcm($notifiable): array + { + return [ + 'token' => $notifiable->fcm_token, + 'title' => "Review reminder: {$this->workerName}", + 'body' => "Please take a moment to review and share your experience with {$this->workerName}.", + 'data' => [ + 'type' => 'review_worker_reminder', + 'application_id' => $this->applicationId, + 'review_level' => $this->reviewLevel, + ], + ]; + } +} diff --git a/app/Notifications/WorkerNoResponseNotification.php b/app/Notifications/WorkerNoResponseNotification.php new file mode 100644 index 0000000..db463dc --- /dev/null +++ b/app/Notifications/WorkerNoResponseNotification.php @@ -0,0 +1,108 @@ +type = $type; + $this->messageTitle = $messageTitle; + $this->messageBody = $messageBody; + $this->daysElapsed = $daysElapsed; + $this->entityType = $entityType; + $this->entityId = $entityId; + } + + /** + * Get the notification's delivery channels. + */ + public function via($notifiable): array + { + $channels = ['database']; + + // Determine if email channel should be included + $emailEnabled = true; + if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) { + $emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true); + } + if ($emailEnabled && !empty($notifiable->email)) { + $channels[] = 'mail'; + } + + // Determine if push channel should be included + $pushEnabled = true; + if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) { + $pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true); + } + if ($pushEnabled && !empty($notifiable->fcm_token)) { + $channels[] = FcmChannel::class; + } + + return $channels; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail($notifiable): MailMessage + { + $name = $notifiable->name ?? $notifiable->full_name ?? 'User'; + return (new MailMessage) + ->subject($this->messageTitle) + ->greeting("Hello {$name},") + ->line($this->messageBody) + ->action('View details', url('/dashboard')) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification for in-app storage. + */ + public function toArray($notifiable): array + { + return [ + 'title' => $this->messageTitle, + 'body' => $this->messageBody, + 'type' => $this->type, + 'days_elapsed' => $this->daysElapsed, + 'entity_type' => $this->entityType, + 'entity_id' => $this->entityId, + ]; + } + + /** + * Get the push notification representation. + */ + public function toFcm($notifiable): array + { + return [ + 'token' => $notifiable->fcm_token, + 'title' => $this->messageTitle, + 'body' => $this->messageBody, + 'data' => [ + 'type' => $this->type, + 'entity_id' => $this->entityId, + 'days_elapsed' => $this->daysElapsed, + ], + ]; + } +} diff --git a/app/Notifications/WorkerReviewReminderNotification.php b/app/Notifications/WorkerReviewReminderNotification.php new file mode 100644 index 0000000..f4cd326 --- /dev/null +++ b/app/Notifications/WorkerReviewReminderNotification.php @@ -0,0 +1,103 @@ +employerName = $employerName; + $this->applicationId = $applicationId; + $this->reviewLevel = $reviewLevel; + } + + /** + * Get the notification's delivery channels. + */ + public function via($notifiable): array + { + $channels = ['database']; + + // Determine if email channel should be included + $emailEnabled = true; + if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) { + $emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true); + } + if ($emailEnabled && !empty($notifiable->email)) { + $channels[] = 'mail'; + } + + // Determine if push channel should be included + $pushEnabled = true; + if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) { + $pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true); + } + if ($pushEnabled && !empty($notifiable->fcm_token)) { + $channels[] = FcmChannel::class; + } + + return $channels; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail($notifiable): MailMessage + { + $name = $notifiable->name ?? $notifiable->full_name ?? 'User'; + return (new MailMessage) + ->subject("Review reminder: Rate your experience with {$this->employerName}") + ->greeting("Hello {$name},") + ->line("It has been {$this->reviewLevel} since you started working with {$this->employerName}.") + ->line("Please take a moment to review and share your experience with {$this->employerName}.") + ->action('Submit Review', url('/dashboard/reviews')) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification for in-app storage. + */ + public function toArray($notifiable): array + { + return [ + 'title' => "Review reminder: {$this->employerName}", + 'body' => "Please take a moment to review and share your experience with {$this->employerName}.", + 'employer_name' => $this->employerName, + 'application_id' => $this->applicationId, + 'review_level' => $this->reviewLevel, + 'type' => 'review_employer_reminder', + ]; + } + + /** + * Get the push notification representation. + */ + public function toFcm($notifiable): array + { + return [ + 'token' => $notifiable->fcm_token, + 'title' => "Review reminder: {$this->employerName}", + 'body' => "Please take a moment to review and share your experience with {$this->employerName}.", + 'data' => [ + 'type' => 'review_employer_reminder', + 'application_id' => $this->applicationId, + 'review_level' => $this->reviewLevel, + ], + ]; + } +} diff --git a/config/reminders.php b/config/reminders.php new file mode 100644 index 0000000..cdd7dc1 --- /dev/null +++ b/config/reminders.php @@ -0,0 +1,10 @@ + env('WORKER_NO_RESPONSE_REMINDER_DAYS', 14), + 'review_eligibility_months' => env('REVIEW_ELIGIBILITY_MONTHS', 3), + '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'), + 'document_expiry_reminder_days' => env('DOCUMENT_EXPIRY_REMINDER_DAYS', '30,7,3,1'), +]; diff --git a/phpunit.xml b/phpunit.xml index e7f0a48..f0048c7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -32,5 +32,13 @@ + + + + + + + + diff --git a/public/swagger.json b/public/swagger.json index bc9338d..62a9169 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -584,7 +584,10 @@ "end_time", "provided_items", "location_details", - "location_pin" + "location_pin", + "contact_person_name", + "contact_number", + "country_code" ], "properties": { "title": { @@ -630,6 +633,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" } } } @@ -1019,7 +1034,12 @@ "visa_status": { "type": "string", "example": "Tourist Visa", - "description": "Visa status (only required/applicable if in_country is true)." + "description": "Visa status (only required/applicable if in_country is true).", + "enum": [ + "Tourist Visa", + "Employment Visa", + "Residence Visa" + ] }, "preferred_job_type": { "type": "string", @@ -5374,7 +5394,7 @@ "name": "visa_status", "in": "query", "required": false, - "description": "Filter by next visa status (comma-separated list, e.g. Residence Visa,Tourist Visa). Only applicable if in country.", + "description": "Filter by next visa status (comma-separated list, e.g. Residence Visa, Tourist Visa, Employment Visa). Only applicable if in country.", "schema": { "type": "string" } @@ -5589,6 +5609,15 @@ "type": "string" } }, + { + "name": "main_profession", + "in": "query", + "required": false, + "description": "Filter candidates by main profession (e.g. Housemaid, Nanny, Cook, Driver, Caregiver).", + "schema": { + "type": "string" + } + }, { "name": "preferred_location", "in": "query", @@ -5638,7 +5667,7 @@ "name": "visa_status", "in": "query", "required": false, - "description": "Filter candidates by next visa status (comma-separated list, e.g. Residence Visa,Tourist Visa). Only applicable if in country.", + "description": "Filter candidates by next visa status (comma-separated list, e.g. Residence Visa, Tourist Visa, Employment Visa). Only applicable if in country.", "schema": { "type": "string" } @@ -5926,6 +5955,49 @@ "example": 3, "description": "Unique count of employers who viewed this worker's profile." }, + "current_employer": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer", + "example": 2 + }, + "name": { + "type": "string", + "example": "Ahmad" + }, + "company_name": { + "type": "string", + "example": "Ahmad Tech Ltd" + }, + "nationality": { + "type": "string", + "example": "UAE" + }, + "city": { + "type": "string", + "example": "Dubai" + }, + "email": { + "type": "string", + "example": "ahmad@example.com" + }, + "phone": { + "type": "string", + "example": "+971509990001" + }, + "hired_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-01T15:10:24.000000Z" + }, + "hired_at_formatted": { + "type": "string", + "example": "Jun 01, 2026" + } + } + }, "currently_working_sponsor": { "type": "object", "nullable": true, @@ -6020,6 +6092,177 @@ } } }, + "/workers/employers": { + "get": { + "tags": [ + "Worker/Employers" + ], + "summary": "Get Worker Employers List", + "description": "Allows authenticated workers to retrieve all current and past employers associated with their employment history. Supports pagination, sorting, and filtering by employment status.", + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter by employment status (Active, Completed, Cancelled, Pending, Rejected, etc.).", + "schema": { + "type": "string", + "example": "Active" + } + }, + { + "name": "sort_by", + "in": "query", + "required": false, + "description": "Field to sort by (joining_date, salary, status, created_at).", + "schema": { + "type": "string", + "default": "joining_date" + } + }, + { + "name": "sort_order", + "in": "query", + "required": false, + "description": "Sort order (asc, desc).", + "schema": { + "type": "string", + "default": "desc" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "description": "Page number for pagination.", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "description": "Number of items per page.", + "schema": { + "type": "integer", + "default": 15 + } + } + ], + "responses": { + "200": { + "description": "Employers list retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "employers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "offer_id": { + "type": "integer", + "example": 12 + }, + "joining_date": { + "type": "string", + "format": "date", + "example": "2026-06-01" + }, + "location": { + "type": "string", + "example": "Dubai" + }, + "salary": { + "type": "integer", + "example": 2500 + }, + "status": { + "type": "string", + "example": "Active" + }, + "notes": { + "type": "string", + "example": "Hired for child care" + }, + "employer": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 2 + }, + "name": { + "type": "string", + "example": "Ahmad" + }, + "email": { + "type": "string", + "example": "ahmad@example.com" + }, + "phone": { + "type": "string", + "example": "+971509990001" + }, + "company_name": { + "type": "string", + "example": "Ahmad Tech Ltd" + }, + "city": { + "type": "string", + "example": "Dubai" + }, + "nationality": { + "type": "string", + "example": "UAE" + } + } + } + } + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "example": 1 + }, + "per_page": { + "type": "integer", + "example": 15 + }, + "current_page": { + "type": "integer", + "example": 1 + }, + "last_page": { + "type": "integer", + "example": 1 + } + } + } + } + } + } + } + } + } + } + } + } + }, "/employers/reviews": { "get": { "tags": [ @@ -6835,9 +7078,25 @@ "type": "string", "example": "full-time" }, + "main_profession": { + "type": "string", + "example": "Housemaid", + "enum": [ + "Housemaid", + "Nanny", + "Cook", + "Driver", + "Caregiver" + ] + }, "visa_status": { "type": "string", - "example": "Tourist Visa" + "example": "Tourist Visa", + "enum": [ + "Tourist Visa", + "Employment Visa", + "Residence Visa" + ] } } } @@ -7891,6 +8150,508 @@ } } } + }, + "/workers/notifications": { + "get": { + "tags": [ + "Worker/Notification" + ], + "summary": "List Worker Notifications", + "description": "Returns a paginated list of notifications for the authenticated worker.", + "parameters": [ + { + "name": "filter", + "in": "query", + "description": "Filter notifications by read status.", + "schema": { + "type": "string", + "enum": [ + "all", + "read", + "unread" + ], + "default": "all" + } + }, + { + "name": "per_page", + "in": "query", + "description": "Number of items per page.", + "schema": { + "type": "integer", + "default": 15 + } + } + ], + "responses": { + "200": { + "description": "Notifications retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "notifications": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Notification" + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "example": 10 + }, + "per_page": { + "type": "integer", + "example": 15 + }, + "current_page": { + "type": "integer", + "example": 1 + }, + "last_page": { + "type": "integer", + "example": 1 + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated." + } + } + }, + "delete": { + "tags": [ + "Worker/Notification" + ], + "summary": "Delete All Worker Notifications", + "description": "Deletes all notifications for the authenticated worker.", + "responses": { + "200": { + "description": "Notifications deleted successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Notification(s) deleted successfully." + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated." + } + } + } + }, + "/workers/notifications/read": { + "put": { + "tags": [ + "Worker/Notification" + ], + "summary": "Mark All Notifications as Read", + "description": "Marks all unread notifications for the authenticated worker as read.", + "responses": { + "200": { + "description": "Notifications marked as read successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Notification(s) marked as read successfully." + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated." + } + } + } + }, + "/workers/notifications/{id}": { + "delete": { + "tags": [ + "Worker/Notification" + ], + "summary": "Delete Single Notification", + "description": "Deletes a single notification for the authenticated worker.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "UUID of the notification to delete.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Notification deleted successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Notification(s) deleted successfully." + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated." + }, + "404": { + "description": "Notification not found." + } + } + } + }, + "/workers/notifications/{id}/read": { + "put": { + "tags": [ + "Worker/Notification" + ], + "summary": "Mark Single Notification as Read", + "description": "Marks a single notification for the authenticated worker as read.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "UUID of the notification to mark as read.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Notification marked as read successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Notification(s) marked as read successfully." + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated." + }, + "404": { + "description": "Notification not found." + } + } + } + }, + "/employers/notifications": { + "get": { + "tags": [ + "Employer/Notification" + ], + "summary": "List Employer Notifications", + "description": "Returns a paginated list of notifications for the authenticated employer.", + "parameters": [ + { + "name": "filter", + "in": "query", + "description": "Filter notifications by read status.", + "schema": { + "type": "string", + "enum": [ + "all", + "read", + "unread" + ], + "default": "all" + } + }, + { + "name": "per_page", + "in": "query", + "description": "Number of items per page.", + "schema": { + "type": "integer", + "default": 15 + } + } + ], + "responses": { + "200": { + "description": "Notifications retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "notifications": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Notification" + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "example": 10 + }, + "per_page": { + "type": "integer", + "example": 15 + }, + "current_page": { + "type": "integer", + "example": 1 + }, + "last_page": { + "type": "integer", + "example": 1 + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated." + } + } + }, + "delete": { + "tags": [ + "Employer/Notification" + ], + "summary": "Delete All Employer Notifications", + "description": "Deletes all notifications for the authenticated employer.", + "responses": { + "200": { + "description": "Notifications deleted successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Notification(s) deleted successfully." + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated." + } + } + } + }, + "/employers/notifications/read": { + "put": { + "tags": [ + "Employer/Notification" + ], + "summary": "Mark All Notifications as Read", + "description": "Marks all unread notifications for the authenticated employer as read.", + "responses": { + "200": { + "description": "Notifications marked as read successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Notification(s) marked as read successfully." + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated." + } + } + } + }, + "/employers/notifications/{id}": { + "delete": { + "tags": [ + "Employer/Notification" + ], + "summary": "Delete Single Notification", + "description": "Deletes a single notification for the authenticated employer.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "UUID of the notification to delete.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Notification deleted successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Notification(s) deleted successfully." + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated." + }, + "404": { + "description": "Notification not found." + } + } + } + }, + "/employers/notifications/{id}/read": { + "put": { + "tags": [ + "Employer/Notification" + ], + "summary": "Mark Single Notification as Read", + "description": "Marks a single notification for the authenticated employer as read.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "UUID of the notification to mark as read.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Notification marked as read successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Notification(s) marked as read successfully." + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated." + }, + "404": { + "description": "Notification not found." + } + } + } } }, "components": { @@ -8157,7 +8918,12 @@ }, "visa_status": { "type": "string", - "example": "Tourist Visa" + "example": "Tourist Visa", + "enum": [ + "Tourist Visa", + "Employment Visa", + "Residence Visa" + ] }, "passport_status": { "type": "string", @@ -8259,7 +9025,14 @@ "type": "string", "nullable": true, "example": "Nanny", - "description": "The main profession of the worker" + "description": "The main profession of the worker", + "enum": [ + "Housemaid", + "Nanny", + "Cook", + "Driver", + "Caregiver" + ] }, "skills": { "type": "array", @@ -8411,6 +9184,69 @@ "example": "2026-05-20T15:23:44.000000Z" } } + }, + "Notification": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "example": "d290f1ee-6c54-4b01-90e6-d701748f0851" + }, + "type": { + "type": "string", + "example": "App\\Notifications\\DocumentExpiryNotification" + }, + "data": { + "type": "object", + "properties": { + "title": { + "type": "string", + "example": "Document Expiry Warning" + }, + "body": { + "type": "string", + "example": "Your Passport will expire in 30 days on 2026-12-31." + }, + "document_type": { + "type": "string", + "example": "Passport" + }, + "expiry_date": { + "type": "string", + "format": "date", + "example": "2026-12-31" + }, + "days_remaining": { + "type": "integer", + "example": 30 + }, + "reminder_level": { + "type": "string", + "example": "30_days" + }, + "type": { + "type": "string", + "example": "document_expiry" + } + } + }, + "read_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": "2026-07-03T12:00:00Z" + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2026-07-03T11:43:30Z" + }, + "time_ago": { + "type": "string", + "example": "2 hours ago" + } + } } } } diff --git a/resources/js/Pages/Employer/SelectedCandidates.jsx b/resources/js/Pages/Employer/SelectedCandidates.jsx index db2de5b..c8a822a 100644 --- a/resources/js/Pages/Employer/SelectedCandidates.jsx +++ b/resources/js/Pages/Employer/SelectedCandidates.jsx @@ -38,6 +38,7 @@ export default function SelectedCandidates({ const [drawerOpen, setDrawerOpen] = useState(false); // Filter States + const [filterProfession, setFilterProfession] = useState('All Professions'); const [filterLocation, setFilterLocation] = useState('All'); const [filterJobType, setFilterJobType] = useState('All'); const [filterAccommodation, setFilterAccommodation] = useState('All'); @@ -83,6 +84,9 @@ export default function SelectedCandidates({ const query = searchTerm.toLowerCase(); list = list.filter(w => w.name.toLowerCase().includes(query)); } + if (filterProfession !== 'All Professions') { + list = list.filter(w => w.main_profession && w.main_profession.toLowerCase() === filterProfession.toLowerCase()); + } if (filterLocation !== 'All' && filterLocation.trim() !== '') { const locKey = filterLocation.toLowerCase(); const locationMapping = { @@ -130,9 +134,10 @@ export default function SelectedCandidates({ } return list; - }, [selectedWorkers, searchTerm, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]); + }, [selectedWorkers, searchTerm, filterProfession, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]); const resetFilters = () => { + setFilterProfession('All Professions'); setFilterLocation('All'); setFilterJobType('All'); setFilterAccommodation('All'); @@ -147,6 +152,7 @@ export default function SelectedCandidates({ const activeFilterCount = useMemo(() => { let count = 0; + if (filterProfession !== 'All Professions') count++; if (filterLocation !== 'All' && filterLocation.trim() !== '') count++; if (filterJobType !== 'All') count++; if (filterAccommodation !== 'All') count++; @@ -158,11 +164,12 @@ export default function SelectedCandidates({ if (filterLanguages.length > 0) count++; if (maxSalary < 5000) count++; return count; - }, [filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]); + }, [filterProfession, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]); // Active filter tags for display const activeFilterTags = useMemo(() => { const tags = []; + if (filterProfession !== 'All Professions') tags.push({ key: 'profession', label: `🧑‍🔧 ${filterProfession}`, clear: () => setFilterProfession('All Professions') }); if (filterLocation !== 'All' && filterLocation.trim()) tags.push({ key: 'location', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('All') }); if (filterJobType !== 'All') tags.push({ key: 'job', label: `💼 ${filterJobType}`, clear: () => setFilterJobType('All') }); if (filterAccommodation !== 'All') tags.push({ key: 'acc', label: `🏠 ${filterAccommodation.replace('_', '-')}`, clear: () => setFilterAccommodation('All') }); @@ -174,7 +181,7 @@ export default function SelectedCandidates({ if (filterLanguages.length > 0) tags.push({ key: 'lang', label: `🗣 ${filterLanguages.length} lang`, clear: () => setFilterLanguages([]) }); if (maxSalary < 5000) tags.push({ key: 'salary', label: `💰 ≤${maxSalary} AED`, clear: () => setMaxSalary(5000) }); return tags; - }, [filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]); + }, [filterProfession, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]); return ( @@ -188,6 +195,22 @@ export default function SelectedCandidates({ activeCount={activeFilterCount} title="Filter Hired Workers" > + {/* Profession */} + + setFilterProfession(e.target.value)} + > + + + + + + + + + {/* Preferred Location */} Residence Visa - - {filterInCountry !== 'In Country' && (

@@ -437,6 +458,12 @@ export default function SelectedCandidates({

{worker.nationality || '—'} + {worker.main_profession && ( + <> + + {worker.main_profession} + + )}
diff --git a/resources/js/Pages/Employer/Workers/Index.jsx b/resources/js/Pages/Employer/Workers/Index.jsx index 167df3d..62c1c97 100644 --- a/resources/js/Pages/Employer/Workers/Index.jsx +++ b/resources/js/Pages/Employer/Workers/Index.jsx @@ -520,8 +520,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [], - - {filterInCountry !== 'In Country' && (

Select "In Country" to enable this filter

diff --git a/routes/api.php b/routes/api.php index b500737..42609b9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,8 @@ use App\Http\Controllers\Api\SponsorAuthController; use App\Http\Controllers\Api\SponsorController; use App\Http\Controllers\Api\GoogleVisionOcrController; +use App\Http\Controllers\Api\WorkerNotificationController; +use App\Http\Controllers\Api\EmployerNotificationController; /* |-------------------------------------------------------------------------- @@ -75,6 +77,7 @@ Route::post('/workers/profile/mark-hired', [WorkerProfileController::class, 'markHired']); Route::get('/workers/dashboard/views', [WorkerProfileController::class, 'getProfileViews']); Route::get('/workers/dashboard', [WorkerProfileController::class, 'getDashboard']); + Route::get('/workers/employers', [WorkerProfileController::class, 'getEmployers']); Route::post('/workers/change-password', [WorkerProfileController::class, 'changePassword']); @@ -117,6 +120,13 @@ Route::post('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'addReview']); Route::get('/workers/reviews/{id}', [\App\Http\Controllers\Api\WorkerReviewController::class, 'viewReview']); Route::put('/workers/reviews/{id}', [\App\Http\Controllers\Api\WorkerReviewController::class, 'editReview']); + + // Notification Management (Worker) + Route::get('/workers/notifications', [WorkerNotificationController::class, 'index']); + Route::put('/workers/notifications/read', [WorkerNotificationController::class, 'markAsRead']); + Route::put('/workers/notifications/{id}/read', [WorkerNotificationController::class, 'markAsRead']); + Route::delete('/workers/notifications', [WorkerNotificationController::class, 'destroy']); + Route::delete('/workers/notifications/{id}', [WorkerNotificationController::class, 'destroy']); }); // Protected Employer Mobile Endpoints (Token Authenticated via Bearer Token) @@ -176,6 +186,13 @@ Route::delete('/employers/jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerDeleteJob']); Route::get('/employers/jobs/{id}/applicants', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerJobApplicants']); Route::post('/employers/applications/{id}/status', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerUpdateApplicationStatus']); + + // Notification Management (Employer) + Route::get('/employers/notifications', [EmployerNotificationController::class, 'index']); + Route::put('/employers/notifications/read', [EmployerNotificationController::class, 'markAsRead']); + Route::put('/employers/notifications/{id}/read', [EmployerNotificationController::class, 'markAsRead']); + Route::delete('/employers/notifications', [EmployerNotificationController::class, 'destroy']); + Route::delete('/employers/notifications/{id}', [EmployerNotificationController::class, 'destroy']); }); // Protected Sponsor Mobile Endpoints (Token Authenticated via Bearer Token) diff --git a/tests/Feature/EmployerWorkerFilterApiTest.php b/tests/Feature/EmployerWorkerFilterApiTest.php index 433fe79..627d229 100644 --- a/tests/Feature/EmployerWorkerFilterApiTest.php +++ b/tests/Feature/EmployerWorkerFilterApiTest.php @@ -49,6 +49,7 @@ protected function setUp(): void 'live_in_out' => 'live_in', 'in_country' => true, 'visa_status' => 'Residence Visa', + 'main_profession' => 'Housemaid', 'age' => 25, 'gender' => 'female', 'salary' => 1500, @@ -80,6 +81,7 @@ protected function setUp(): void 'live_in_out' => 'live_out', 'in_country' => true, 'visa_status' => 'Tourist Visa', + 'main_profession' => 'Nanny', 'age' => 28, 'gender' => 'female', 'salary' => 1800, @@ -96,7 +98,7 @@ protected function setUp(): void 'file_path' => 'passports/test2.jpg', ]); - // Worker 3: Dubai, full-time, live-out, Kenyan, in_country=false, Cancelled Visa, male + // Worker 3: Dubai, full-time, live-out, Kenyan, in_country=false, Residence Visa, male $w3 = Worker::create([ 'name' => 'Worker Kenyan Out Country', 'email' => 'worker3@example.com', @@ -110,7 +112,8 @@ protected function setUp(): void 'preferred_job_type' => 'full-time', 'live_in_out' => 'live_out', 'in_country' => false, - 'visa_status' => 'Cancelled Visa', + 'visa_status' => 'Residence Visa', + 'main_profession' => 'Cook', 'age' => 30, 'gender' => 'male', 'salary' => 1200, @@ -201,17 +204,29 @@ public function test_get_workers_visa_status_if_in_country_filter() $this->assertCount(1, $workers); $this->assertEquals('Worker Filipino In Abu Dhabi', $workers[0]['name']); - // Cancelled Visa is for Worker 3, but Worker 3 is out country, so it should not match + // Residence Visa is for Worker 3 (Kenyan), but Worker 3 is out country, so it should not match // because of the "if in country next visa type" constraint. $response2 = $this->withHeaders([ 'Authorization' => 'Bearer ' . $this->token, - ])->getJson('/api/employers/workers?visa_status=Cancelled Visa'); + ])->getJson('/api/employers/workers?visa_status=Residence Visa&nationality=Kenyan'); $response2->assertStatus(200); $workers2 = $response2->json('data.workers'); $this->assertCount(0, $workers2); } + public function test_get_workers_main_profession_filter() + { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + ])->getJson('/api/employers/workers?main_profession=Housemaid'); + + $response->assertStatus(200); + $workers = $response->json('data.workers'); + $this->assertCount(1, $workers); + $this->assertEquals('Worker Indian In Dubai', $workers[0]['name']); + } + /** * Test getCandidates filtering. */ diff --git a/tests/Feature/EmployerWorkerWebFilterTest.php b/tests/Feature/EmployerWorkerWebFilterTest.php index a71e688..ee1517e 100644 --- a/tests/Feature/EmployerWorkerWebFilterTest.php +++ b/tests/Feature/EmployerWorkerWebFilterTest.php @@ -41,6 +41,7 @@ protected function setUp(): void 'live_in_out' => 'live_in', 'in_country' => true, 'visa_status' => 'Residence Visa', + 'main_profession' => 'Housemaid', 'age' => 25, 'salary' => 1500, 'experience' => '3 Years', @@ -70,6 +71,7 @@ protected function setUp(): void 'live_in_out' => 'live_out', 'in_country' => true, 'visa_status' => 'Tourist Visa', + 'main_profession' => 'Nanny', 'age' => 28, 'salary' => 1800, 'experience' => '2 Years', @@ -164,7 +166,8 @@ public function test_employer_can_view_candidates_index_and_apply_filters() 'preferred_job_type' => 'full-time', 'live_in_out' => 'live_out', 'in_country' => false, - 'visa_status' => 'Cancelled Visa', + 'visa_status' => 'Residence Visa', + 'main_profession' => 'Cook', 'age' => 30, 'salary' => 1200, 'experience' => 'None', @@ -227,4 +230,59 @@ public function test_employer_workers_web_gender_filtering() $response->assertSee('Worker Indian Dubai'); $response->assertDontSee('Worker Filipino Abu Dhabi'); } + + /** + * Test web workers index main_profession filtering. + */ + public function test_employer_workers_web_main_profession_filtering() + { + $response = $this->actingAs($this->employer) + ->withSession(['user' => $this->employer]) + ->get(route('employer.workers', ['main_profession' => 'Housemaid'])); + + $response->assertStatus(200); + $response->assertSee('Worker Indian Dubai'); + $response->assertDontSee('Worker Filipino Abu Dhabi'); + + // Test candidate filtering by main_profession + $w = Worker::create([ + 'name' => 'Worker Kenyan Hired', + 'email' => 'worker3@example.com', + 'phone' => '+971503333333', + 'password' => bcrypt('password'), + 'nationality' => 'Kenyan', + 'verified' => true, + 'status' => 'Hired', + 'preferred_location' => 'Dubai', + 'preferred_job_type' => 'full-time', + 'live_in_out' => 'live_out', + 'in_country' => false, + 'visa_status' => 'Residence Visa', + 'main_profession' => 'Cook', + 'age' => 30, + 'salary' => 1200, + 'experience' => 'None', + 'religion' => 'Christian', + 'language' => 'SW', + 'availability' => 'Immediate', + 'bio' => 'New helper', + ]); + + JobOffer::create([ + 'employer_id' => $this->employer->id, + 'worker_id' => $w->id, + 'work_date' => now()->format('Y-m-d'), + 'location' => 'Dubai', + 'salary' => 1200, + 'notes' => 'Hired', + 'status' => 'accepted', + ]); + + $response2 = $this->actingAs($this->employer) + ->withSession(['user' => $this->employer]) + ->get(route('employer.candidates', ['main_profession' => 'Cook'])); + + $response2->assertStatus(200); + $response2->assertSee('selectedWorkers'); + } } diff --git a/tests/Feature/NewRemindersAndSettingsTest.php b/tests/Feature/NewRemindersAndSettingsTest.php new file mode 100644 index 0000000..d143764 --- /dev/null +++ b/tests/Feature/NewRemindersAndSettingsTest.php @@ -0,0 +1,369 @@ + 'John Worker', + 'email' => 'worker@example.com', + 'password' => bcrypt('password'), + 'phone' => '971500000001', + 'nationality' => 'Indian', + 'status' => 'active', + 'api_token' => 'worker_api_token', + 'age' => 25, + 'salary' => 2000, + 'availability' => 'Immediate', + 'experience' => '2 Years', + 'religion' => 'Christian', + 'bio' => 'Helper info', + 'passport_status' => 'valid', + ], $overrides)); + } + + private function createTestEmployer(array $overrides = []): User + { + return User::create(array_merge([ + 'name' => 'Alice Employer', + 'email' => 'employer@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + 'api_token' => 'employer_api_token', + 'subscription_status' => 'active', + ], $overrides)); + } + + public function test_worker_no_response_reminders() + { + Notification::fake(); + + $worker = $this->createTestWorker(); + $employer = $this->createTestEmployer(); + + // 1. Create a pending direct Job Offer created exactly 14 days ago + $offer = JobOffer::create([ + 'employer_id' => $employer->id, + 'worker_id' => $worker->id, + 'work_date' => now()->addDays(5)->toDateString(), + 'location' => 'Dubai Marina', + 'salary' => 3000, + 'status' => 'pending', + ]); + // Force created_at to 14 days ago + $offer->created_at = now()->subDays(14); + $offer->save(); + + // 2. Create a conversation where the last message is from employer, sent 14 days ago + $conversation = Conversation::create([ + 'employer_id' => $employer->id, + 'worker_id' => $worker->id, + ]); + $message = Message::create([ + 'conversation_id' => $conversation->id, + 'sender_type' => 'App\Models\User', + 'sender_id' => $employer->id, + 'text' => 'Hello worker, are you interested?', + ]); + $message->created_at = now()->subDays(14); + $message->save(); + + // Run scheduler + Artisan::call('app:send-reminders'); + + // Assert Worker got WorkerNoResponseNotification for both + Notification::assertSentTo( + $worker, + WorkerNoResponseNotification::class, + function ($notification) use ($offer) { + return $notification->type === 'worker_no_response_offer' && + $notification->daysElapsed === 14 && + $notification->entityId === $offer->id; + } + ); + + Notification::assertSentTo( + $worker, + WorkerNoResponseNotification::class, + function ($notification) use ($message) { + return $notification->type === 'worker_no_response_chat' && + $notification->daysElapsed === 14 && + $notification->entityId === $message->id; + } + ); + + // Assert reminder logs were created + $this->assertDatabaseHas('reminder_logs', [ + 'user_type' => get_class($worker), + 'user_id' => $worker->id, + 'event_type' => 'worker_no_response_offer', + 'reminder_level' => '14_days', + ]); + + $this->assertDatabaseHas('reminder_logs', [ + 'user_type' => get_class($worker), + 'user_id' => $worker->id, + 'event_type' => 'worker_no_response_chat', + 'reminder_level' => '14_days', + ]); + + // Run again to verify duplicate prevention + Notification::fake(); + Artisan::call('app:send-reminders'); + Notification::assertNothingSent(); + } + + public function test_review_reminders_after_joining_date() + { + Notification::fake(); + + $worker = $this->createTestWorker(); + $employer = $this->createTestEmployer(); + + $job = JobPost::create([ + 'employer_id' => $employer->id, + 'title' => 'Nanny', + 'location' => 'Dubai', + 'salary' => 2500, + 'workers_needed' => 1, + 'job_type' => 'Full Time', + 'start_date' => now()->subMonths(4)->toDateString(), + 'description' => 'Test job', + 'status' => 'active', + ]); + + // JobApplication with joining_confirmed_at exactly 3 months ago + $app = JobApplication::create([ + 'job_id' => $job->id, + 'worker_id' => $worker->id, + 'status' => 'hired', + 'joining_confirmed_at' => now()->subMonths(3)->toDateString(), + ]); + + // Run scheduler + Artisan::call('app:send-reminders'); + + // Assert Worker got review reminder for employer + Notification::assertSentTo( + $worker, + WorkerReviewReminderNotification::class, + function ($notification) use ($app) { + return $notification->employerName === 'Alice Employer' && + $notification->applicationId === $app->id && + $notification->reviewLevel === '3_months'; + } + ); + + // Assert Employer got review reminder for worker + Notification::assertSentTo( + $employer, + EmployerReviewReminderNotification::class, + function ($notification) use ($app) { + return $notification->workerName === 'John Worker' && + $notification->applicationId === $app->id && + $notification->reviewLevel === '3_months'; + } + ); + + // Assert reminder logs were created + $this->assertDatabaseHas('reminder_logs', [ + 'user_type' => get_class($worker), + 'user_id' => $worker->id, + 'event_type' => 'review_employer_reminder', + 'reminder_level' => '3_months', + ]); + + $this->assertDatabaseHas('reminder_logs', [ + 'user_type' => get_class($employer), + 'user_id' => $employer->id, + 'event_type' => 'review_worker_reminder', + 'reminder_level' => '3_months', + ]); + + // Run again to verify duplicate prevention + Notification::fake(); + Artisan::call('app:send-reminders'); + Notification::assertNothingSent(); + } + + public function test_worker_review_controller_eligibility_and_edit_window() + { + $worker = $this->createTestWorker(); + $employer = $this->createTestEmployer(); + + $job = JobPost::create([ + 'employer_id' => $employer->id, + 'title' => 'Test Job', + 'location' => 'Dubai', + 'salary' => 2500, + 'workers_needed' => 1, + 'job_type' => 'Full Time', + 'start_date' => now()->subMonths(4)->toDateString(), + 'description' => 'Test Description', + 'status' => 'active', + ]); + + // 1. Cannot review before eligibility period (set eligibility to 3 months) + $app = JobApplication::create([ + 'job_id' => $job->id, + 'worker_id' => $worker->id, + 'status' => 'hired', + 'joining_confirmed_at' => now()->subMonths(2)->toDateString(), + ]); + + $response = $this->postJson('/api/workers/reviews', [ + 'employer_id' => $employer->id, + 'job_id' => $job->id, + 'rating' => 5, + 'comment' => 'Great employer!', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(403); + $response->assertJsonPath('success', false); + $response->assertJsonPath('message', 'You can write a review only after 3 months from the joining date.'); + + // 2. Can review after eligibility period + $app->joining_confirmed_at = now()->subMonths(3)->subDays(5)->toDateString(); + $app->save(); + + $response = $this->postJson('/api/workers/reviews', [ + 'employer_id' => $employer->id, + 'job_id' => $job->id, + 'rating' => 5, + 'comment' => 'Great employer!', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(201); + $response->assertJsonPath('success', true); + $reviewId = $response->json('data.review.id'); + + // 3. Edit review within 7 days + $response = $this->putJson("/api/workers/reviews/{$reviewId}", [ + 'rating' => 4, + 'comment' => 'Good employer indeed.', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + $response->assertStatus(200); + + // 4. Cannot edit review after 7 days + $review = EmployerReview::find($reviewId); + $review->created_at = now()->subDays(8); + $review->save(); + + $response = $this->putJson("/api/workers/reviews/{$reviewId}", [ + 'rating' => 3, + 'comment' => 'Okay employer.', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(403); + $response->assertJsonPath('success', false); + $response->assertJsonPath('message', 'The 7-day edit window for this review has expired.'); + } + + public function test_employer_review_controller_eligibility_and_edit_window() + { + $worker = $this->createTestWorker(); + $employer = $this->createTestEmployer(); + + $job = JobPost::create([ + 'employer_id' => $employer->id, + 'title' => 'Test Job', + 'location' => 'Dubai', + 'salary' => 2500, + 'workers_needed' => 1, + 'job_type' => 'Full Time', + 'start_date' => now()->subMonths(4)->toDateString(), + 'description' => 'Test Description', + 'status' => 'active', + ]); + + // 1. Cannot review before eligibility period (set eligibility to 3 months) + $app = JobApplication::create([ + 'job_id' => $job->id, + 'worker_id' => $worker->id, + 'status' => 'hired', + 'joining_confirmed_at' => now()->subMonths(2)->toDateString(), + ]); + + $response = $this->postJson('/api/employers/reviews', [ + 'worker_id' => $worker->id, + 'rating' => 5, + 'comment' => 'Great worker!', + ], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(403); + $response->assertJsonPath('success', false); + $response->assertJsonPath('message', 'You can write a review only after 3 months from the joining date.'); + + // 2. Can review after eligibility period + $app->joining_confirmed_at = now()->subMonths(3)->subDays(5)->toDateString(); + $app->save(); + + $response = $this->postJson('/api/employers/reviews', [ + 'worker_id' => $worker->id, + 'rating' => 5, + 'comment' => 'Great worker!', + ], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(201); + $response->assertJsonPath('success', true); + $reviewId = $response->json('data.review.id'); + + // 3. Edit review within 7 days + $response = $this->putJson("/api/employers/reviews/{$reviewId}", [ + 'rating' => 4, + 'comment' => 'Good worker indeed.', + ], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + $response->assertStatus(200); + + // 4. Cannot edit review after 7 days + $review = Review::find($reviewId); + $review->created_at = now()->subDays(8); + $review->save(); + + $response = $this->putJson("/api/employers/reviews/{$reviewId}", [ + 'rating' => 3, + 'comment' => 'Okay worker.', + ], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(403); + $response->assertJsonPath('success', false); + $response->assertJsonPath('message', 'The 7-day edit window for this review has expired.'); + } +} diff --git a/tests/Feature/NotificationApiTest.php b/tests/Feature/NotificationApiTest.php new file mode 100644 index 0000000..507bdac --- /dev/null +++ b/tests/Feature/NotificationApiTest.php @@ -0,0 +1,248 @@ + 'John Worker', + 'email' => 'worker@example.com', + 'password' => bcrypt('password'), + 'phone' => '971500000001', + 'nationality' => 'Indian', + 'status' => 'active', + 'api_token' => 'worker_api_token', + 'age' => 25, + 'salary' => 2000, + 'availability' => 'Immediate', + 'experience' => '2 Years', + 'religion' => 'Christian', + 'bio' => 'Helper info', + 'passport_status' => 'valid', + ], $overrides)); + } + + private function createTestEmployer(array $overrides = []): User + { + return User::create(array_merge([ + 'name' => 'Alice Employer', + 'email' => 'employer@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + 'api_token' => 'employer_api_token', + 'subscription_status' => 'active', + ], $overrides)); + } + + public function test_worker_can_list_notifications_with_filters() + { + $worker = $this->createTestWorker(); + + // Send two notifications + $worker->notify(new DocumentExpiryNotification('Passport', '2026-12-31', 30, '30_days')); + $worker->notify(new DocumentExpiryNotification('Visa', '2026-12-31', 7, '7_days')); + + // Mark the Passport notification as read deterministically + $passportNotification = $worker->unreadNotifications()->where('data->document_type', 'Passport')->first(); + $passportNotification->markAsRead(); + + // 1. Fetch all notifications + $response = $this->getJson('/api/workers/notifications', [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $response->assertJsonCount(2, 'data.notifications'); + + // 2. Fetch unread notifications + $response = $this->getJson('/api/workers/notifications?filter=unread', [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonCount(1, 'data.notifications'); + $response->assertJsonPath('data.notifications.0.data.document_type', 'Visa'); + + // 3. Fetch read notifications + $response = $this->getJson('/api/workers/notifications?filter=read', [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonCount(1, 'data.notifications'); + $response->assertJsonPath('data.notifications.0.data.document_type', 'Passport'); + } + + public function test_worker_can_mark_single_and_all_notifications_as_read() + { + $worker = $this->createTestWorker(); + + // Send two notifications + $worker->notify(new DocumentExpiryNotification('Passport', '2026-12-31', 30, '30_days')); + $worker->notify(new DocumentExpiryNotification('Visa', '2026-12-31', 7, '7_days')); + + $unread = $worker->unreadNotifications; + $this->assertEquals(2, $unread->count()); + + // 1. Mark single notification as read + $notificationId = $unread->first()->id; + $response = $this->putJson("/api/workers/notifications/{$notificationId}/read", [], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(1, $worker->fresh()->unreadNotifications->count()); + + // 2. Mark all notifications as read + $response = $this->putJson('/api/workers/notifications/read', [], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(0, $worker->fresh()->unreadNotifications->count()); + } + + public function test_worker_can_delete_single_and_all_notifications() + { + $worker = $this->createTestWorker(); + + // Send two notifications + $worker->notify(new DocumentExpiryNotification('Passport', '2026-12-31', 30, '30_days')); + $worker->notify(new DocumentExpiryNotification('Visa', '2026-12-31', 7, '7_days')); + + $this->assertEquals(2, $worker->notifications()->count()); + + // 1. Delete single notification + $notificationId = $worker->notifications->first()->id; + $response = $this->deleteJson("/api/workers/notifications/{$notificationId}", [], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(1, $worker->fresh()->notifications()->count()); + + // 2. Delete all notifications + $response = $this->deleteJson('/api/workers/notifications', [], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(0, $worker->fresh()->notifications()->count()); + } + + public function test_employer_can_list_notifications_with_filters() + { + $employer = $this->createTestEmployer(); + + // Send two notifications + $employer->notify(new DocumentExpiryNotification('Emirates ID', '2026-12-31', 30, '30_days')); + $employer->notify(new DocumentExpiryNotification('Trade License', '2026-12-31', 7, '7_days')); + + // Mark the Emirates ID notification as read deterministically + $emiratesIdNotification = $employer->unreadNotifications()->where('data->document_type', 'Emirates ID')->first(); + $emiratesIdNotification->markAsRead(); + + // 1. Fetch all notifications + $response = $this->getJson('/api/employers/notifications', [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $response->assertJsonCount(2, 'data.notifications'); + + // 2. Fetch unread notifications + $response = $this->getJson('/api/employers/notifications?filter=unread', [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonCount(1, 'data.notifications'); + $response->assertJsonPath('data.notifications.0.data.document_type', 'Trade License'); + + // 3. Fetch read notifications + $response = $this->getJson('/api/employers/notifications?filter=read', [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonCount(1, 'data.notifications'); + $response->assertJsonPath('data.notifications.0.data.document_type', 'Emirates ID'); + } + + public function test_employer_can_mark_single_and_all_notifications_as_read() + { + $employer = $this->createTestEmployer(); + + // Send two notifications + $employer->notify(new DocumentExpiryNotification('Emirates ID', '2026-12-31', 30, '30_days')); + $employer->notify(new DocumentExpiryNotification('Trade License', '2026-12-31', 7, '7_days')); + + $unread = $employer->unreadNotifications; + $this->assertEquals(2, $unread->count()); + + // 1. Mark single notification as read + $notificationId = $unread->first()->id; + $response = $this->putJson("/api/employers/notifications/{$notificationId}/read", [], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(1, $employer->fresh()->unreadNotifications->count()); + + // 2. Mark all notifications as read + $response = $this->putJson('/api/employers/notifications/read', [], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(0, $employer->fresh()->unreadNotifications->count()); + } + + public function test_employer_can_delete_single_and_all_notifications() + { + $employer = $this->createTestEmployer(); + + // Send two notifications + $employer->notify(new DocumentExpiryNotification('Emirates ID', '2026-12-31', 30, '30_days')); + $employer->notify(new DocumentExpiryNotification('Trade License', '2026-12-31', 7, '7_days')); + + $this->assertEquals(2, $employer->notifications()->count()); + + // 1. Delete single notification + $notificationId = $employer->notifications->first()->id; + $response = $this->deleteJson("/api/employers/notifications/{$notificationId}", [], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(1, $employer->fresh()->notifications()->count()); + + // 2. Delete all notifications + $response = $this->deleteJson('/api/employers/notifications', [], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(0, $employer->fresh()->notifications()->count()); + } +} diff --git a/tests/Feature/SponsorAuthApiTest.php b/tests/Feature/SponsorAuthApiTest.php index ed2059c..be550c9 100644 --- a/tests/Feature/SponsorAuthApiTest.php +++ b/tests/Feature/SponsorAuthApiTest.php @@ -217,15 +217,18 @@ public function test_sponsor_can_post_charity_event() $response = $this->withHeaders([ 'Authorization' => 'Bearer sponsor_token_xyz', ])->postJson('/api/sponsors/charity-events', [ - 'title' => 'Community Ramadan Iftar', - 'body' => 'Join us for a free community Iftar gathering at the center.', - 'type' => 'charity', - 'event_date' => '2026-06-15', - 'start_time' => '09:00 AM', - 'end_time' => '04:00 PM', - 'provided_items' => 'Free Iftar meals, Water, Dates', - 'location_details' => 'Al Quoz Community Center, Dubai', - 'location_pin' => 'https://maps.app.goo.gl/xyz', + 'title' => 'Community Ramadan Iftar', + 'body' => 'Join us for a free community Iftar gathering at the center.', + 'type' => 'charity', + 'event_date' => '2026-06-15', + 'start_time' => '09:00 AM', + 'end_time' => '04:00 PM', + 'provided_items' => 'Free Iftar meals, Water, Dates', + 'location_details' => 'Al Quoz Community Center, Dubai', + 'location_pin' => 'https://maps.app.goo.gl/xyz', + 'contact_person_name' => 'Jane Doe', + 'contact_number' => '551234567', + 'country_code' => '+971', ]); $response->assertStatus(201) @@ -246,6 +249,9 @@ public function test_sponsor_can_post_charity_event() 'location_details' => 'Al Quoz Community Center, Dubai', 'location_pin' => 'https://maps.app.goo.gl/xyz', 'content' => 'Join us for a free community Iftar gathering at the center.', + 'contact_person_name' => 'Jane Doe', + 'contact_number' => '551234567', + 'country_code' => '+971', ] ] ]) @@ -266,6 +272,9 @@ public function test_sponsor_can_post_charity_event() 'location_details', 'location_pin', 'content', + 'contact_person_name', + 'contact_number', + 'country_code', ] ] ]); @@ -277,6 +286,82 @@ public function test_sponsor_can_post_charity_event() ]); } + /** + * Test validation rules for charity event posting. + */ + public function test_sponsor_charity_event_validation() + { + $sponsor = Sponsor::create([ + 'full_name' => 'Test Sponsor', + 'organization_name' => 'Save the Children', + 'email' => 'tsponsor@example.com', + 'mobile' => '+971509994444', + 'password' => Hash::make('password123'), + 'api_token' => 'sponsor_token_xyz', + 'status' => 'active', + ]); + + // 1. Missing fields + $response = $this->withHeaders([ + 'Authorization' => 'Bearer sponsor_token_xyz', + ])->postJson('/api/sponsors/charity-events', [ + 'title' => 'Community Ramadan Iftar', + 'body' => 'Join us for a free community Iftar gathering at the center.', + 'type' => 'charity', + 'event_date' => '2026-06-15', + 'start_time' => '09:00 AM', + 'end_time' => '04:00 PM', + 'provided_items' => 'Free Iftar meals, Water, Dates', + 'location_details' => 'Al Quoz Community Center, Dubai', + 'location_pin' => 'https://maps.app.goo.gl/xyz', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['contact_person_name', 'contact_number', 'country_code']); + + // 2. Invalid country code format (missing +) + $response = $this->withHeaders([ + 'Authorization' => 'Bearer sponsor_token_xyz', + ])->postJson('/api/sponsors/charity-events', [ + 'title' => 'Community Ramadan Iftar', + 'body' => 'Join us for a free community Iftar gathering at the center.', + 'type' => 'charity', + 'event_date' => '2026-06-15', + 'start_time' => '09:00 AM', + 'end_time' => '04:00 PM', + 'provided_items' => 'Free Iftar meals, Water, Dates', + 'location_details' => 'Al Quoz Community Center, Dubai', + 'location_pin' => 'https://maps.app.goo.gl/xyz', + 'contact_person_name' => 'Jane Doe', + 'contact_number' => '551234567', + 'country_code' => '971', // missing plus + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['country_code']); + + // 3. Invalid contact number format (non-digits) + $response = $this->withHeaders([ + 'Authorization' => 'Bearer sponsor_token_xyz', + ])->postJson('/api/sponsors/charity-events', [ + 'title' => 'Community Ramadan Iftar', + 'body' => 'Join us for a free community Iftar gathering at the center.', + 'type' => 'charity', + 'event_date' => '2026-06-15', + 'start_time' => '09:00 AM', + 'end_time' => '04:00 PM', + 'provided_items' => 'Free Iftar meals, Water, Dates', + 'location_details' => 'Al Quoz Community Center, Dubai', + 'location_pin' => 'https://maps.app.goo.gl/xyz', + 'contact_person_name' => 'Jane Doe', + 'contact_number' => '55-123-abc', // invalid format + 'country_code' => '+971', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['contact_number']); + } + /** * Test that a sponsor can list charity events showing both employer and sponsor events. */ diff --git a/tests/Feature/WorkerEmployersApiTest.php b/tests/Feature/WorkerEmployersApiTest.php new file mode 100644 index 0000000..69303b4 --- /dev/null +++ b/tests/Feature/WorkerEmployersApiTest.php @@ -0,0 +1,239 @@ + 'Rahul Sharma', + 'email' => 'rahul@example.com', + 'phone' => '+971501234567', + 'language' => 'HI', + 'password' => bcrypt('password'), + 'nationality' => 'Indian', + 'age' => 25, + 'salary' => 1500, + 'availability' => 'Immediate', + 'experience' => 'Not Specified', + 'religion' => 'Not Specified', + 'bio' => 'Test', + 'verified' => true, + 'status' => 'active', + 'api_token' => 'worker-test-token', + ]); + + // Create passport document to prevent pending 404 + WorkerDocument::create([ + 'worker_id' => $worker->id, + 'type' => 'passport', + 'number' => '123456', + 'file_path' => 'passports/test.pdf', + ]); + + // Create an employer + $employer = User::create([ + 'name' => 'Employer One', + 'email' => 'emp1@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + ]); + + EmployerProfile::create([ + 'user_id' => $employer->id, + 'company_name' => 'Ahmad Tech Ltd', + 'nationality' => 'UAE', + 'city' => 'Dubai', + ]); + + // Create an accepted job offer (current employer) + JobOffer::create([ + 'employer_id' => $employer->id, + 'worker_id' => $worker->id, + 'work_date' => now()->subDays(10), + 'location' => 'Dubai', + 'salary' => 2500, + 'status' => 'accepted', + 'notes' => 'Current Active Job', + ]); + + // Hit the worker dashboard API + $response = $this->withHeaders([ + 'Authorization' => 'Bearer worker-test-token', + ])->getJson('/api/workers/dashboard'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'current_employer' => [ + 'id' => $employer->id, + 'name' => 'Employer One', + 'company_name' => 'Ahmad Tech Ltd', + 'city' => 'Dubai', + 'nationality' => 'UAE', + ], + 'currently_working_sponsor' => [ + 'id' => $employer->id, + 'name' => 'Employer One', + 'company_name' => 'Ahmad Tech Ltd', + 'city' => 'Dubai', + 'nationality' => 'UAE', + ] + ] + ]); + } + + public function test_worker_employers_list_pagination_and_sorting() + { + // Create worker + $worker = Worker::create([ + 'name' => 'Rahul Sharma', + 'email' => 'rahul@example.com', + 'phone' => '+971501234567', + 'language' => 'HI', + 'password' => bcrypt('password'), + 'nationality' => 'Indian', + 'age' => 25, + 'salary' => 1500, + 'availability' => 'Immediate', + 'experience' => 'Not Specified', + 'religion' => 'Not Specified', + 'bio' => 'Test', + 'verified' => true, + 'status' => 'active', + 'api_token' => 'worker-test-token', + ]); + + WorkerDocument::create([ + 'worker_id' => $worker->id, + 'type' => 'passport', + 'number' => '123456', + 'file_path' => 'passports/test.pdf', + ]); + + // Create 3 employers + $emp1 = User::create([ + 'name' => 'Employer One', + 'email' => 'emp1@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer' + ]); + EmployerProfile::create(['user_id' => $emp1->id, 'company_name' => 'Tech One']); + + $emp2 = User::create([ + 'name' => 'Employer Two', + 'email' => 'emp2@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer' + ]); + EmployerProfile::create(['user_id' => $emp2->id, 'company_name' => 'Tech Two']); + + $emp3 = User::create([ + 'name' => 'Employer Three', + 'email' => 'emp3@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer' + ]); + EmployerProfile::create(['user_id' => $emp3->id, 'company_name' => 'Tech Three']); + + // Create JobOffers: + // Offer 1: Completed status, joining 20 days ago + JobOffer::create([ + 'employer_id' => $emp1->id, + 'worker_id' => $worker->id, + 'work_date' => now()->subDays(20), + 'location' => 'Abu Dhabi', + 'salary' => 2000, + 'status' => 'completed', + 'notes' => 'Past job', + ]); + + // Offer 2: Cancelled status, joining 30 days ago + JobOffer::create([ + 'employer_id' => $emp2->id, + 'worker_id' => $worker->id, + 'work_date' => now()->subDays(30), + 'location' => 'Sharjah', + 'salary' => 1800, + 'status' => 'cancelled', + 'notes' => 'Cancelled job', + ]); + + // Offer 3: Accepted (Active) status, joining 10 days ago + JobOffer::create([ + 'employer_id' => $emp3->id, + 'worker_id' => $worker->id, + 'work_date' => now()->subDays(10), + 'location' => 'Dubai', + 'salary' => 3000, + 'status' => 'accepted', + 'notes' => 'Active job', + ]); + + // List all employers (should return 3) + // With Active employer at the top, then previous employers sorted by work_date desc: + // 1st: Emp 3 (Active) + // 2nd: Emp 1 (Completed, 20 days ago) + // 3rd: Emp 2 (Cancelled, 30 days ago) + $response = $this->withHeaders([ + 'Authorization' => 'Bearer worker-test-token', + ])->getJson('/api/workers/employers'); + + $response->assertStatus(200); + $data = $response->json('data.employers'); + + $this->assertCount(3, $data); + $this->assertEquals('Active', $data[0]['status']); + $this->assertEquals('Employer Three', $data[0]['employer']['name']); + $this->assertEquals('Completed', $data[1]['status']); + $this->assertEquals('Employer One', $data[1]['employer']['name']); + $this->assertEquals('Cancelled', $data[2]['status']); + $this->assertEquals('Employer Two', $data[2]['employer']['name']); + + // Test filtering by status Completed + $responseFiltered = $this->withHeaders([ + 'Authorization' => 'Bearer worker-test-token', + ])->getJson('/api/workers/employers?status=completed'); + + $responseFiltered->assertStatus(200); + $filteredData = $responseFiltered->json('data.employers'); + $this->assertCount(1, $filteredData); + $this->assertEquals('Completed', $filteredData[0]['status']); + $this->assertEquals('Employer One', $filteredData[0]['employer']['name']); + + // Test filtering by status Active + $responseActive = $this->withHeaders([ + 'Authorization' => 'Bearer worker-test-token', + ])->getJson('/api/workers/employers?status=active'); + + $responseActive->assertStatus(200); + $activeData = $responseActive->json('data.employers'); + $this->assertCount(1, $activeData); + $this->assertEquals('Active', $activeData[0]['status']); + $this->assertEquals('Employer Three', $activeData[0]['employer']['name']); + + // Test pagination + $responsePaginated = $this->withHeaders([ + 'Authorization' => 'Bearer worker-test-token', + ])->getJson('/api/workers/employers?per_page=2'); + + $responsePaginated->assertStatus(200); + $paginatedData = $responsePaginated->json('data.employers'); + $this->assertCount(2, $paginatedData); + $this->assertEquals(3, $responsePaginated->json('data.pagination.total')); + $this->assertEquals(2, $responsePaginated->json('data.pagination.per_page')); + } +} diff --git a/tests/Feature/WorkerJourneyApiTest.php b/tests/Feature/WorkerJourneyApiTest.php index 3cf25d2..f978614 100644 --- a/tests/Feature/WorkerJourneyApiTest.php +++ b/tests/Feature/WorkerJourneyApiTest.php @@ -507,7 +507,7 @@ public function test_register_passport_and_visa_with_direct_json_payload() 'nationality' => 'Filipino', 'age' => $expectedAge, 'verified' => true, - 'visa_status' => 'CLEANER', + 'visa_status' => 'Tourist Visa', ]); $this->assertDatabaseHas('worker_documents', [ @@ -636,7 +636,7 @@ public function test_register_visa_with_full_ocr_fields() $visaDoc = \App\Models\WorkerDocument::where('worker_id', $workerId)->where('type', 'visa')->first(); $this->assertNotNull($visaDoc->ocr_data); $this->assertEquals('uae_visa', $visaDoc->ocr_data['document_type']); - $this->assertEquals('ENTRY PERMIT', $visaDoc->ocr_data['visa_type']); + $this->assertEquals('Tourist Visa', $visaDoc->ocr_data['visa_type']); $this->assertEquals('Mr.MUHAMMAD NADEEM RASHEED', $visaDoc->ocr_data['full_name']); $this->assertEquals(97.4, $visaDoc->ocr_accuracy); } @@ -689,7 +689,7 @@ public function test_register_passport_and_visa_with_json_encoded_strings() 'name' => 'Johnny Test', 'phone' => '+971509999999', 'verified' => true, - 'visa_status' => 'HELPER', + 'visa_status' => 'Tourist Visa', ]); $this->assertDatabaseHas('worker_documents', [