mohan #25

Merged
mohanmd merged 10 commits from mohan into master 2026-07-04 16:10:27 +00:00
30 changed files with 2946 additions and 82 deletions
Showing only changes of commit f1ac2c8436 - Show all commits

View File

@ -69,3 +69,11 @@ EMIRATES_BACK_API_URL=
SPONSOR_LICENSE_API_URL=
VITE_APP_NAME="${APP_NAME}"
# Notification Reminder Settings
WORKER_NO_RESPONSE_REMINDER_DAYS=14
REVIEW_ELIGIBILITY_MONTHS=3
REVIEW_EDIT_PERIOD_DAYS=7
EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS=3
WORKER_JOIN_CONFIRM_REMINDER_DAYS=3
DOCUMENT_EXPIRY_REMINDER_DAYS=30,7,1

View File

@ -46,17 +46,22 @@ public function handle()
$this->info('6. Checking Pending Direct Job Offers...');
$this->checkJobOffers();
$this->info('7. Checking Worker No Response...');
$this->checkWorkerNoResponse();
$this->info('8. Checking Review Reminders...');
$this->checkReviewReminders();
$this->info('Automated reminder system run complete!');
}
private function getReminderLevel(int $days): ?string
private function getReminderLevel(int $days, string $configKey, string $default): ?string
{
if ($days === 7) {
return '7_days';
} elseif ($days === 3) {
return '3_days';
} elseif ($days === 1) {
return '1_day';
$value = config($configKey, $default);
$configuredDays = array_map('intval', array_filter(explode(',', $value), 'strlen'));
if (in_array($days, $configuredDays)) {
return $days === 1 ? '1_day' : "{$days}_days";
}
return null;
}
@ -96,7 +101,7 @@ private function checkWorkerDocuments()
$expiry = Carbon::parse($doc->expiry_date);
$days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false);
$level = $this->getReminderLevel($days);
$level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1');
if (!$level) continue;
if ($this->alreadySent($worker, 'document_expiry', $doc, $level)) continue;
@ -123,7 +128,7 @@ private function checkEmployerProfiles()
$expiry = Carbon::parse($profile->emirates_id_expiry);
$days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false);
$level = $this->getReminderLevel($days);
$level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1');
if (!$level) continue;
if ($this->alreadySent($user, 'document_expiry', $profile, $level)) continue;
@ -149,7 +154,7 @@ private function checkSponsors()
$expiry = Carbon::parse($sponsor->emirates_id_expiry);
$days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false);
$level = $this->getReminderLevel($days);
$level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1');
if ($level && !$this->alreadySent($sponsor, 'document_expiry_emirates', $sponsor, $level)) {
$this->info("Sending {$level} reminder to sponsor {$sponsor->full_name} for Emirates ID expiry.");
$sponsor->notify(new \App\Notifications\DocumentExpiryNotification(
@ -167,7 +172,7 @@ private function checkSponsors()
$expiry = Carbon::parse($sponsor->license_expiry);
$days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false);
$level = $this->getReminderLevel($days);
$level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1');
if ($level && !$this->alreadySent($sponsor, 'document_expiry_license', $sponsor, $level)) {
$this->info("Sending {$level} reminder to sponsor {$sponsor->full_name} for License expiry.");
$sponsor->notify(new \App\Notifications\DocumentExpiryNotification(
@ -199,7 +204,7 @@ private function checkHireConfirmations()
$startDate = Carbon::parse($job->start_date);
$days = (int) now()->startOfDay()->diffInDays($startDate->startOfDay(), false);
$level = $this->getReminderLevel($days);
$level = $this->getReminderLevel($days, 'reminders.employer_hire_confirm_reminder_days', '3,7');
if (!$level) continue;
if ($this->alreadySent($employer, 'hire_confirmation', $app, $level)) continue;
@ -235,7 +240,7 @@ private function checkJoiningConfirmations()
$startDate = Carbon::parse($job->start_date);
$days = (int) now()->startOfDay()->diffInDays($startDate->startOfDay(), false);
$level = $this->getReminderLevel($days);
$level = $this->getReminderLevel($days, 'reminders.worker_join_confirm_reminder_days', '3,7');
if (!$level) continue;
if ($this->alreadySent($worker, 'joining_confirmation', $app, $level)) continue;
@ -269,7 +274,7 @@ private function checkJobOffers()
$workDate = Carbon::parse($offer->work_date);
$days = (int) now()->startOfDay()->diffInDays($workDate->startOfDay(), false);
$level = $this->getReminderLevel($days);
$level = $this->getReminderLevel($days, 'reminders.worker_join_confirm_reminder_days', '3,7');
if (!$level) continue;
if ($this->alreadySent($worker, 'pending_confirmation', $offer, $level)) continue;
@ -288,4 +293,166 @@ private function checkJobOffers()
$this->logReminder($worker, 'pending_confirmation', $offer, $level, 'all');
}
}
private function checkWorkerNoResponse()
{
$noResponseDays = (int) config('reminders.worker_no_response_reminder_days', 14);
// 1. Pending direct Job Offers
$offers = \App\Models\JobOffer::where('status', 'pending')
->with(['worker', 'employer'])
->get();
foreach ($offers as $offer) {
$worker = $offer->worker;
if (!$worker) continue;
$employer = $offer->employer;
$employerName = $employer ? $employer->name : 'Employer';
$daysSinceCreation = (int) Carbon::parse($offer->created_at)->startOfDay()->diffInDays(now()->startOfDay(), false);
if ($daysSinceCreation !== $noResponseDays) continue;
$level = $daysSinceCreation === 1 ? '1_day' : "{$daysSinceCreation}_days";
if ($this->alreadySent($worker, 'worker_no_response_offer', $offer, $level)) continue;
$this->info("Sending no-response reminder to worker {$worker->name} for pending direct offer from {$employerName}.");
$worker->notify(new \App\Notifications\WorkerNoResponseNotification(
'worker_no_response_offer',
"Action Required: Pending Job Offer",
"You have a pending job offer from {$employerName} sent on {$offer->created_at->toDateString()} that you have not responded to.",
$daysSinceCreation,
get_class($offer),
$offer->id
));
$this->logReminder($worker, 'worker_no_response_offer', $offer, $level, 'all');
}
// 2. Chat conversations where the last message is from the employer
$conversations = \App\Models\Conversation::with(['employer', 'worker'])->get();
foreach ($conversations as $conv) {
$worker = $conv->worker;
if (!$worker) continue;
$employer = $conv->employer;
$employerName = $employer ? $employer->name : 'Employer';
$lastMessage = $conv->messages()->latest()->first();
if (!$lastMessage) continue;
$isEmployerSender = false;
if ($lastMessage->sender_type === 'App\Models\User' || $lastMessage->sender_id == $conv->employer_id) {
$isEmployerSender = true;
}
if (!$isEmployerSender) continue;
$daysSinceMessage = (int) Carbon::parse($lastMessage->created_at)->startOfDay()->diffInDays(now()->startOfDay(), false);
if ($daysSinceMessage !== $noResponseDays) continue;
$level = $daysSinceMessage === 1 ? '1_day' : "{$daysSinceMessage}_days";
if ($this->alreadySent($worker, 'worker_no_response_chat', $lastMessage, $level)) continue;
$this->info("Sending no-response chat reminder to worker {$worker->name} for conversation with {$employerName}.");
$worker->notify(new \App\Notifications\WorkerNoResponseNotification(
'worker_no_response_chat',
"New Messages: Reply to {$employerName}",
"You have a message from {$employerName} that you have not responded to for {$daysSinceMessage} days.",
$daysSinceMessage,
get_class($lastMessage),
$lastMessage->id
));
$this->logReminder($worker, 'worker_no_response_chat', $lastMessage, $level, 'all');
}
}
private function checkReviewReminders()
{
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
$level = "{$eligibilityMonths}_months";
// 1. Standard Job Applications with status 'hired'
$applications = \App\Models\JobApplication::where('status', 'hired')
->whereNotNull('joining_confirmed_at')
->with(['jobPost.employer', 'worker'])
->get();
foreach ($applications as $app) {
$job = $app->jobPost;
if (!$job) continue;
$employer = $job->employer;
$worker = $app->worker;
if (!$employer || !$worker) continue;
$joiningDate = Carbon::parse($app->joining_confirmed_at);
$months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
if ($months >= $eligibilityMonths) {
// Remind worker to review employer
if (!$this->alreadySent($worker, 'review_employer_reminder', $app, $level)) {
$this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name}.");
$worker->notify(new \App\Notifications\WorkerReviewReminderNotification(
$employer->name,
$app->id,
$level
));
$this->logReminder($worker, 'review_employer_reminder', $app, $level, 'all');
}
// Remind employer to review worker
if (!$this->alreadySent($employer, 'review_worker_reminder', $app, $level)) {
$this->info("Sending review reminder to employer {$employer->name} to review worker {$worker->name}.");
$employer->notify(new \App\Notifications\EmployerReviewReminderNotification(
$worker->name,
$app->id,
$level
));
$this->logReminder($employer, 'review_worker_reminder', $app, $level, 'all');
}
}
}
// 2. Direct Job Offers with status 'accepted'
$offers = \App\Models\JobOffer::where('status', 'accepted')
->with(['worker', 'employer'])
->get();
foreach ($offers as $offer) {
$worker = $offer->worker;
$employer = $offer->employer;
if (!$worker || !$employer) continue;
$joiningDate = $offer->work_date ? Carbon::parse($offer->work_date) : $offer->updated_at;
$months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
if ($months >= $eligibilityMonths) {
// Remind worker to review employer
if (!$this->alreadySent($worker, 'review_employer_reminder', $offer, $level)) {
$this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name} (direct offer).");
$worker->notify(new \App\Notifications\WorkerReviewReminderNotification(
$employer->name,
$offer->id,
$level
));
$this->logReminder($worker, 'review_employer_reminder', $offer, $level, 'all');
}
// Remind employer to review worker
if (!$this->alreadySent($employer, 'review_worker_reminder', $offer, $level)) {
$this->info("Sending review reminder to employer {$employer->name} to review worker {$worker->name} (direct offer).");
$employer->notify(new \App\Notifications\EmployerReviewReminderNotification(
$worker->name,
$offer->id,
$level
));
$this->logReminder($employer, 'review_worker_reminder', $offer, $level, 'all');
}
}
}
}
}

View File

@ -132,7 +132,7 @@ public function updateProfile(Request $request, $id)
'experience' => 'required|string',
'salary' => 'nullable|numeric',
'nationality' => 'nullable|string',
'visa_status' => 'nullable|string',
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa',
'age' => 'nullable|integer',
'in_country' => 'nullable',
'preferred_job_type' => 'nullable|string',

View File

@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\User;
class EmployerNotificationController extends Controller
{
/**
* List all notifications for the employer.
* GET /api/employers/notifications
*/
public function index(Request $request)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
$filter = $request->query('filter', 'all');
$perPage = (int) $request->query('per_page', 15);
if ($filter === 'read') {
$query = $employer->readNotifications();
} elseif ($filter === 'unread') {
$query = $employer->unreadNotifications();
} else {
$query = $employer->notifications();
}
$paginator = $query->paginate($perPage);
$notifications = collect($paginator->items())->map(function ($notification) {
return [
'id' => $notification->id,
'type' => $notification->type,
'data' => $notification->data,
'read_at' => $notification->read_at ? $notification->read_at->toIso8601String() : null,
'created_at' => $notification->created_at->toIso8601String(),
'time_ago' => $notification->created_at->diffForHumans(),
];
});
return response()->json([
'success' => true,
'data' => [
'notifications' => $notifications,
'pagination' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
]
]
], 200);
}
/**
* Mark a single notification or all unread notifications as read.
* PUT /api/employers/notifications/{id}/read or PUT /api/employers/notifications/read
*/
public function markAsRead(Request $request, $id = null)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
if ($id) {
$notification = $employer->notifications()->where('id', $id)->first();
if (!$notification) {
return response()->json([
'success' => false,
'message' => 'Notification not found.'
], 404);
}
$notification->markAsRead();
} else {
$employer->unreadNotifications->markAsRead();
}
return response()->json([
'success' => true,
'message' => 'Notification(s) marked as read successfully.'
], 200);
}
/**
* Delete a single notification or all notifications.
* DELETE /api/employers/notifications/{id} or DELETE /api/employers/notifications
*/
public function destroy(Request $request, $id = null)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
if ($id) {
$notification = $employer->notifications()->where('id', $id)->first();
if (!$notification) {
return response()->json([
'success' => false,
'message' => 'Notification not found.'
], 404);
}
$notification->delete();
} else {
$employer->notifications()->delete();
}
return response()->json([
'success' => true,
'message' => 'Notification(s) deleted successfully.'
], 200);
}
}

View File

@ -35,12 +35,65 @@ public function addReview(Request $request)
}
try {
// 1. Validate confirmed hire and completed joining, and review eligibility period
$hasConfirmedHire = false;
$joiningDate = null;
// Check standard JobApplication where the job belongs to this employer and worker matches
$app = \App\Models\JobApplication::where('worker_id', $request->worker_id)
->where('status', 'hired')
->whereNotNull('joining_confirmed_at')
->whereHas('jobPost', function ($query) use ($employer) {
$query->where('employer_id', $employer->id);
})
->first();
if ($app) {
$hasConfirmedHire = true;
$joiningDate = \Carbon\Carbon::parse($app->joining_confirmed_at);
} else {
// Check direct JobOffer
$offer = \App\Models\JobOffer::where('worker_id', $request->worker_id)
->where('employer_id', $employer->id)
->where('status', 'accepted')
->first();
if ($offer) {
$hasConfirmedHire = true;
$joiningDate = $offer->work_date ? \Carbon\Carbon::parse($offer->work_date) : $offer->updated_at;
}
}
if (!$hasConfirmedHire) {
return response()->json([
'success' => false,
'message' => 'You can only review workers with a confirmed hire and completed joining.'
], 403);
}
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) {
return response()->json([
'success' => false,
'message' => "You can write a review only after {$eligibilityMonths} months from the joining date."
], 403);
}
// Check if employer has already reviewed this worker
$existingReview = Review::where('employer_id', $employer->id)
->where('worker_id', $request->worker_id)
->first();
if ($existingReview) {
// Check if the edit window has expired
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
if ($existingReview->created_at->addDays($editPeriodDays)->isPast()) {
return response()->json([
'success' => false,
'message' => "The {$editPeriodDays}-day edit window for this review has expired."
], 403);
}
// If they already have a review, we can update it or direct them to the edit endpoint.
// To be robust, let's update it directly and inform them it was updated (acting as an edit).
$existingReview->update([
@ -118,6 +171,15 @@ public function editReview(Request $request, $id)
], 404);
}
// Check if the edit window has expired
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
if ($review->created_at->addDays($editPeriodDays)->isPast()) {
return response()->json([
'success' => false,
'message' => "The {$editPeriodDays}-day edit window for this review has expired."
], 403);
}
$review->update([
'rating' => $request->rating,
'comment' => $request->comment,

View File

@ -944,8 +944,8 @@ public function getWorkerDetail(Request $request, $id)
];
// Visa status
$visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa'];
$visaStatus = $visaStatusesList[$w->id % 5];
$visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa'];
$visaStatus = $visaStatusesList[$w->id % 3];
$photo = null;

View File

@ -229,15 +229,18 @@ public function postCharityEvent(Request $request)
}
$validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
'title' => 'required|string|max:255',
'body' => 'required|string',
'type' => 'nullable|string|in:charity,info,warning,success',
'event_date' => 'required|string',
'start_time' => 'required|string',
'end_time' => 'required|string',
'provided_items' => 'required|string',
'location_details' => 'required|string',
'location_pin' => 'required|string|url',
'title' => 'required|string|max:255',
'body' => 'required|string',
'type' => 'nullable|string|in:charity,info,warning,success',
'event_date' => 'required|string',
'start_time' => 'required|string',
'end_time' => 'required|string',
'provided_items' => 'required|string',
'location_details' => 'required|string',
'location_pin' => 'required|string|url',
'contact_person_name' => 'required|string|max:255',
'contact_number' => 'required|string|regex:/^\d{7,15}$/',
'country_code' => 'required|string|regex:/^\+\d{1,4}$/',
]);
if ($validator->fails()) {
@ -252,13 +255,16 @@ public function postCharityEvent(Request $request)
$eventTime = $request->start_time . ' - ' . $request->end_time;
$bodyJson = json_encode([
'type' => 'Charity',
'provided_items' => $request->provided_items,
'event_date' => $request->event_date,
'event_time' => $eventTime,
'location_details' => $request->location_details,
'location_pin' => $request->location_pin,
'content' => $request->body,
'type' => 'Charity',
'provided_items' => $request->provided_items,
'event_date' => $request->event_date,
'event_time' => $eventTime,
'location_details' => $request->location_details,
'location_pin' => $request->location_pin,
'content' => $request->body,
'contact_person_name' => $request->contact_person_name,
'contact_number' => $request->contact_number,
'country_code' => $request->country_code,
]);
$event = Announcement::create([

View File

@ -318,7 +318,7 @@ public function register(Request $request)
'age' => 'nullable|integer',
'experience' => 'nullable|string|max:100',
'in_country' => 'nullable',
'visa_status' => 'nullable|string|max:100',
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa',
'preferred_job_type' => 'nullable|string|max:100',
'main_profession' => 'nullable|string|max:100',
'gender' => 'nullable|string|in:male,female,other',
@ -1247,6 +1247,19 @@ private function normaliseDateForController(?string $raw): ?string
private function cleanVisaData(array $visaDataInput): array
{
$rawVisaType = $visaDataInput['visa_type'] ?? null;
if ($rawVisaType) {
$normalized = strtolower(trim($rawVisaType));
if (str_contains($normalized, 'residence')) {
$rawVisaType = 'Residence Visa';
} elseif (str_contains($normalized, 'employment')) {
$rawVisaType = 'Employment Visa';
} else {
$rawVisaType = 'Tourist Visa';
}
} else {
$rawVisaType = 'Tourist Visa';
}
$rawEntryPermitNo = $visaDataInput['entry_permit_no'] ?? $visaDataInput['file_number'] ?? $visaDataInput['id_number'] ?? $visaDataInput['number'] ?? null;
$rawIssueDate = $visaDataInput['issue_date'] ?? null;
$rawValidUntil = $visaDataInput['valid_until'] ?? $visaDataInput['expiry_date'] ?? null;
@ -1277,7 +1290,7 @@ private function cleanVisaData(array $visaDataInput): array
return [
'document_type' => 'uae_visa',
'country' => 'United Arab Emirates',
'visa_type' => $rawVisaType ?: 'ENTRY PERMIT',
'visa_type' => $rawVisaType,
'entry_permit_no' => $rawEntryPermitNo ?: '',
'issue_date' => $rawIssueDate ?: '',
'valid_until' => $rawValidUntil ?: '',

View File

@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Worker;
class WorkerNotificationController extends Controller
{
/**
* List all notifications for the worker.
* GET /api/workers/notifications
*/
public function index(Request $request)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
$filter = $request->query('filter', 'all');
$perPage = (int) $request->query('per_page', 15);
if ($filter === 'read') {
$query = $worker->readNotifications();
} elseif ($filter === 'unread') {
$query = $worker->unreadNotifications();
} else {
$query = $worker->notifications();
}
$paginator = $query->paginate($perPage);
$notifications = collect($paginator->items())->map(function ($notification) {
return [
'id' => $notification->id,
'type' => $notification->type,
'data' => $notification->data,
'read_at' => $notification->read_at ? $notification->read_at->toIso8601String() : null,
'created_at' => $notification->created_at->toIso8601String(),
'time_ago' => $notification->created_at->diffForHumans(),
];
});
return response()->json([
'success' => true,
'data' => [
'notifications' => $notifications,
'pagination' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
]
]
], 200);
}
/**
* Mark a single notification or all unread notifications as read.
* PUT /api/workers/notifications/{id}/read or PUT /api/workers/notifications/read
*/
public function markAsRead(Request $request, $id = null)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
if ($id) {
$notification = $worker->notifications()->where('id', $id)->first();
if (!$notification) {
return response()->json([
'success' => false,
'message' => 'Notification not found.'
], 404);
}
$notification->markAsRead();
} else {
$worker->unreadNotifications->markAsRead();
}
return response()->json([
'success' => true,
'message' => 'Notification(s) marked as read successfully.'
], 200);
}
/**
* Delete a single notification or all notifications.
* DELETE /api/workers/notifications/{id} or DELETE /api/workers/notifications
*/
public function destroy(Request $request, $id = null)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
if ($id) {
$notification = $worker->notifications()->where('id', $id)->first();
if (!$notification) {
return response()->json([
'success' => false,
'message' => 'Notification not found.'
], 404);
}
$notification->delete();
} else {
$worker->notifications()->delete();
}
return response()->json([
'success' => true,
'message' => 'Notification(s) deleted successfully.'
], 200);
}
}

View File

@ -77,7 +77,7 @@ public function updateProfile(Request $request)
'skills' => 'nullable|array',
'skills.*' => 'exists:skills,id',
'in_country' => 'nullable',
'visa_status' => 'nullable|string|max:100',
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa',
'preferred_job_type' => 'nullable|string|max:100',
'gender' => 'nullable|string|in:male,female,other',
'live_in_out' => 'nullable|string|in:live_in,live_out',
@ -658,6 +658,7 @@ public function getDashboard(Request $request)
'employer_contacted' => $employerContacted,
'profile_viewed' => $profileViewed,
'currently_working_sponsor' => $currentSponsor,
'current_employer' => $currentSponsor,
'latest_charity_events_list' => $charityEvents
]
], 200);
@ -673,6 +674,105 @@ public function getDashboard(Request $request)
}
}
/**
* Get list of employers associated with the worker.
* GET /api/workers/employers
*/
public function getEmployers(Request $request)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
try {
$query = JobOffer::where('worker_id', $worker->id)
->with(['employer.employerProfile']);
// Filtering by status
if ($request->filled('status')) {
$statusInput = strtolower($request->status);
if ($statusInput === 'active') {
$query->where('status', 'accepted');
} else {
$query->where('status', $statusInput);
}
}
// Sorting
$sortBy = $request->input('sort_by', 'joining_date');
$sortOrder = strtolower($request->input('sort_order', 'desc')) === 'asc' ? 'asc' : 'desc';
$sortColumn = 'work_date';
if ($sortBy === 'salary') {
$sortColumn = 'salary';
} elseif ($sortBy === 'status') {
$sortColumn = 'status';
} elseif ($sortBy === 'created_at') {
$sortColumn = 'created_at';
}
// Always display the current active employer (status = accepted) at the top of the list,
// followed by other employers ordered by the chosen sort column.
$query->orderByRaw("CASE WHEN status = 'accepted' THEN 0 ELSE 1 END ASC")
->orderBy($sortColumn, $sortOrder);
$perPage = (int) $request->input('per_page', 15);
$paginator = $query->paginate($perPage);
$employers = collect($paginator->items())->map(function ($offer) {
$emp = $offer->employer;
$empProfile = $emp ? $emp->employerProfile : null;
// Map 'accepted' -> 'Active' (and others to Title Case for nice presentation)
$statusFormatted = $offer->status;
if (strtolower($offer->status) === 'accepted') {
$statusFormatted = 'Active';
} else {
$statusFormatted = ucfirst($offer->status);
}
return [
'offer_id' => $offer->id,
'joining_date' => $offer->work_date ? $offer->work_date->format('Y-m-d') : null,
'location' => $offer->location,
'salary' => (int) $offer->salary,
'status' => $statusFormatted,
'notes' => $offer->notes,
'employer' => $emp ? [
'id' => $emp->id,
'name' => $emp->name,
'email' => $emp->email,
'phone' => $emp->phone,
'company_name' => $empProfile->company_name ?? 'Private Sponsor',
'city' => $empProfile->city ?? null,
'nationality' => $empProfile->nationality ?? null,
] : null
];
});
return response()->json([
'success' => true,
'data' => [
'employers' => $employers,
'pagination' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
]
]
], 200);
} catch (\Exception $e) {
logger()->error('Worker Employers API Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while loading the employers list.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* Change worker's password.
*
@ -765,6 +865,19 @@ private function normaliseDateForController(?string $raw): ?string
private function cleanVisaData(array $visaDataInput): array
{
$rawVisaType = $visaDataInput['visa_type'] ?? null;
if ($rawVisaType) {
$normalized = strtolower(trim($rawVisaType));
if (str_contains($normalized, 'residence')) {
$rawVisaType = 'Residence Visa';
} elseif (str_contains($normalized, 'employment')) {
$rawVisaType = 'Employment Visa';
} else {
$rawVisaType = 'Tourist Visa';
}
} else {
$rawVisaType = 'Tourist Visa';
}
$rawEntryPermitNo = $visaDataInput['entry_permit_no'] ?? $visaDataInput['file_number'] ?? $visaDataInput['id_number'] ?? $visaDataInput['number'] ?? null;
$rawIssueDate = $visaDataInput['issue_date'] ?? null;
$rawValidUntil = $visaDataInput['valid_until'] ?? $visaDataInput['expiry_date'] ?? null;
@ -795,7 +908,7 @@ private function cleanVisaData(array $visaDataInput): array
return [
'document_type' => 'uae_visa',
'country' => 'United Arab Emirates',
'visa_type' => $rawVisaType ?: 'ENTRY PERMIT',
'visa_type' => $rawVisaType,
'entry_permit_no' => $rawEntryPermitNo ?: '',
'issue_date' => $rawIssueDate ?: '',
'valid_until' => $rawValidUntil ?: '',

View File

@ -84,11 +84,12 @@ public function addReview(Request $request)
], 403);
}
// Verify if completed 3 months from joining date
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < 3) {
// Verify if completed configured months from joining date
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) {
return response()->json([
'success' => false,
'message' => 'You can write a review only after 3 months from the joining date.'
'message' => "You can write a review only after {$eligibilityMonths} months from the joining date."
], 403);
}
@ -169,11 +170,12 @@ public function editReview(Request $request, $id)
], 404);
}
// Check if the 7-day edit window has expired
if ($review->created_at->addDays(7)->isPast()) {
// Check if the edit window has expired
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
if ($review->created_at->addDays($editPeriodDays)->isPast()) {
return response()->json([
'success' => false,
'message' => 'The 7-day edit window for this review has expired.'
'message' => "The {$editPeriodDays}-day edit window for this review has expired."
], 403);
}
@ -224,7 +226,8 @@ public function viewReview(Request $request, $id)
], 404);
}
$editable = !$review->created_at->addDays(7)->isPast();
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
$editable = !$review->created_at->addDays($editPeriodDays)->isPast();
return response()->json([
'success' => true,
@ -278,7 +281,8 @@ public function getReviews(Request $request)
$reviews = $query->skip($offset)->take($perPage)->get()
->map(function ($rev) {
$editable = !$rev->created_at->addDays(7)->isPast();
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
$editable = !$rev->created_at->addDays($editPeriodDays)->isPast();
return [
'id' => $rev->id,

View File

@ -74,6 +74,7 @@ public function index(Request $request)
'live_in_out' => $w->live_in_out,
'in_country' => (bool)$w->in_country,
'visa_status' => $w->visa_status,
'main_profession' => $w->main_profession,
];
})->filter()->values()->toArray();
@ -105,6 +106,7 @@ public function index(Request $request)
'live_in_out' => $w->live_in_out,
'in_country' => (bool)$w->in_country,
'visa_status' => $w->visa_status,
'main_profession' => $w->main_profession,
];
})->filter()->values()->toArray();
@ -265,6 +267,13 @@ public function index(Request $request)
}));
}
if ($request->filled('main_profession')) {
$mainProfession = strtolower($request->main_profession);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($mainProfession) {
return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession;
}));
}
$nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500]));
$nationalitiesData = json_decode($nationalitiesResponse->getContent(), true);
$allNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray();

View File

@ -281,7 +281,7 @@ public function index(Request $request)
'languages' => ['All Languages', 'English', 'Arabic', 'Hindi', 'Tagalog'],
'workTypes' => ['All Types', 'full-time', 'part-time', 'live-in', 'live-out'],
'skills' => ['All Skills', 'cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'],
'visaStatuses' => ['All Visa Statuses', 'Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa'],
'visaStatuses' => ['All Visa Statuses', 'Residence Visa', 'Tourist Visa', 'Employment Visa'],
];
return Inertia::render('Employer/Workers/Index', [

View File

@ -73,16 +73,48 @@ public function getPassportStatusAttribute()
public function getVisaStatusAttribute()
{
if ($this->attributes['visa_status'] ?? null) {
return $this->attributes['visa_status'];
$val = $this->attributes['visa_status'];
$normalized = strtolower(trim($val));
if (str_contains($normalized, 'residence')) {
return 'Residence Visa';
} elseif (str_contains($normalized, 'employment')) {
return 'Employment Visa';
} else {
return 'Tourist Visa';
}
}
$visa = $this->documents->where('type', 'visa')->first();
if (!$visa) {
return 'Visa Pending';
return 'Residence Visa';
}
if ($visa->expiry_date && \Carbon\Carbon::parse($visa->expiry_date)->isPast()) {
return 'Cancelled Visa';
$ocrData = $visa->ocr_data;
$vType = $ocrData['visa_type'] ?? null;
if ($vType) {
$normalized = strtolower(trim($vType));
if (str_contains($normalized, 'residence')) {
return 'Residence Visa';
} elseif (str_contains($normalized, 'employment')) {
return 'Employment Visa';
}
}
return 'Tourist Visa';
}
public function setVisaStatusAttribute($value)
{
if (empty($value)) {
$this->attributes['visa_status'] = null;
return;
}
$normalized = strtolower(trim($value));
if (str_contains($normalized, 'residence')) {
$this->attributes['visa_status'] = 'Residence Visa';
} elseif (str_contains($normalized, 'employment')) {
$this->attributes['visa_status'] = 'Employment Visa';
} else {
$this->attributes['visa_status'] = 'Tourist Visa';
}
return 'Residence Visa';
}
public function getEmiratesIdStatusAttribute()

View File

@ -0,0 +1,103 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\FcmChannel;
class EmployerReviewReminderNotification extends Notification implements ShouldQueue
{
use Queueable;
public $workerName;
public $applicationId;
public $reviewLevel;
/**
* Create a new notification instance.
*/
public function __construct($workerName, $applicationId, $reviewLevel)
{
$this->workerName = $workerName;
$this->applicationId = $applicationId;
$this->reviewLevel = $reviewLevel;
}
/**
* Get the notification's delivery channels.
*/
public function via($notifiable): array
{
$channels = ['database'];
// Determine if email channel should be included
$emailEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
}
if ($emailEnabled && !empty($notifiable->email)) {
$channels[] = 'mail';
}
// Determine if push channel should be included
$pushEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
}
if ($pushEnabled && !empty($notifiable->fcm_token)) {
$channels[] = FcmChannel::class;
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail($notifiable): MailMessage
{
$name = $notifiable->name ?? $notifiable->full_name ?? 'User';
return (new MailMessage)
->subject("Review reminder: Rate your experience with {$this->workerName}")
->greeting("Hello {$name},")
->line("It has been {$this->reviewLevel} since you hired {$this->workerName}.")
->line("Please take a moment to review and share your experience with {$this->workerName}.")
->action('Submit Review', url('/dashboard/reviews'))
->line('Thank you for using our application!');
}
/**
* Get the array representation of the notification for in-app storage.
*/
public function toArray($notifiable): array
{
return [
'title' => "Review reminder: {$this->workerName}",
'body' => "Please take a moment to review and share your experience with {$this->workerName}.",
'worker_name' => $this->workerName,
'application_id' => $this->applicationId,
'review_level' => $this->reviewLevel,
'type' => 'review_worker_reminder',
];
}
/**
* Get the push notification representation.
*/
public function toFcm($notifiable): array
{
return [
'token' => $notifiable->fcm_token,
'title' => "Review reminder: {$this->workerName}",
'body' => "Please take a moment to review and share your experience with {$this->workerName}.",
'data' => [
'type' => 'review_worker_reminder',
'application_id' => $this->applicationId,
'review_level' => $this->reviewLevel,
],
];
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\FcmChannel;
class WorkerNoResponseNotification extends Notification implements ShouldQueue
{
use Queueable;
public $type;
public $messageTitle;
public $messageBody;
public $daysElapsed;
public $entityType;
public $entityId;
/**
* Create a new notification instance.
*/
public function __construct($type, $messageTitle, $messageBody, $daysElapsed, $entityType, $entityId)
{
$this->type = $type;
$this->messageTitle = $messageTitle;
$this->messageBody = $messageBody;
$this->daysElapsed = $daysElapsed;
$this->entityType = $entityType;
$this->entityId = $entityId;
}
/**
* Get the notification's delivery channels.
*/
public function via($notifiable): array
{
$channels = ['database'];
// Determine if email channel should be included
$emailEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
}
if ($emailEnabled && !empty($notifiable->email)) {
$channels[] = 'mail';
}
// Determine if push channel should be included
$pushEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
}
if ($pushEnabled && !empty($notifiable->fcm_token)) {
$channels[] = FcmChannel::class;
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail($notifiable): MailMessage
{
$name = $notifiable->name ?? $notifiable->full_name ?? 'User';
return (new MailMessage)
->subject($this->messageTitle)
->greeting("Hello {$name},")
->line($this->messageBody)
->action('View details', url('/dashboard'))
->line('Thank you for using our application!');
}
/**
* Get the array representation of the notification for in-app storage.
*/
public function toArray($notifiable): array
{
return [
'title' => $this->messageTitle,
'body' => $this->messageBody,
'type' => $this->type,
'days_elapsed' => $this->daysElapsed,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
];
}
/**
* Get the push notification representation.
*/
public function toFcm($notifiable): array
{
return [
'token' => $notifiable->fcm_token,
'title' => $this->messageTitle,
'body' => $this->messageBody,
'data' => [
'type' => $this->type,
'entity_id' => $this->entityId,
'days_elapsed' => $this->daysElapsed,
],
];
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\FcmChannel;
class WorkerReviewReminderNotification extends Notification implements ShouldQueue
{
use Queueable;
public $employerName;
public $applicationId;
public $reviewLevel;
/**
* Create a new notification instance.
*/
public function __construct($employerName, $applicationId, $reviewLevel)
{
$this->employerName = $employerName;
$this->applicationId = $applicationId;
$this->reviewLevel = $reviewLevel;
}
/**
* Get the notification's delivery channels.
*/
public function via($notifiable): array
{
$channels = ['database'];
// Determine if email channel should be included
$emailEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
}
if ($emailEnabled && !empty($notifiable->email)) {
$channels[] = 'mail';
}
// Determine if push channel should be included
$pushEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
}
if ($pushEnabled && !empty($notifiable->fcm_token)) {
$channels[] = FcmChannel::class;
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail($notifiable): MailMessage
{
$name = $notifiable->name ?? $notifiable->full_name ?? 'User';
return (new MailMessage)
->subject("Review reminder: Rate your experience with {$this->employerName}")
->greeting("Hello {$name},")
->line("It has been {$this->reviewLevel} since you started working with {$this->employerName}.")
->line("Please take a moment to review and share your experience with {$this->employerName}.")
->action('Submit Review', url('/dashboard/reviews'))
->line('Thank you for using our application!');
}
/**
* Get the array representation of the notification for in-app storage.
*/
public function toArray($notifiable): array
{
return [
'title' => "Review reminder: {$this->employerName}",
'body' => "Please take a moment to review and share your experience with {$this->employerName}.",
'employer_name' => $this->employerName,
'application_id' => $this->applicationId,
'review_level' => $this->reviewLevel,
'type' => 'review_employer_reminder',
];
}
/**
* Get the push notification representation.
*/
public function toFcm($notifiable): array
{
return [
'token' => $notifiable->fcm_token,
'title' => "Review reminder: {$this->employerName}",
'body' => "Please take a moment to review and share your experience with {$this->employerName}.",
'data' => [
'type' => 'review_employer_reminder',
'application_id' => $this->applicationId,
'review_level' => $this->reviewLevel,
],
];
}
}

10
config/reminders.php Normal file
View File

@ -0,0 +1,10 @@
<?php
return [
'worker_no_response_reminder_days' => env('WORKER_NO_RESPONSE_REMINDER_DAYS', 14),
'review_eligibility_months' => env('REVIEW_ELIGIBILITY_MONTHS', 3),
'review_edit_period_days' => env('REVIEW_EDIT_PERIOD_DAYS', 7),
'employer_hire_confirm_reminder_days' => env('EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS', '3,7'),
'worker_join_confirm_reminder_days' => env('WORKER_JOIN_CONFIRM_REMINDER_DAYS', '3,7,1'),
'document_expiry_reminder_days' => env('DOCUMENT_EXPIRY_REMINDER_DAYS', '30,7,3,1'),
];

View File

@ -32,5 +32,13 @@
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
<!-- Reminder Configuration for Tests -->
<env name="WORKER_NO_RESPONSE_REMINDER_DAYS" value="14"/>
<env name="REVIEW_ELIGIBILITY_MONTHS" value="3"/>
<env name="REVIEW_EDIT_PERIOD_DAYS" value="7"/>
<env name="EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS" value="3,7"/>
<env name="WORKER_JOIN_CONFIRM_REMINDER_DAYS" value="3,7,1"/>
<env name="DOCUMENT_EXPIRY_REMINDER_DAYS" value="30,7,3,1"/>
</php>
</phpunit>

View File

@ -584,7 +584,10 @@
"end_time",
"provided_items",
"location_details",
"location_pin"
"location_pin",
"contact_person_name",
"contact_number",
"country_code"
],
"properties": {
"title": {
@ -630,6 +633,18 @@
"type": "string",
"format": "uri",
"example": "https://maps.app.goo.gl/xyz"
},
"contact_person_name": {
"type": "string",
"example": "Jane Doe"
},
"contact_number": {
"type": "string",
"example": "551234567"
},
"country_code": {
"type": "string",
"example": "+971"
}
}
}
@ -1019,7 +1034,12 @@
"visa_status": {
"type": "string",
"example": "Tourist Visa",
"description": "Visa status (only required/applicable if in_country is true)."
"description": "Visa status (only required/applicable if in_country is true).",
"enum": [
"Tourist Visa",
"Employment Visa",
"Residence Visa"
]
},
"preferred_job_type": {
"type": "string",
@ -5374,7 +5394,7 @@
"name": "visa_status",
"in": "query",
"required": false,
"description": "Filter by next visa status (comma-separated list, e.g. Residence Visa,Tourist Visa). Only applicable if in country.",
"description": "Filter by next visa status (comma-separated list, e.g. Residence Visa, Tourist Visa, Employment Visa). Only applicable if in country.",
"schema": {
"type": "string"
}
@ -5589,6 +5609,15 @@
"type": "string"
}
},
{
"name": "main_profession",
"in": "query",
"required": false,
"description": "Filter candidates by main profession (e.g. Housemaid, Nanny, Cook, Driver, Caregiver).",
"schema": {
"type": "string"
}
},
{
"name": "preferred_location",
"in": "query",
@ -5638,7 +5667,7 @@
"name": "visa_status",
"in": "query",
"required": false,
"description": "Filter candidates by next visa status (comma-separated list, e.g. Residence Visa,Tourist Visa). Only applicable if in country.",
"description": "Filter candidates by next visa status (comma-separated list, e.g. Residence Visa, Tourist Visa, Employment Visa). Only applicable if in country.",
"schema": {
"type": "string"
}
@ -5926,6 +5955,49 @@
"example": 3,
"description": "Unique count of employers who viewed this worker's profile."
},
"current_employer": {
"type": "object",
"nullable": true,
"properties": {
"id": {
"type": "integer",
"example": 2
},
"name": {
"type": "string",
"example": "Ahmad"
},
"company_name": {
"type": "string",
"example": "Ahmad Tech Ltd"
},
"nationality": {
"type": "string",
"example": "UAE"
},
"city": {
"type": "string",
"example": "Dubai"
},
"email": {
"type": "string",
"example": "ahmad@example.com"
},
"phone": {
"type": "string",
"example": "+971509990001"
},
"hired_at": {
"type": "string",
"format": "date-time",
"example": "2026-06-01T15:10:24.000000Z"
},
"hired_at_formatted": {
"type": "string",
"example": "Jun 01, 2026"
}
}
},
"currently_working_sponsor": {
"type": "object",
"nullable": true,
@ -6020,6 +6092,177 @@
}
}
},
"/workers/employers": {
"get": {
"tags": [
"Worker/Employers"
],
"summary": "Get Worker Employers List",
"description": "Allows authenticated workers to retrieve all current and past employers associated with their employment history. Supports pagination, sorting, and filtering by employment status.",
"parameters": [
{
"name": "status",
"in": "query",
"required": false,
"description": "Filter by employment status (Active, Completed, Cancelled, Pending, Rejected, etc.).",
"schema": {
"type": "string",
"example": "Active"
}
},
{
"name": "sort_by",
"in": "query",
"required": false,
"description": "Field to sort by (joining_date, salary, status, created_at).",
"schema": {
"type": "string",
"default": "joining_date"
}
},
{
"name": "sort_order",
"in": "query",
"required": false,
"description": "Sort order (asc, desc).",
"schema": {
"type": "string",
"default": "desc"
}
},
{
"name": "page",
"in": "query",
"required": false,
"description": "Page number for pagination.",
"schema": {
"type": "integer",
"default": 1
}
},
{
"name": "per_page",
"in": "query",
"required": false,
"description": "Number of items per page.",
"schema": {
"type": "integer",
"default": 15
}
}
],
"responses": {
"200": {
"description": "Employers list retrieved successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"type": "object",
"properties": {
"employers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"offer_id": {
"type": "integer",
"example": 12
},
"joining_date": {
"type": "string",
"format": "date",
"example": "2026-06-01"
},
"location": {
"type": "string",
"example": "Dubai"
},
"salary": {
"type": "integer",
"example": 2500
},
"status": {
"type": "string",
"example": "Active"
},
"notes": {
"type": "string",
"example": "Hired for child care"
},
"employer": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"example": 2
},
"name": {
"type": "string",
"example": "Ahmad"
},
"email": {
"type": "string",
"example": "ahmad@example.com"
},
"phone": {
"type": "string",
"example": "+971509990001"
},
"company_name": {
"type": "string",
"example": "Ahmad Tech Ltd"
},
"city": {
"type": "string",
"example": "Dubai"
},
"nationality": {
"type": "string",
"example": "UAE"
}
}
}
}
}
},
"pagination": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"example": 1
},
"per_page": {
"type": "integer",
"example": 15
},
"current_page": {
"type": "integer",
"example": 1
},
"last_page": {
"type": "integer",
"example": 1
}
}
}
}
}
}
}
}
}
}
}
}
},
"/employers/reviews": {
"get": {
"tags": [
@ -6835,9 +7078,25 @@
"type": "string",
"example": "full-time"
},
"main_profession": {
"type": "string",
"example": "Housemaid",
"enum": [
"Housemaid",
"Nanny",
"Cook",
"Driver",
"Caregiver"
]
},
"visa_status": {
"type": "string",
"example": "Tourist Visa"
"example": "Tourist Visa",
"enum": [
"Tourist Visa",
"Employment Visa",
"Residence Visa"
]
}
}
}
@ -7891,6 +8150,508 @@
}
}
}
},
"/workers/notifications": {
"get": {
"tags": [
"Worker/Notification"
],
"summary": "List Worker Notifications",
"description": "Returns a paginated list of notifications for the authenticated worker.",
"parameters": [
{
"name": "filter",
"in": "query",
"description": "Filter notifications by read status.",
"schema": {
"type": "string",
"enum": [
"all",
"read",
"unread"
],
"default": "all"
}
},
{
"name": "per_page",
"in": "query",
"description": "Number of items per page.",
"schema": {
"type": "integer",
"default": 15
}
}
],
"responses": {
"200": {
"description": "Notifications retrieved successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"type": "object",
"properties": {
"notifications": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Notification"
}
},
"pagination": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"example": 10
},
"per_page": {
"type": "integer",
"example": 15
},
"current_page": {
"type": "integer",
"example": 1
},
"last_page": {
"type": "integer",
"example": 1
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Unauthenticated."
}
}
},
"delete": {
"tags": [
"Worker/Notification"
],
"summary": "Delete All Worker Notifications",
"description": "Deletes all notifications for the authenticated worker.",
"responses": {
"200": {
"description": "Notifications deleted successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Notification(s) deleted successfully."
}
}
}
}
}
},
"401": {
"description": "Unauthenticated."
}
}
}
},
"/workers/notifications/read": {
"put": {
"tags": [
"Worker/Notification"
],
"summary": "Mark All Notifications as Read",
"description": "Marks all unread notifications for the authenticated worker as read.",
"responses": {
"200": {
"description": "Notifications marked as read successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Notification(s) marked as read successfully."
}
}
}
}
}
},
"401": {
"description": "Unauthenticated."
}
}
}
},
"/workers/notifications/{id}": {
"delete": {
"tags": [
"Worker/Notification"
],
"summary": "Delete Single Notification",
"description": "Deletes a single notification for the authenticated worker.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "UUID of the notification to delete.",
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Notification deleted successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Notification(s) deleted successfully."
}
}
}
}
}
},
"401": {
"description": "Unauthenticated."
},
"404": {
"description": "Notification not found."
}
}
}
},
"/workers/notifications/{id}/read": {
"put": {
"tags": [
"Worker/Notification"
],
"summary": "Mark Single Notification as Read",
"description": "Marks a single notification for the authenticated worker as read.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "UUID of the notification to mark as read.",
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Notification marked as read successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Notification(s) marked as read successfully."
}
}
}
}
}
},
"401": {
"description": "Unauthenticated."
},
"404": {
"description": "Notification not found."
}
}
}
},
"/employers/notifications": {
"get": {
"tags": [
"Employer/Notification"
],
"summary": "List Employer Notifications",
"description": "Returns a paginated list of notifications for the authenticated employer.",
"parameters": [
{
"name": "filter",
"in": "query",
"description": "Filter notifications by read status.",
"schema": {
"type": "string",
"enum": [
"all",
"read",
"unread"
],
"default": "all"
}
},
{
"name": "per_page",
"in": "query",
"description": "Number of items per page.",
"schema": {
"type": "integer",
"default": 15
}
}
],
"responses": {
"200": {
"description": "Notifications retrieved successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"type": "object",
"properties": {
"notifications": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Notification"
}
},
"pagination": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"example": 10
},
"per_page": {
"type": "integer",
"example": 15
},
"current_page": {
"type": "integer",
"example": 1
},
"last_page": {
"type": "integer",
"example": 1
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Unauthenticated."
}
}
},
"delete": {
"tags": [
"Employer/Notification"
],
"summary": "Delete All Employer Notifications",
"description": "Deletes all notifications for the authenticated employer.",
"responses": {
"200": {
"description": "Notifications deleted successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Notification(s) deleted successfully."
}
}
}
}
}
},
"401": {
"description": "Unauthenticated."
}
}
}
},
"/employers/notifications/read": {
"put": {
"tags": [
"Employer/Notification"
],
"summary": "Mark All Notifications as Read",
"description": "Marks all unread notifications for the authenticated employer as read.",
"responses": {
"200": {
"description": "Notifications marked as read successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Notification(s) marked as read successfully."
}
}
}
}
}
},
"401": {
"description": "Unauthenticated."
}
}
}
},
"/employers/notifications/{id}": {
"delete": {
"tags": [
"Employer/Notification"
],
"summary": "Delete Single Notification",
"description": "Deletes a single notification for the authenticated employer.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "UUID of the notification to delete.",
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Notification deleted successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Notification(s) deleted successfully."
}
}
}
}
}
},
"401": {
"description": "Unauthenticated."
},
"404": {
"description": "Notification not found."
}
}
}
},
"/employers/notifications/{id}/read": {
"put": {
"tags": [
"Employer/Notification"
],
"summary": "Mark Single Notification as Read",
"description": "Marks a single notification for the authenticated employer as read.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "UUID of the notification to mark as read.",
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Notification marked as read successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Notification(s) marked as read successfully."
}
}
}
}
}
},
"401": {
"description": "Unauthenticated."
},
"404": {
"description": "Notification not found."
}
}
}
}
},
"components": {
@ -8157,7 +8918,12 @@
},
"visa_status": {
"type": "string",
"example": "Tourist Visa"
"example": "Tourist Visa",
"enum": [
"Tourist Visa",
"Employment Visa",
"Residence Visa"
]
},
"passport_status": {
"type": "string",
@ -8259,7 +9025,14 @@
"type": "string",
"nullable": true,
"example": "Nanny",
"description": "The main profession of the worker"
"description": "The main profession of the worker",
"enum": [
"Housemaid",
"Nanny",
"Cook",
"Driver",
"Caregiver"
]
},
"skills": {
"type": "array",
@ -8411,6 +9184,69 @@
"example": "2026-05-20T15:23:44.000000Z"
}
}
},
"Notification": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"example": "d290f1ee-6c54-4b01-90e6-d701748f0851"
},
"type": {
"type": "string",
"example": "App\\Notifications\\DocumentExpiryNotification"
},
"data": {
"type": "object",
"properties": {
"title": {
"type": "string",
"example": "Document Expiry Warning"
},
"body": {
"type": "string",
"example": "Your Passport will expire in 30 days on 2026-12-31."
},
"document_type": {
"type": "string",
"example": "Passport"
},
"expiry_date": {
"type": "string",
"format": "date",
"example": "2026-12-31"
},
"days_remaining": {
"type": "integer",
"example": 30
},
"reminder_level": {
"type": "string",
"example": "30_days"
},
"type": {
"type": "string",
"example": "document_expiry"
}
}
},
"read_at": {
"type": "string",
"format": "date-time",
"nullable": true,
"example": "2026-07-03T12:00:00Z"
},
"created_at": {
"type": "string",
"format": "date-time",
"example": "2026-07-03T11:43:30Z"
},
"time_ago": {
"type": "string",
"example": "2 hours ago"
}
}
}
}
}

View File

@ -38,6 +38,7 @@ export default function SelectedCandidates({
const [drawerOpen, setDrawerOpen] = useState(false);
// Filter States
const [filterProfession, setFilterProfession] = useState('All Professions');
const [filterLocation, setFilterLocation] = useState('All');
const [filterJobType, setFilterJobType] = useState('All');
const [filterAccommodation, setFilterAccommodation] = useState('All');
@ -83,6 +84,9 @@ export default function SelectedCandidates({
const query = searchTerm.toLowerCase();
list = list.filter(w => w.name.toLowerCase().includes(query));
}
if (filterProfession !== 'All Professions') {
list = list.filter(w => w.main_profession && w.main_profession.toLowerCase() === filterProfession.toLowerCase());
}
if (filterLocation !== 'All' && filterLocation.trim() !== '') {
const locKey = filterLocation.toLowerCase();
const locationMapping = {
@ -130,9 +134,10 @@ export default function SelectedCandidates({
}
return list;
}, [selectedWorkers, searchTerm, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
}, [selectedWorkers, searchTerm, filterProfession, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
const resetFilters = () => {
setFilterProfession('All Professions');
setFilterLocation('All');
setFilterJobType('All');
setFilterAccommodation('All');
@ -147,6 +152,7 @@ export default function SelectedCandidates({
const activeFilterCount = useMemo(() => {
let count = 0;
if (filterProfession !== 'All Professions') count++;
if (filterLocation !== 'All' && filterLocation.trim() !== '') count++;
if (filterJobType !== 'All') count++;
if (filterAccommodation !== 'All') count++;
@ -158,11 +164,12 @@ export default function SelectedCandidates({
if (filterLanguages.length > 0) count++;
if (maxSalary < 5000) count++;
return count;
}, [filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
}, [filterProfession, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
// Active filter tags for display
const activeFilterTags = useMemo(() => {
const tags = [];
if (filterProfession !== 'All Professions') tags.push({ key: 'profession', label: `🧑‍🔧 ${filterProfession}`, clear: () => setFilterProfession('All Professions') });
if (filterLocation !== 'All' && filterLocation.trim()) tags.push({ key: 'location', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('All') });
if (filterJobType !== 'All') tags.push({ key: 'job', label: `💼 ${filterJobType}`, clear: () => setFilterJobType('All') });
if (filterAccommodation !== 'All') tags.push({ key: 'acc', label: `🏠 ${filterAccommodation.replace('_', '-')}`, clear: () => setFilterAccommodation('All') });
@ -174,7 +181,7 @@ export default function SelectedCandidates({
if (filterLanguages.length > 0) tags.push({ key: 'lang', label: `🗣 ${filterLanguages.length} lang`, clear: () => setFilterLanguages([]) });
if (maxSalary < 5000) tags.push({ key: 'salary', label: `💰 ≤${maxSalary} AED`, clear: () => setMaxSalary(5000) });
return tags;
}, [filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
}, [filterProfession, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
return (
<EmployerLayout title={t('candidates_pipeline', 'Hired Workers')}>
@ -188,6 +195,22 @@ export default function SelectedCandidates({
activeCount={activeFilterCount}
title="Filter Hired Workers"
>
{/* Profession */}
<FilterSection label="Profession">
<FilterSelect
id="filter_main_profession"
value={filterProfession}
onChange={e => setFilterProfession(e.target.value)}
>
<option value="All Professions">All Professions</option>
<option value="Housemaid">Housemaid</option>
<option value="Nanny">Nanny</option>
<option value="Cook">Cook</option>
<option value="Driver">Driver</option>
<option value="Caregiver">Caregiver</option>
</FilterSelect>
</FilterSection>
{/* Preferred Location */}
<FilterSection label="Preferred Location">
<FilterSelect
@ -281,8 +304,6 @@ export default function SelectedCandidates({
<option value="Residence Visa">Residence Visa</option>
<option value="Tourist Visa">Tourist Visa</option>
<option value="Employment Visa">Employment Visa</option>
<option value="Cancelled Visa">Cancelled Visa</option>
<option value="Own Visa">Own Visa</option>
</FilterSelect>
{filterInCountry !== 'In Country' && (
<p className="text-[11px] text-slate-400 mt-1.5 font-medium">
@ -437,6 +458,12 @@ export default function SelectedCandidates({
<div className="text-[10px] text-slate-400 font-medium flex items-center space-x-1 mt-0.5">
<Globe2 className="w-3 h-3" />
<span>{worker.nationality || '—'}</span>
{worker.main_profession && (
<>
<span className="text-slate-300"></span>
<span>{worker.main_profession}</span>
</>
)}
</div>
</div>
</div>

View File

@ -520,8 +520,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
<option value="Residence Visa">Residence Visa</option>
<option value="Tourist Visa">Tourist Visa</option>
<option value="Employment Visa">Employment Visa</option>
<option value="Cancelled Visa">Cancelled Visa</option>
<option value="Own Visa">Own Visa</option>
</FilterSelect>
{filterInCountry !== 'In Country' && (
<p className="text-[11px] text-slate-400 mt-1.5 font-medium">Select "In Country" to enable this filter</p>

View File

@ -13,6 +13,8 @@
use App\Http\Controllers\Api\SponsorAuthController;
use App\Http\Controllers\Api\SponsorController;
use App\Http\Controllers\Api\GoogleVisionOcrController;
use App\Http\Controllers\Api\WorkerNotificationController;
use App\Http\Controllers\Api\EmployerNotificationController;
/*
|--------------------------------------------------------------------------
@ -75,6 +77,7 @@
Route::post('/workers/profile/mark-hired', [WorkerProfileController::class, 'markHired']);
Route::get('/workers/dashboard/views', [WorkerProfileController::class, 'getProfileViews']);
Route::get('/workers/dashboard', [WorkerProfileController::class, 'getDashboard']);
Route::get('/workers/employers', [WorkerProfileController::class, 'getEmployers']);
Route::post('/workers/change-password', [WorkerProfileController::class, 'changePassword']);
@ -117,6 +120,13 @@
Route::post('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'addReview']);
Route::get('/workers/reviews/{id}', [\App\Http\Controllers\Api\WorkerReviewController::class, 'viewReview']);
Route::put('/workers/reviews/{id}', [\App\Http\Controllers\Api\WorkerReviewController::class, 'editReview']);
// Notification Management (Worker)
Route::get('/workers/notifications', [WorkerNotificationController::class, 'index']);
Route::put('/workers/notifications/read', [WorkerNotificationController::class, 'markAsRead']);
Route::put('/workers/notifications/{id}/read', [WorkerNotificationController::class, 'markAsRead']);
Route::delete('/workers/notifications', [WorkerNotificationController::class, 'destroy']);
Route::delete('/workers/notifications/{id}', [WorkerNotificationController::class, 'destroy']);
});
// Protected Employer Mobile Endpoints (Token Authenticated via Bearer Token)
@ -176,6 +186,13 @@
Route::delete('/employers/jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerDeleteJob']);
Route::get('/employers/jobs/{id}/applicants', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerJobApplicants']);
Route::post('/employers/applications/{id}/status', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerUpdateApplicationStatus']);
// Notification Management (Employer)
Route::get('/employers/notifications', [EmployerNotificationController::class, 'index']);
Route::put('/employers/notifications/read', [EmployerNotificationController::class, 'markAsRead']);
Route::put('/employers/notifications/{id}/read', [EmployerNotificationController::class, 'markAsRead']);
Route::delete('/employers/notifications', [EmployerNotificationController::class, 'destroy']);
Route::delete('/employers/notifications/{id}', [EmployerNotificationController::class, 'destroy']);
});
// Protected Sponsor Mobile Endpoints (Token Authenticated via Bearer Token)

View File

@ -49,6 +49,7 @@ protected function setUp(): void
'live_in_out' => 'live_in',
'in_country' => true,
'visa_status' => 'Residence Visa',
'main_profession' => 'Housemaid',
'age' => 25,
'gender' => 'female',
'salary' => 1500,
@ -80,6 +81,7 @@ protected function setUp(): void
'live_in_out' => 'live_out',
'in_country' => true,
'visa_status' => 'Tourist Visa',
'main_profession' => 'Nanny',
'age' => 28,
'gender' => 'female',
'salary' => 1800,
@ -96,7 +98,7 @@ protected function setUp(): void
'file_path' => 'passports/test2.jpg',
]);
// Worker 3: Dubai, full-time, live-out, Kenyan, in_country=false, Cancelled Visa, male
// Worker 3: Dubai, full-time, live-out, Kenyan, in_country=false, Residence Visa, male
$w3 = Worker::create([
'name' => 'Worker Kenyan Out Country',
'email' => 'worker3@example.com',
@ -110,7 +112,8 @@ protected function setUp(): void
'preferred_job_type' => 'full-time',
'live_in_out' => 'live_out',
'in_country' => false,
'visa_status' => 'Cancelled Visa',
'visa_status' => 'Residence Visa',
'main_profession' => 'Cook',
'age' => 30,
'gender' => 'male',
'salary' => 1200,
@ -201,17 +204,29 @@ public function test_get_workers_visa_status_if_in_country_filter()
$this->assertCount(1, $workers);
$this->assertEquals('Worker Filipino In Abu Dhabi', $workers[0]['name']);
// Cancelled Visa is for Worker 3, but Worker 3 is out country, so it should not match
// Residence Visa is for Worker 3 (Kenyan), but Worker 3 is out country, so it should not match
// because of the "if in country next visa type" constraint.
$response2 = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->token,
])->getJson('/api/employers/workers?visa_status=Cancelled Visa');
])->getJson('/api/employers/workers?visa_status=Residence Visa&nationality=Kenyan');
$response2->assertStatus(200);
$workers2 = $response2->json('data.workers');
$this->assertCount(0, $workers2);
}
public function test_get_workers_main_profession_filter()
{
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->token,
])->getJson('/api/employers/workers?main_profession=Housemaid');
$response->assertStatus(200);
$workers = $response->json('data.workers');
$this->assertCount(1, $workers);
$this->assertEquals('Worker Indian In Dubai', $workers[0]['name']);
}
/**
* Test getCandidates filtering.
*/

View File

@ -41,6 +41,7 @@ protected function setUp(): void
'live_in_out' => 'live_in',
'in_country' => true,
'visa_status' => 'Residence Visa',
'main_profession' => 'Housemaid',
'age' => 25,
'salary' => 1500,
'experience' => '3 Years',
@ -70,6 +71,7 @@ protected function setUp(): void
'live_in_out' => 'live_out',
'in_country' => true,
'visa_status' => 'Tourist Visa',
'main_profession' => 'Nanny',
'age' => 28,
'salary' => 1800,
'experience' => '2 Years',
@ -164,7 +166,8 @@ public function test_employer_can_view_candidates_index_and_apply_filters()
'preferred_job_type' => 'full-time',
'live_in_out' => 'live_out',
'in_country' => false,
'visa_status' => 'Cancelled Visa',
'visa_status' => 'Residence Visa',
'main_profession' => 'Cook',
'age' => 30,
'salary' => 1200,
'experience' => 'None',
@ -227,4 +230,59 @@ public function test_employer_workers_web_gender_filtering()
$response->assertSee('Worker Indian Dubai');
$response->assertDontSee('Worker Filipino Abu Dhabi');
}
/**
* Test web workers index main_profession filtering.
*/
public function test_employer_workers_web_main_profession_filtering()
{
$response = $this->actingAs($this->employer)
->withSession(['user' => $this->employer])
->get(route('employer.workers', ['main_profession' => 'Housemaid']));
$response->assertStatus(200);
$response->assertSee('Worker Indian Dubai');
$response->assertDontSee('Worker Filipino Abu Dhabi');
// Test candidate filtering by main_profession
$w = Worker::create([
'name' => 'Worker Kenyan Hired',
'email' => 'worker3@example.com',
'phone' => '+971503333333',
'password' => bcrypt('password'),
'nationality' => 'Kenyan',
'verified' => true,
'status' => 'Hired',
'preferred_location' => 'Dubai',
'preferred_job_type' => 'full-time',
'live_in_out' => 'live_out',
'in_country' => false,
'visa_status' => 'Residence Visa',
'main_profession' => 'Cook',
'age' => 30,
'salary' => 1200,
'experience' => 'None',
'religion' => 'Christian',
'language' => 'SW',
'availability' => 'Immediate',
'bio' => 'New helper',
]);
JobOffer::create([
'employer_id' => $this->employer->id,
'worker_id' => $w->id,
'work_date' => now()->format('Y-m-d'),
'location' => 'Dubai',
'salary' => 1200,
'notes' => 'Hired',
'status' => 'accepted',
]);
$response2 = $this->actingAs($this->employer)
->withSession(['user' => $this->employer])
->get(route('employer.candidates', ['main_profession' => 'Cook']));
$response2->assertStatus(200);
$response2->assertSee('selectedWorkers');
}
}

View File

@ -0,0 +1,369 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Worker;
use App\Models\JobPost;
use App\Models\JobApplication;
use App\Models\JobOffer;
use App\Models\Conversation;
use App\Models\Message;
use App\Models\Review;
use App\Models\EmployerReview;
use App\Notifications\WorkerNoResponseNotification;
use App\Notifications\WorkerReviewReminderNotification;
use App\Notifications\EmployerReviewReminderNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;
class NewRemindersAndSettingsTest extends TestCase
{
use RefreshDatabase;
private function createTestWorker(array $overrides = []): Worker
{
return Worker::create(array_merge([
'name' => 'John Worker',
'email' => 'worker@example.com',
'password' => bcrypt('password'),
'phone' => '971500000001',
'nationality' => 'Indian',
'status' => 'active',
'api_token' => 'worker_api_token',
'age' => 25,
'salary' => 2000,
'availability' => 'Immediate',
'experience' => '2 Years',
'religion' => 'Christian',
'bio' => 'Helper info',
'passport_status' => 'valid',
], $overrides));
}
private function createTestEmployer(array $overrides = []): User
{
return User::create(array_merge([
'name' => 'Alice Employer',
'email' => 'employer@example.com',
'password' => bcrypt('password'),
'role' => 'employer',
'api_token' => 'employer_api_token',
'subscription_status' => 'active',
], $overrides));
}
public function test_worker_no_response_reminders()
{
Notification::fake();
$worker = $this->createTestWorker();
$employer = $this->createTestEmployer();
// 1. Create a pending direct Job Offer created exactly 14 days ago
$offer = JobOffer::create([
'employer_id' => $employer->id,
'worker_id' => $worker->id,
'work_date' => now()->addDays(5)->toDateString(),
'location' => 'Dubai Marina',
'salary' => 3000,
'status' => 'pending',
]);
// Force created_at to 14 days ago
$offer->created_at = now()->subDays(14);
$offer->save();
// 2. Create a conversation where the last message is from employer, sent 14 days ago
$conversation = Conversation::create([
'employer_id' => $employer->id,
'worker_id' => $worker->id,
]);
$message = Message::create([
'conversation_id' => $conversation->id,
'sender_type' => 'App\Models\User',
'sender_id' => $employer->id,
'text' => 'Hello worker, are you interested?',
]);
$message->created_at = now()->subDays(14);
$message->save();
// Run scheduler
Artisan::call('app:send-reminders');
// Assert Worker got WorkerNoResponseNotification for both
Notification::assertSentTo(
$worker,
WorkerNoResponseNotification::class,
function ($notification) use ($offer) {
return $notification->type === 'worker_no_response_offer' &&
$notification->daysElapsed === 14 &&
$notification->entityId === $offer->id;
}
);
Notification::assertSentTo(
$worker,
WorkerNoResponseNotification::class,
function ($notification) use ($message) {
return $notification->type === 'worker_no_response_chat' &&
$notification->daysElapsed === 14 &&
$notification->entityId === $message->id;
}
);
// Assert reminder logs were created
$this->assertDatabaseHas('reminder_logs', [
'user_type' => get_class($worker),
'user_id' => $worker->id,
'event_type' => 'worker_no_response_offer',
'reminder_level' => '14_days',
]);
$this->assertDatabaseHas('reminder_logs', [
'user_type' => get_class($worker),
'user_id' => $worker->id,
'event_type' => 'worker_no_response_chat',
'reminder_level' => '14_days',
]);
// Run again to verify duplicate prevention
Notification::fake();
Artisan::call('app:send-reminders');
Notification::assertNothingSent();
}
public function test_review_reminders_after_joining_date()
{
Notification::fake();
$worker = $this->createTestWorker();
$employer = $this->createTestEmployer();
$job = JobPost::create([
'employer_id' => $employer->id,
'title' => 'Nanny',
'location' => 'Dubai',
'salary' => 2500,
'workers_needed' => 1,
'job_type' => 'Full Time',
'start_date' => now()->subMonths(4)->toDateString(),
'description' => 'Test job',
'status' => 'active',
]);
// JobApplication with joining_confirmed_at exactly 3 months ago
$app = JobApplication::create([
'job_id' => $job->id,
'worker_id' => $worker->id,
'status' => 'hired',
'joining_confirmed_at' => now()->subMonths(3)->toDateString(),
]);
// Run scheduler
Artisan::call('app:send-reminders');
// Assert Worker got review reminder for employer
Notification::assertSentTo(
$worker,
WorkerReviewReminderNotification::class,
function ($notification) use ($app) {
return $notification->employerName === 'Alice Employer' &&
$notification->applicationId === $app->id &&
$notification->reviewLevel === '3_months';
}
);
// Assert Employer got review reminder for worker
Notification::assertSentTo(
$employer,
EmployerReviewReminderNotification::class,
function ($notification) use ($app) {
return $notification->workerName === 'John Worker' &&
$notification->applicationId === $app->id &&
$notification->reviewLevel === '3_months';
}
);
// Assert reminder logs were created
$this->assertDatabaseHas('reminder_logs', [
'user_type' => get_class($worker),
'user_id' => $worker->id,
'event_type' => 'review_employer_reminder',
'reminder_level' => '3_months',
]);
$this->assertDatabaseHas('reminder_logs', [
'user_type' => get_class($employer),
'user_id' => $employer->id,
'event_type' => 'review_worker_reminder',
'reminder_level' => '3_months',
]);
// Run again to verify duplicate prevention
Notification::fake();
Artisan::call('app:send-reminders');
Notification::assertNothingSent();
}
public function test_worker_review_controller_eligibility_and_edit_window()
{
$worker = $this->createTestWorker();
$employer = $this->createTestEmployer();
$job = JobPost::create([
'employer_id' => $employer->id,
'title' => 'Test Job',
'location' => 'Dubai',
'salary' => 2500,
'workers_needed' => 1,
'job_type' => 'Full Time',
'start_date' => now()->subMonths(4)->toDateString(),
'description' => 'Test Description',
'status' => 'active',
]);
// 1. Cannot review before eligibility period (set eligibility to 3 months)
$app = JobApplication::create([
'job_id' => $job->id,
'worker_id' => $worker->id,
'status' => 'hired',
'joining_confirmed_at' => now()->subMonths(2)->toDateString(),
]);
$response = $this->postJson('/api/workers/reviews', [
'employer_id' => $employer->id,
'job_id' => $job->id,
'rating' => 5,
'comment' => 'Great employer!',
], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(403);
$response->assertJsonPath('success', false);
$response->assertJsonPath('message', 'You can write a review only after 3 months from the joining date.');
// 2. Can review after eligibility period
$app->joining_confirmed_at = now()->subMonths(3)->subDays(5)->toDateString();
$app->save();
$response = $this->postJson('/api/workers/reviews', [
'employer_id' => $employer->id,
'job_id' => $job->id,
'rating' => 5,
'comment' => 'Great employer!',
], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(201);
$response->assertJsonPath('success', true);
$reviewId = $response->json('data.review.id');
// 3. Edit review within 7 days
$response = $this->putJson("/api/workers/reviews/{$reviewId}", [
'rating' => 4,
'comment' => 'Good employer indeed.',
], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
// 4. Cannot edit review after 7 days
$review = EmployerReview::find($reviewId);
$review->created_at = now()->subDays(8);
$review->save();
$response = $this->putJson("/api/workers/reviews/{$reviewId}", [
'rating' => 3,
'comment' => 'Okay employer.',
], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(403);
$response->assertJsonPath('success', false);
$response->assertJsonPath('message', 'The 7-day edit window for this review has expired.');
}
public function test_employer_review_controller_eligibility_and_edit_window()
{
$worker = $this->createTestWorker();
$employer = $this->createTestEmployer();
$job = JobPost::create([
'employer_id' => $employer->id,
'title' => 'Test Job',
'location' => 'Dubai',
'salary' => 2500,
'workers_needed' => 1,
'job_type' => 'Full Time',
'start_date' => now()->subMonths(4)->toDateString(),
'description' => 'Test Description',
'status' => 'active',
]);
// 1. Cannot review before eligibility period (set eligibility to 3 months)
$app = JobApplication::create([
'job_id' => $job->id,
'worker_id' => $worker->id,
'status' => 'hired',
'joining_confirmed_at' => now()->subMonths(2)->toDateString(),
]);
$response = $this->postJson('/api/employers/reviews', [
'worker_id' => $worker->id,
'rating' => 5,
'comment' => 'Great worker!',
], [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(403);
$response->assertJsonPath('success', false);
$response->assertJsonPath('message', 'You can write a review only after 3 months from the joining date.');
// 2. Can review after eligibility period
$app->joining_confirmed_at = now()->subMonths(3)->subDays(5)->toDateString();
$app->save();
$response = $this->postJson('/api/employers/reviews', [
'worker_id' => $worker->id,
'rating' => 5,
'comment' => 'Great worker!',
], [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(201);
$response->assertJsonPath('success', true);
$reviewId = $response->json('data.review.id');
// 3. Edit review within 7 days
$response = $this->putJson("/api/employers/reviews/{$reviewId}", [
'rating' => 4,
'comment' => 'Good worker indeed.',
], [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
// 4. Cannot edit review after 7 days
$review = Review::find($reviewId);
$review->created_at = now()->subDays(8);
$review->save();
$response = $this->putJson("/api/employers/reviews/{$reviewId}", [
'rating' => 3,
'comment' => 'Okay worker.',
], [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(403);
$response->assertJsonPath('success', false);
$response->assertJsonPath('message', 'The 7-day edit window for this review has expired.');
}
}

View File

@ -0,0 +1,248 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Worker;
use App\Notifications\DocumentExpiryNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class NotificationApiTest extends TestCase
{
use RefreshDatabase;
private function createTestWorker(array $overrides = []): Worker
{
return Worker::create(array_merge([
'name' => 'John Worker',
'email' => 'worker@example.com',
'password' => bcrypt('password'),
'phone' => '971500000001',
'nationality' => 'Indian',
'status' => 'active',
'api_token' => 'worker_api_token',
'age' => 25,
'salary' => 2000,
'availability' => 'Immediate',
'experience' => '2 Years',
'religion' => 'Christian',
'bio' => 'Helper info',
'passport_status' => 'valid',
], $overrides));
}
private function createTestEmployer(array $overrides = []): User
{
return User::create(array_merge([
'name' => 'Alice Employer',
'email' => 'employer@example.com',
'password' => bcrypt('password'),
'role' => 'employer',
'api_token' => 'employer_api_token',
'subscription_status' => 'active',
], $overrides));
}
public function test_worker_can_list_notifications_with_filters()
{
$worker = $this->createTestWorker();
// Send two notifications
$worker->notify(new DocumentExpiryNotification('Passport', '2026-12-31', 30, '30_days'));
$worker->notify(new DocumentExpiryNotification('Visa', '2026-12-31', 7, '7_days'));
// Mark the Passport notification as read deterministically
$passportNotification = $worker->unreadNotifications()->where('data->document_type', 'Passport')->first();
$passportNotification->markAsRead();
// 1. Fetch all notifications
$response = $this->getJson('/api/workers/notifications', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$response->assertJsonCount(2, 'data.notifications');
// 2. Fetch unread notifications
$response = $this->getJson('/api/workers/notifications?filter=unread', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonCount(1, 'data.notifications');
$response->assertJsonPath('data.notifications.0.data.document_type', 'Visa');
// 3. Fetch read notifications
$response = $this->getJson('/api/workers/notifications?filter=read', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonCount(1, 'data.notifications');
$response->assertJsonPath('data.notifications.0.data.document_type', 'Passport');
}
public function test_worker_can_mark_single_and_all_notifications_as_read()
{
$worker = $this->createTestWorker();
// Send two notifications
$worker->notify(new DocumentExpiryNotification('Passport', '2026-12-31', 30, '30_days'));
$worker->notify(new DocumentExpiryNotification('Visa', '2026-12-31', 7, '7_days'));
$unread = $worker->unreadNotifications;
$this->assertEquals(2, $unread->count());
// 1. Mark single notification as read
$notificationId = $unread->first()->id;
$response = $this->putJson("/api/workers/notifications/{$notificationId}/read", [], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$this->assertEquals(1, $worker->fresh()->unreadNotifications->count());
// 2. Mark all notifications as read
$response = $this->putJson('/api/workers/notifications/read', [], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$this->assertEquals(0, $worker->fresh()->unreadNotifications->count());
}
public function test_worker_can_delete_single_and_all_notifications()
{
$worker = $this->createTestWorker();
// Send two notifications
$worker->notify(new DocumentExpiryNotification('Passport', '2026-12-31', 30, '30_days'));
$worker->notify(new DocumentExpiryNotification('Visa', '2026-12-31', 7, '7_days'));
$this->assertEquals(2, $worker->notifications()->count());
// 1. Delete single notification
$notificationId = $worker->notifications->first()->id;
$response = $this->deleteJson("/api/workers/notifications/{$notificationId}", [], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$this->assertEquals(1, $worker->fresh()->notifications()->count());
// 2. Delete all notifications
$response = $this->deleteJson('/api/workers/notifications', [], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$this->assertEquals(0, $worker->fresh()->notifications()->count());
}
public function test_employer_can_list_notifications_with_filters()
{
$employer = $this->createTestEmployer();
// Send two notifications
$employer->notify(new DocumentExpiryNotification('Emirates ID', '2026-12-31', 30, '30_days'));
$employer->notify(new DocumentExpiryNotification('Trade License', '2026-12-31', 7, '7_days'));
// Mark the Emirates ID notification as read deterministically
$emiratesIdNotification = $employer->unreadNotifications()->where('data->document_type', 'Emirates ID')->first();
$emiratesIdNotification->markAsRead();
// 1. Fetch all notifications
$response = $this->getJson('/api/employers/notifications', [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$response->assertJsonCount(2, 'data.notifications');
// 2. Fetch unread notifications
$response = $this->getJson('/api/employers/notifications?filter=unread', [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
$response->assertJsonCount(1, 'data.notifications');
$response->assertJsonPath('data.notifications.0.data.document_type', 'Trade License');
// 3. Fetch read notifications
$response = $this->getJson('/api/employers/notifications?filter=read', [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
$response->assertJsonCount(1, 'data.notifications');
$response->assertJsonPath('data.notifications.0.data.document_type', 'Emirates ID');
}
public function test_employer_can_mark_single_and_all_notifications_as_read()
{
$employer = $this->createTestEmployer();
// Send two notifications
$employer->notify(new DocumentExpiryNotification('Emirates ID', '2026-12-31', 30, '30_days'));
$employer->notify(new DocumentExpiryNotification('Trade License', '2026-12-31', 7, '7_days'));
$unread = $employer->unreadNotifications;
$this->assertEquals(2, $unread->count());
// 1. Mark single notification as read
$notificationId = $unread->first()->id;
$response = $this->putJson("/api/employers/notifications/{$notificationId}/read", [], [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$this->assertEquals(1, $employer->fresh()->unreadNotifications->count());
// 2. Mark all notifications as read
$response = $this->putJson('/api/employers/notifications/read', [], [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$this->assertEquals(0, $employer->fresh()->unreadNotifications->count());
}
public function test_employer_can_delete_single_and_all_notifications()
{
$employer = $this->createTestEmployer();
// Send two notifications
$employer->notify(new DocumentExpiryNotification('Emirates ID', '2026-12-31', 30, '30_days'));
$employer->notify(new DocumentExpiryNotification('Trade License', '2026-12-31', 7, '7_days'));
$this->assertEquals(2, $employer->notifications()->count());
// 1. Delete single notification
$notificationId = $employer->notifications->first()->id;
$response = $this->deleteJson("/api/employers/notifications/{$notificationId}", [], [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$this->assertEquals(1, $employer->fresh()->notifications()->count());
// 2. Delete all notifications
$response = $this->deleteJson('/api/employers/notifications', [], [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$this->assertEquals(0, $employer->fresh()->notifications()->count());
}
}

View File

@ -217,15 +217,18 @@ public function test_sponsor_can_post_charity_event()
$response = $this->withHeaders([
'Authorization' => 'Bearer sponsor_token_xyz',
])->postJson('/api/sponsors/charity-events', [
'title' => 'Community Ramadan Iftar',
'body' => 'Join us for a free community Iftar gathering at the center.',
'type' => 'charity',
'event_date' => '2026-06-15',
'start_time' => '09:00 AM',
'end_time' => '04:00 PM',
'provided_items' => 'Free Iftar meals, Water, Dates',
'location_details' => 'Al Quoz Community Center, Dubai',
'location_pin' => 'https://maps.app.goo.gl/xyz',
'title' => 'Community Ramadan Iftar',
'body' => 'Join us for a free community Iftar gathering at the center.',
'type' => 'charity',
'event_date' => '2026-06-15',
'start_time' => '09:00 AM',
'end_time' => '04:00 PM',
'provided_items' => 'Free Iftar meals, Water, Dates',
'location_details' => 'Al Quoz Community Center, Dubai',
'location_pin' => 'https://maps.app.goo.gl/xyz',
'contact_person_name' => 'Jane Doe',
'contact_number' => '551234567',
'country_code' => '+971',
]);
$response->assertStatus(201)
@ -246,6 +249,9 @@ public function test_sponsor_can_post_charity_event()
'location_details' => 'Al Quoz Community Center, Dubai',
'location_pin' => 'https://maps.app.goo.gl/xyz',
'content' => 'Join us for a free community Iftar gathering at the center.',
'contact_person_name' => 'Jane Doe',
'contact_number' => '551234567',
'country_code' => '+971',
]
]
])
@ -266,6 +272,9 @@ public function test_sponsor_can_post_charity_event()
'location_details',
'location_pin',
'content',
'contact_person_name',
'contact_number',
'country_code',
]
]
]);
@ -277,6 +286,82 @@ public function test_sponsor_can_post_charity_event()
]);
}
/**
* Test validation rules for charity event posting.
*/
public function test_sponsor_charity_event_validation()
{
$sponsor = Sponsor::create([
'full_name' => 'Test Sponsor',
'organization_name' => 'Save the Children',
'email' => 'tsponsor@example.com',
'mobile' => '+971509994444',
'password' => Hash::make('password123'),
'api_token' => 'sponsor_token_xyz',
'status' => 'active',
]);
// 1. Missing fields
$response = $this->withHeaders([
'Authorization' => 'Bearer sponsor_token_xyz',
])->postJson('/api/sponsors/charity-events', [
'title' => 'Community Ramadan Iftar',
'body' => 'Join us for a free community Iftar gathering at the center.',
'type' => 'charity',
'event_date' => '2026-06-15',
'start_time' => '09:00 AM',
'end_time' => '04:00 PM',
'provided_items' => 'Free Iftar meals, Water, Dates',
'location_details' => 'Al Quoz Community Center, Dubai',
'location_pin' => 'https://maps.app.goo.gl/xyz',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['contact_person_name', 'contact_number', 'country_code']);
// 2. Invalid country code format (missing +)
$response = $this->withHeaders([
'Authorization' => 'Bearer sponsor_token_xyz',
])->postJson('/api/sponsors/charity-events', [
'title' => 'Community Ramadan Iftar',
'body' => 'Join us for a free community Iftar gathering at the center.',
'type' => 'charity',
'event_date' => '2026-06-15',
'start_time' => '09:00 AM',
'end_time' => '04:00 PM',
'provided_items' => 'Free Iftar meals, Water, Dates',
'location_details' => 'Al Quoz Community Center, Dubai',
'location_pin' => 'https://maps.app.goo.gl/xyz',
'contact_person_name' => 'Jane Doe',
'contact_number' => '551234567',
'country_code' => '971', // missing plus
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['country_code']);
// 3. Invalid contact number format (non-digits)
$response = $this->withHeaders([
'Authorization' => 'Bearer sponsor_token_xyz',
])->postJson('/api/sponsors/charity-events', [
'title' => 'Community Ramadan Iftar',
'body' => 'Join us for a free community Iftar gathering at the center.',
'type' => 'charity',
'event_date' => '2026-06-15',
'start_time' => '09:00 AM',
'end_time' => '04:00 PM',
'provided_items' => 'Free Iftar meals, Water, Dates',
'location_details' => 'Al Quoz Community Center, Dubai',
'location_pin' => 'https://maps.app.goo.gl/xyz',
'contact_person_name' => 'Jane Doe',
'contact_number' => '55-123-abc', // invalid format
'country_code' => '+971',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['contact_number']);
}
/**
* Test that a sponsor can list charity events showing both employer and sponsor events.
*/

View File

@ -0,0 +1,239 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\Worker;
use App\Models\WorkerDocument;
use App\Models\User;
use App\Models\JobOffer;
use App\Models\EmployerProfile;
class WorkerEmployersApiTest extends TestCase
{
use RefreshDatabase;
public function test_worker_dashboard_current_employer_detail()
{
// Create worker
$worker = Worker::create([
'name' => 'Rahul Sharma',
'email' => 'rahul@example.com',
'phone' => '+971501234567',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'Indian',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'Not Specified',
'religion' => 'Not Specified',
'bio' => 'Test',
'verified' => true,
'status' => 'active',
'api_token' => 'worker-test-token',
]);
// Create passport document to prevent pending 404
WorkerDocument::create([
'worker_id' => $worker->id,
'type' => 'passport',
'number' => '123456',
'file_path' => 'passports/test.pdf',
]);
// Create an employer
$employer = User::create([
'name' => 'Employer One',
'email' => 'emp1@example.com',
'password' => bcrypt('password'),
'role' => 'employer',
]);
EmployerProfile::create([
'user_id' => $employer->id,
'company_name' => 'Ahmad Tech Ltd',
'nationality' => 'UAE',
'city' => 'Dubai',
]);
// Create an accepted job offer (current employer)
JobOffer::create([
'employer_id' => $employer->id,
'worker_id' => $worker->id,
'work_date' => now()->subDays(10),
'location' => 'Dubai',
'salary' => 2500,
'status' => 'accepted',
'notes' => 'Current Active Job',
]);
// Hit the worker dashboard API
$response = $this->withHeaders([
'Authorization' => 'Bearer worker-test-token',
])->getJson('/api/workers/dashboard');
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'current_employer' => [
'id' => $employer->id,
'name' => 'Employer One',
'company_name' => 'Ahmad Tech Ltd',
'city' => 'Dubai',
'nationality' => 'UAE',
],
'currently_working_sponsor' => [
'id' => $employer->id,
'name' => 'Employer One',
'company_name' => 'Ahmad Tech Ltd',
'city' => 'Dubai',
'nationality' => 'UAE',
]
]
]);
}
public function test_worker_employers_list_pagination_and_sorting()
{
// Create worker
$worker = Worker::create([
'name' => 'Rahul Sharma',
'email' => 'rahul@example.com',
'phone' => '+971501234567',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'Indian',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'Not Specified',
'religion' => 'Not Specified',
'bio' => 'Test',
'verified' => true,
'status' => 'active',
'api_token' => 'worker-test-token',
]);
WorkerDocument::create([
'worker_id' => $worker->id,
'type' => 'passport',
'number' => '123456',
'file_path' => 'passports/test.pdf',
]);
// Create 3 employers
$emp1 = User::create([
'name' => 'Employer One',
'email' => 'emp1@example.com',
'password' => bcrypt('password'),
'role' => 'employer'
]);
EmployerProfile::create(['user_id' => $emp1->id, 'company_name' => 'Tech One']);
$emp2 = User::create([
'name' => 'Employer Two',
'email' => 'emp2@example.com',
'password' => bcrypt('password'),
'role' => 'employer'
]);
EmployerProfile::create(['user_id' => $emp2->id, 'company_name' => 'Tech Two']);
$emp3 = User::create([
'name' => 'Employer Three',
'email' => 'emp3@example.com',
'password' => bcrypt('password'),
'role' => 'employer'
]);
EmployerProfile::create(['user_id' => $emp3->id, 'company_name' => 'Tech Three']);
// Create JobOffers:
// Offer 1: Completed status, joining 20 days ago
JobOffer::create([
'employer_id' => $emp1->id,
'worker_id' => $worker->id,
'work_date' => now()->subDays(20),
'location' => 'Abu Dhabi',
'salary' => 2000,
'status' => 'completed',
'notes' => 'Past job',
]);
// Offer 2: Cancelled status, joining 30 days ago
JobOffer::create([
'employer_id' => $emp2->id,
'worker_id' => $worker->id,
'work_date' => now()->subDays(30),
'location' => 'Sharjah',
'salary' => 1800,
'status' => 'cancelled',
'notes' => 'Cancelled job',
]);
// Offer 3: Accepted (Active) status, joining 10 days ago
JobOffer::create([
'employer_id' => $emp3->id,
'worker_id' => $worker->id,
'work_date' => now()->subDays(10),
'location' => 'Dubai',
'salary' => 3000,
'status' => 'accepted',
'notes' => 'Active job',
]);
// List all employers (should return 3)
// With Active employer at the top, then previous employers sorted by work_date desc:
// 1st: Emp 3 (Active)
// 2nd: Emp 1 (Completed, 20 days ago)
// 3rd: Emp 2 (Cancelled, 30 days ago)
$response = $this->withHeaders([
'Authorization' => 'Bearer worker-test-token',
])->getJson('/api/workers/employers');
$response->assertStatus(200);
$data = $response->json('data.employers');
$this->assertCount(3, $data);
$this->assertEquals('Active', $data[0]['status']);
$this->assertEquals('Employer Three', $data[0]['employer']['name']);
$this->assertEquals('Completed', $data[1]['status']);
$this->assertEquals('Employer One', $data[1]['employer']['name']);
$this->assertEquals('Cancelled', $data[2]['status']);
$this->assertEquals('Employer Two', $data[2]['employer']['name']);
// Test filtering by status Completed
$responseFiltered = $this->withHeaders([
'Authorization' => 'Bearer worker-test-token',
])->getJson('/api/workers/employers?status=completed');
$responseFiltered->assertStatus(200);
$filteredData = $responseFiltered->json('data.employers');
$this->assertCount(1, $filteredData);
$this->assertEquals('Completed', $filteredData[0]['status']);
$this->assertEquals('Employer One', $filteredData[0]['employer']['name']);
// Test filtering by status Active
$responseActive = $this->withHeaders([
'Authorization' => 'Bearer worker-test-token',
])->getJson('/api/workers/employers?status=active');
$responseActive->assertStatus(200);
$activeData = $responseActive->json('data.employers');
$this->assertCount(1, $activeData);
$this->assertEquals('Active', $activeData[0]['status']);
$this->assertEquals('Employer Three', $activeData[0]['employer']['name']);
// Test pagination
$responsePaginated = $this->withHeaders([
'Authorization' => 'Bearer worker-test-token',
])->getJson('/api/workers/employers?per_page=2');
$responsePaginated->assertStatus(200);
$paginatedData = $responsePaginated->json('data.employers');
$this->assertCount(2, $paginatedData);
$this->assertEquals(3, $responsePaginated->json('data.pagination.total'));
$this->assertEquals(2, $responsePaginated->json('data.pagination.per_page'));
}
}

View File

@ -507,7 +507,7 @@ public function test_register_passport_and_visa_with_direct_json_payload()
'nationality' => 'Filipino',
'age' => $expectedAge,
'verified' => true,
'visa_status' => 'CLEANER',
'visa_status' => 'Tourist Visa',
]);
$this->assertDatabaseHas('worker_documents', [
@ -636,7 +636,7 @@ public function test_register_visa_with_full_ocr_fields()
$visaDoc = \App\Models\WorkerDocument::where('worker_id', $workerId)->where('type', 'visa')->first();
$this->assertNotNull($visaDoc->ocr_data);
$this->assertEquals('uae_visa', $visaDoc->ocr_data['document_type']);
$this->assertEquals('ENTRY PERMIT', $visaDoc->ocr_data['visa_type']);
$this->assertEquals('Tourist Visa', $visaDoc->ocr_data['visa_type']);
$this->assertEquals('Mr.MUHAMMAD NADEEM RASHEED', $visaDoc->ocr_data['full_name']);
$this->assertEquals(97.4, $visaDoc->ocr_accuracy);
}
@ -689,7 +689,7 @@ public function test_register_passport_and_visa_with_json_encoded_strings()
'name' => 'Johnny Test',
'phone' => '+971509999999',
'verified' => true,
'visa_status' => 'HELPER',
'visa_status' => 'Tourist Visa',
]);
$this->assertDatabaseHas('worker_documents', [