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