diff --git a/.env.example b/.env.example index 35836ad..f7d5f88 100644 --- a/.env.example +++ b/.env.example @@ -69,3 +69,11 @@ EMIRATES_BACK_API_URL= SPONSOR_LICENSE_API_URL= VITE_APP_NAME="${APP_NAME}" + +# Notification Reminder Settings +WORKER_NO_RESPONSE_REMINDER_DAYS=14 +REVIEW_ELIGIBILITY_MONTHS=3 +REVIEW_EDIT_PERIOD_DAYS=7 +EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS=3 +WORKER_JOIN_CONFIRM_REMINDER_DAYS=3 +DOCUMENT_EXPIRY_REMINDER_DAYS=30,7,1 diff --git a/app/Console/Commands/SendReminders.php b/app/Console/Commands/SendReminders.php new file mode 100644 index 0000000..692ad8c --- /dev/null +++ b/app/Console/Commands/SendReminders.php @@ -0,0 +1,458 @@ +info('Starting automated reminder notification system...'); + + $this->info('1. Checking Worker Documents...'); + $this->checkWorkerDocuments(); + + $this->info('2. Checking Employer Profiles (Emirates ID Expiry)...'); + $this->checkEmployerProfiles(); + + $this->info('3. Checking Sponsors (Emirates ID and License Expiry)...'); + $this->checkSponsors(); + + $this->info('4. Checking Pending Hire Confirmations (Employer)...'); + $this->checkHireConfirmations(); + + $this->info('5. Checking Pending Joining Confirmations (Worker)...'); + $this->checkJoiningConfirmations(); + + $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 $configKey, string $default): ?string + { + $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; + } + + private function alreadySent($notifiable, string $eventType, $entity, string $reminderLevel): bool + { + return \App\Models\ReminderLog::where('user_type', get_class($notifiable)) + ->where('user_id', $notifiable->id) + ->where('event_type', $eventType) + ->where('entity_type', get_class($entity)) + ->where('entity_id', $entity->id) + ->where('reminder_level', $reminderLevel) + ->exists(); + } + + private function logReminder($notifiable, string $eventType, $entity, string $reminderLevel, string $channel): void + { + \App\Models\ReminderLog::create([ + 'user_type' => get_class($notifiable), + 'user_id' => $notifiable->id, + 'event_type' => $eventType, + 'entity_type' => get_class($entity), + 'entity_id' => $entity->id, + 'reminder_level' => $reminderLevel, + 'channel' => $channel, + 'sent_at' => now(), + ]); + } + + private function checkWorkerDocuments() + { + $workerDocs = \App\Models\WorkerDocument::whereNotNull('expiry_date')->get(); + foreach ($workerDocs as $doc) { + $worker = $doc->worker; + if (!$worker) continue; + + $expiry = Carbon::parse($doc->expiry_date); + $days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false); + + $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; + + $this->info("Sending {$level} reminder to worker {$worker->name} for document {$doc->type} expiry."); + $worker->notify(new \App\Notifications\DocumentExpiryNotification( + ucfirst($doc->type), + $doc->expiry_date, + $days, + $level + )); + + $this->logReminder($worker, 'document_expiry', $doc, $level, 'all'); + } + } + + private function checkEmployerProfiles() + { + $profiles = \App\Models\EmployerProfile::whereNotNull('emirates_id_expiry')->get(); + foreach ($profiles as $profile) { + $user = \App\Models\User::find($profile->user_id); + if (!$user) continue; + + $expiry = Carbon::parse($profile->emirates_id_expiry); + $days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false); + + $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; + + $this->info("Sending {$level} reminder to employer {$user->name} for Emirates ID expiry."); + $user->notify(new \App\Notifications\DocumentExpiryNotification( + 'Emirates ID', + $profile->emirates_id_expiry, + $days, + $level + )); + + $this->logReminder($user, 'document_expiry', $profile, $level, 'all'); + } + } + + private function checkSponsors() + { + $sponsors = \App\Models\Sponsor::all(); + foreach ($sponsors as $sponsor) { + // 1. Check Emirates ID expiry + if ($sponsor->emirates_id_expiry) { + $expiry = Carbon::parse($sponsor->emirates_id_expiry); + $days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false); + + $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( + 'Emirates ID', + $sponsor->emirates_id_expiry, + $days, + $level + )); + $this->logReminder($sponsor, 'document_expiry_emirates', $sponsor, $level, 'all'); + } + } + + // 2. Check License expiry + if ($sponsor->license_expiry) { + $expiry = Carbon::parse($sponsor->license_expiry); + $days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false); + + $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( + 'Trade License', + $sponsor->license_expiry->toDateString(), + $days, + $level + )); + $this->logReminder($sponsor, 'document_expiry_license', $sponsor, $level, 'all'); + } + } + } + } + + private function checkHireConfirmations() + { + $applications = \App\Models\JobApplication::where('status', 'selected') + ->with('jobPost.employer') + ->get(); + + foreach ($applications as $app) { + $job = $app->jobPost; + if (!$job || !$job->start_date) continue; + $employer = $job->employer; + if (!$employer) continue; + $worker = $app->worker; + if (!$worker) continue; + + $startDate = Carbon::parse($job->start_date); + $days = (int) now()->startOfDay()->diffInDays($startDate->startOfDay(), false); + + $level = $this->getReminderLevel($days, 'reminders.employer_hire_confirm_reminder_days', '3,7'); + if (!$level) continue; + + if ($this->alreadySent($employer, 'hire_confirmation', $app, $level)) continue; + + $this->info("Sending {$level} reminder to employer {$employer->name} to confirm hiring for job {$job->title}."); + $employer->notify(new \App\Notifications\HireConfirmationNotification( + $job->title, + $worker->name, + $days, + $level, + $app->id + )); + + $this->logReminder($employer, 'hire_confirmation', $app, $level, 'all'); + } + } + + private function checkJoiningConfirmations() + { + $applications = \App\Models\JobApplication::where('status', 'hired') + ->whereNull('joining_confirmed_at') + ->with(['jobPost.employer', 'worker']) + ->get(); + + foreach ($applications as $app) { + $job = $app->jobPost; + if (!$job || !$job->start_date) continue; + $worker = $app->worker; + if (!$worker) continue; + $employer = $job->employer; + $employerName = $employer ? $employer->name : 'Employer'; + + $startDate = Carbon::parse($job->start_date); + $days = (int) now()->startOfDay()->diffInDays($startDate->startOfDay(), false); + + $level = $this->getReminderLevel($days, 'reminders.worker_join_confirm_reminder_days', '3,7'); + if (!$level) continue; + + if ($this->alreadySent($worker, 'joining_confirmation', $app, $level)) continue; + + $this->info("Sending {$level} reminder to worker {$worker->name} to confirm joining for job {$job->title}."); + $worker->notify(new \App\Notifications\JoiningConfirmationNotification( + $job->title, + $employerName, + $days, + $level, + $app->id + )); + + $this->logReminder($worker, 'joining_confirmation', $app, $level, 'all'); + } + } + + private function checkJobOffers() + { + $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'; + + if (!$offer->work_date) continue; + $workDate = Carbon::parse($offer->work_date); + $days = (int) now()->startOfDay()->diffInDays($workDate->startOfDay(), false); + + $level = $this->getReminderLevel($days, 'reminders.worker_join_confirm_reminder_days', '3,7'); + if (!$level) continue; + + if ($this->alreadySent($worker, 'pending_confirmation', $offer, $level)) continue; + + $this->info("Sending {$level} reminder to worker {$worker->name} for pending direct offer from {$employerName}."); + $worker->notify(new \App\Notifications\PendingConfirmationNotification( + 'accept_offer', + "Pending Job Offer", + "You have a pending job offer from {$employerName} starting on {$offer->work_date->toDateString()}.", + $days, + $level, + get_class($offer), + $offer->id + )); + + $this->logReminder($worker, 'pending_confirmation', $offer, $level, 'all'); + } + } + + private function checkWorkerNoResponse() + { + $noResponseDays = (int) config('reminders.worker_no_response_reminder_days', 14); + + // 1. Pending direct Job Offers + $offers = \App\Models\JobOffer::where('status', 'pending') + ->with(['worker', 'employer']) + ->get(); + + foreach ($offers as $offer) { + $worker = $offer->worker; + if (!$worker) continue; + $employer = $offer->employer; + $employerName = $employer ? $employer->name : 'Employer'; + + $daysSinceCreation = (int) Carbon::parse($offer->created_at)->startOfDay()->diffInDays(now()->startOfDay(), false); + + if ($daysSinceCreation !== $noResponseDays) continue; + + $level = $daysSinceCreation === 1 ? '1_day' : "{$daysSinceCreation}_days"; + + if ($this->alreadySent($worker, 'worker_no_response_offer', $offer, $level)) continue; + + $this->info("Sending no-response reminder to worker {$worker->name} for pending direct offer from {$employerName}."); + $worker->notify(new \App\Notifications\WorkerNoResponseNotification( + 'worker_no_response_offer', + "Action Required: Pending Job Offer", + "You have a pending job offer from {$employerName} sent on {$offer->created_at->toDateString()} that you have not responded to.", + $daysSinceCreation, + get_class($offer), + $offer->id + )); + + $this->logReminder($worker, 'worker_no_response_offer', $offer, $level, 'all'); + } + + // 2. Chat conversations where the last message is from the employer + $conversations = \App\Models\Conversation::with(['employer', 'worker'])->get(); + + foreach ($conversations as $conv) { + $worker = $conv->worker; + if (!$worker) continue; + $employer = $conv->employer; + $employerName = $employer ? $employer->name : 'Employer'; + + $lastMessage = $conv->messages()->latest()->first(); + if (!$lastMessage) continue; + + $isEmployerSender = false; + if ($lastMessage->sender_type === 'App\Models\User' || $lastMessage->sender_id == $conv->employer_id) { + $isEmployerSender = true; + } + + if (!$isEmployerSender) continue; + + $daysSinceMessage = (int) Carbon::parse($lastMessage->created_at)->startOfDay()->diffInDays(now()->startOfDay(), false); + + if ($daysSinceMessage !== $noResponseDays) continue; + + $level = $daysSinceMessage === 1 ? '1_day' : "{$daysSinceMessage}_days"; + + if ($this->alreadySent($worker, 'worker_no_response_chat', $lastMessage, $level)) continue; + + $this->info("Sending no-response chat reminder to worker {$worker->name} for conversation with {$employerName}."); + $worker->notify(new \App\Notifications\WorkerNoResponseNotification( + 'worker_no_response_chat', + "New Messages: Reply to {$employerName}", + "You have a message from {$employerName} that you have not responded to for {$daysSinceMessage} days.", + $daysSinceMessage, + get_class($lastMessage), + $lastMessage->id + )); + + $this->logReminder($worker, 'worker_no_response_chat', $lastMessage, $level, 'all'); + } + } + + private function checkReviewReminders() + { + $eligibilityMonths = (int) config('reminders.review_eligibility_months', 3); + $level = "{$eligibilityMonths}_months"; + + // 1. Standard Job Applications with status 'hired' + $applications = \App\Models\JobApplication::where('status', 'hired') + ->whereNotNull('joining_confirmed_at') + ->with(['jobPost.employer', 'worker']) + ->get(); + + foreach ($applications as $app) { + $job = $app->jobPost; + if (!$job) continue; + $employer = $job->employer; + $worker = $app->worker; + if (!$employer || !$worker) continue; + + $joiningDate = Carbon::parse($app->joining_confirmed_at); + $months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false); + + if ($months >= $eligibilityMonths) { + // Remind worker to review employer + if (!$this->alreadySent($worker, 'review_employer_reminder', $app, $level)) { + $this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name}."); + $worker->notify(new \App\Notifications\WorkerReviewReminderNotification( + $employer->name, + $app->id, + $level + )); + $this->logReminder($worker, 'review_employer_reminder', $app, $level, 'all'); + } + + // Remind employer to review worker + if (!$this->alreadySent($employer, 'review_worker_reminder', $app, $level)) { + $this->info("Sending review reminder to employer {$employer->name} to review worker {$worker->name}."); + $employer->notify(new \App\Notifications\EmployerReviewReminderNotification( + $worker->name, + $app->id, + $level + )); + $this->logReminder($employer, 'review_worker_reminder', $app, $level, 'all'); + } + } + } + + // 2. Direct Job Offers with status 'accepted' + $offers = \App\Models\JobOffer::where('status', 'accepted') + ->with(['worker', 'employer']) + ->get(); + + foreach ($offers as $offer) { + $worker = $offer->worker; + $employer = $offer->employer; + if (!$worker || !$employer) continue; + + $joiningDate = $offer->work_date ? Carbon::parse($offer->work_date) : $offer->updated_at; + $months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false); + + if ($months >= $eligibilityMonths) { + // Remind worker to review employer + if (!$this->alreadySent($worker, 'review_employer_reminder', $offer, $level)) { + $this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name} (direct offer)."); + $worker->notify(new \App\Notifications\WorkerReviewReminderNotification( + $employer->name, + $offer->id, + $level + )); + $this->logReminder($worker, 'review_employer_reminder', $offer, $level, 'all'); + } + + // Remind employer to review worker + if (!$this->alreadySent($employer, 'review_worker_reminder', $offer, $level)) { + $this->info("Sending review reminder to employer {$employer->name} to review worker {$worker->name} (direct offer)."); + $employer->notify(new \App\Notifications\EmployerReviewReminderNotification( + $worker->name, + $offer->id, + $level + )); + $this->logReminder($employer, 'review_worker_reminder', $offer, $level, 'all'); + } + } + } + } +} diff --git a/app/Http/Controllers/Admin/WorkerController.php b/app/Http/Controllers/Admin/WorkerController.php index bcb8a78..c4b9a2a 100644 --- a/app/Http/Controllers/Admin/WorkerController.php +++ b/app/Http/Controllers/Admin/WorkerController.php @@ -132,7 +132,7 @@ public function updateProfile(Request $request, $id) 'experience' => 'required|string', 'salary' => 'nullable|numeric', 'nationality' => 'nullable|string', - 'visa_status' => 'nullable|string', + 'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa', 'age' => 'nullable|integer', 'in_country' => 'nullable', 'preferred_job_type' => 'nullable|string', diff --git a/app/Http/Controllers/Api/EmployerAuthController.php b/app/Http/Controllers/Api/EmployerAuthController.php index 8e2e1e6..017be9e 100644 --- a/app/Http/Controllers/Api/EmployerAuthController.php +++ b/app/Http/Controllers/Api/EmployerAuthController.php @@ -252,6 +252,10 @@ public function register(Request $request) $emiratesIdIssuePlace = $emiratesIdInput['issue_place'] ?? $emiratesIdInput['issue place'] ?? $emiratesIdInput['issuing_place'] ?? null; $emiratesIdOccupation = $emiratesIdInput['occupation'] ?? $emiratesIdInput['occuption'] ?? null; $emiratesIdCardNumber = $emiratesIdInput['card_number'] ?? null; + + $emiratesIdExpiry = $this->normaliseDate($emiratesIdExpiry); + $emiratesIdDob = $this->normaliseDate($emiratesIdDob); + $emiratesIdIssueDate = $this->normaliseDate($emiratesIdIssueDate); } // Create inactive User @@ -447,7 +451,7 @@ public function payment(Request $request) { $validator = Validator::make($request->all(), [ 'email' => 'required|email', - 'plan_id' => 'required|string', + 'plan_id' => 'required', 'amount_aed' => 'required|numeric', 'paytabs_transaction_id' => 'required|string', ]); @@ -477,7 +481,21 @@ public function payment(Request $request) ], 403); } - \Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor) { + $planMap = [ + '1' => 'basic', + '2' => 'premium', + '3' => 'vip', + 1 => 'basic', + 2 => 'premium', + 3 => 'vip', + 'basic' => 'basic', + 'premium' => 'premium', + 'enterprise' => 'vip', + 'vip' => 'vip', + ]; + $dbPlanId = $planMap[$request->plan_id] ?? $request->plan_id; + + \Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor, $dbPlanId) { // Update User subscription $user->update([ 'subscription_status' => 'active', @@ -487,7 +505,7 @@ public function payment(Request $request) // Update Sponsor status $sponsor->update([ 'subscription_status' => 'active', - 'subscription_plan' => $request->plan_id, + 'subscription_plan' => $dbPlanId, 'subscription_start_date' => now(), 'subscription_end_date' => now()->addDays(30), 'payment_status' => 'paid', @@ -496,7 +514,7 @@ public function payment(Request $request) // Insert into subscriptions table \Illuminate\Support\Facades\DB::table('subscriptions')->insert([ 'user_id' => $user->id, - 'plan_id' => $request->plan_id, + 'plan_id' => $dbPlanId, 'amount_aed' => $request->amount_aed, 'starts_at' => now(), 'expires_at' => now()->addDays(30), @@ -606,38 +624,81 @@ public function password(Request $request) public function plans() { + $dbPlans = \App\Models\Plan::where('status', 'Active')->get(); + if ($dbPlans->isEmpty()) { + $plans = [ + [ + 'id' => 1, + 'name' => 'Basic Search', + 'price' => 99.00, + 'currency' => 'AED', + 'period' => 'month', + 'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR verification'], + 'popular' => false, + 'max_contact_people' => 10, + 'unlimited_contacts' => false, + 'enable_job_apply' => false, + 'status' => 'Active', + 'duration' => 'Monthly', + ], + [ + 'id' => 2, + 'name' => 'Premium Employer Pass', + 'price' => 199.00, + 'currency' => 'AED', + 'period' => 'month', + 'features' => ['Unlimited shortlisting', 'Direct candidate messaging', 'Priority interview scheduling', 'Dedicated support'], + 'popular' => true, + 'max_contact_people' => 50, + 'unlimited_contacts' => false, + 'enable_job_apply' => true, + 'status' => 'Active', + 'duration' => 'Monthly', + ], + [ + 'id' => 3, + 'name' => 'VIP Concierge', + 'price' => 499.00, + 'currency' => 'AED', + 'period' => 'month', + 'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'], + 'popular' => false, + 'max_contact_people' => null, + 'unlimited_contacts' => true, + 'enable_job_apply' => true, + 'status' => 'Active', + 'duration' => 'Monthly', + ] + ]; + } else { + $planIdNumberMap = [ + 'basic' => 1, + 'premium' => 2, + 'vip' => 3, + 'enterprise' => 3, + ]; + $plans = $dbPlans->map(function ($plan) use ($planIdNumberMap) { + return [ + 'id' => $planIdNumberMap[$plan->id] ?? 2, + 'name' => $plan->name, + 'price' => (float)$plan->price, + 'currency' => 'AED', + 'period' => strtolower($plan->duration) === 'monthly' ? 'month' : $plan->duration, + 'features' => $plan->features, + 'popular' => $plan->id === 'premium', + 'max_contact_people' => $plan->max_contact_people, + 'unlimited_contacts' => (bool)$plan->unlimited_contacts, + 'enable_job_apply' => (bool)$plan->enable_job_apply, + 'status' => $plan->status, + 'duration' => $plan->duration, + ]; + })->toArray(); + } + return response()->json([ 'success' => true, 'data' => [ - 'plans' => [ - [ - 'id' => 'basic', - 'name' => 'Basic Search', - 'price' => 99.00, - 'currency' => 'AED', - 'period' => 'month', - 'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR verification'], - 'popular' => false, - ], - [ - 'id' => 'premium', - 'name' => 'Premium Employer Pass', - 'price' => 199.00, - 'currency' => 'AED', - 'period' => 'month', - 'features' => ['Unlimited shortlisting', 'Direct candidate messaging', 'Priority interview scheduling', 'Dedicated support'], - 'popular' => true, - ], - [ - 'id' => 'enterprise', - 'name' => 'VIP Concierge', - 'price' => 499.00, - 'currency' => 'AED', - 'period' => 'month', - 'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'], - 'popular' => false, - ] - ] + 'plans' => $plans ] ]); } @@ -779,4 +840,36 @@ public function resetPassword(Request $request) ], 500); } } + + /** + * Normalise date format to Y-m-d. + * E.g. "06/06/2025" -> "2025-06-06" + */ + private function normaliseDate(?string $raw): ?string + { + if (empty($raw)) { + return null; + } + $raw = trim($raw); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) { + return $raw; + } + try { + if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) { + $day = str_pad($m[1], 2, '0', STR_PAD_LEFT); + $month = $m[2]; + $year = $m[3]; + if (is_numeric($month)) { + $month = str_pad($month, 2, '0', STR_PAD_LEFT); + return "{$year}-{$month}-{$day}"; + } + $ts = strtotime("{$day} {$month} {$year}"); + return $ts ? date('Y-m-d', $ts) : $raw; + } + $dt = new \DateTime($raw); + return $dt->format('Y-m-d'); + } catch (\Exception $e) { + return $raw; + } + } } diff --git a/app/Http/Controllers/Api/EmployerMessageController.php b/app/Http/Controllers/Api/EmployerMessageController.php index e2ab989..25c4bd8 100644 --- a/app/Http/Controllers/Api/EmployerMessageController.php +++ b/app/Http/Controllers/Api/EmployerMessageController.php @@ -334,10 +334,28 @@ public function startConversation(Request $request) try { // Find or create conversation - $conv = Conversation::firstOrCreate([ - 'employer_id' => $employer->id, - 'worker_id' => $request->worker_id, - ]); + $conv = Conversation::where('employer_id', $employer->id) + ->where('worker_id', $request->worker_id) + ->first(); + + if (!$conv) { + if (!User::canContactWorker($employer->id, $request->worker_id)) { + $activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + return response()->json([ + 'success' => false, + 'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.' + ], 403); + } + + $conv = Conversation::create([ + 'employer_id' => $employer->id, + 'worker_id' => $request->worker_id, + ]); + } return response()->json([ 'success' => true, diff --git a/app/Http/Controllers/Api/EmployerNotificationController.php b/app/Http/Controllers/Api/EmployerNotificationController.php new file mode 100644 index 0000000..fe53372 --- /dev/null +++ b/app/Http/Controllers/Api/EmployerNotificationController.php @@ -0,0 +1,113 @@ +attributes->get('employer'); + + $filter = $request->query('filter', 'all'); + $perPage = (int) $request->query('per_page', 15); + + if ($filter === 'read') { + $query = $employer->readNotifications(); + } elseif ($filter === 'unread') { + $query = $employer->unreadNotifications(); + } else { + $query = $employer->notifications(); + } + + $paginator = $query->paginate($perPage); + + $notifications = collect($paginator->items())->map(function ($notification) { + return [ + 'id' => $notification->id, + 'type' => $notification->type, + 'data' => $notification->data, + 'read_at' => $notification->read_at ? $notification->read_at->toIso8601String() : null, + 'created_at' => $notification->created_at->toIso8601String(), + 'time_ago' => $notification->created_at->diffForHumans(), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'notifications' => $notifications, + 'pagination' => [ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ] + ] + ], 200); + } + + /** + * Mark a single notification or all unread notifications as read. + * PUT /api/employers/notifications/{id}/read or PUT /api/employers/notifications/read + */ + public function markAsRead(Request $request, $id = null) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if ($id) { + $notification = $employer->notifications()->where('id', $id)->first(); + if (!$notification) { + return response()->json([ + 'success' => false, + 'message' => 'Notification not found.' + ], 404); + } + $notification->markAsRead(); + } else { + $employer->unreadNotifications->markAsRead(); + } + + return response()->json([ + 'success' => true, + 'message' => 'Notification(s) marked as read successfully.' + ], 200); + } + + /** + * Delete a single notification or all notifications. + * DELETE /api/employers/notifications/{id} or DELETE /api/employers/notifications + */ + public function destroy(Request $request, $id = null) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if ($id) { + $notification = $employer->notifications()->where('id', $id)->first(); + if (!$notification) { + return response()->json([ + 'success' => false, + 'message' => 'Notification not found.' + ], 404); + } + $notification->delete(); + } else { + $employer->notifications()->delete(); + } + + return response()->json([ + 'success' => true, + 'message' => 'Notification(s) deleted successfully.' + ], 200); + } +} diff --git a/app/Http/Controllers/Api/EmployerProfileController.php b/app/Http/Controllers/Api/EmployerProfileController.php index 2bb4cc5..fa38aa6 100644 --- a/app/Http/Controllers/Api/EmployerProfileController.php +++ b/app/Http/Controllers/Api/EmployerProfileController.php @@ -23,6 +23,7 @@ public function getProfile(Request $request) $employer = $request->attributes->get('employer'); try { + $employer->load('employerReviews.worker'); $profile = $employer->employerProfile; if (!$profile) { @@ -38,10 +39,18 @@ public function getProfile(Request $request) 'name' => $employer->name, 'email' => $employer->email, 'phone' => $profile->phone, + 'address' => $profile->address, 'country' => $profile->country, 'language' => $profile->language ?? 'English', 'notifications' => (bool)($profile->notifications ?? true), 'verification_status' => $profile->verification_status ?? 'approved', + 'id_number' => $profile->emirates_id_number, + 'card_number' => $profile->emirates_id_card_number, + 'full_name' => $profile->emirates_id_name, + 'gender' => $profile->emirates_id_gender, + 'rating' => $employer->rating, + 'reviews_count' => $employer->reviews_count, + 'reviews' => $employer->employerReviews, 'emirates_id' => [ 'emirates_id_number' => $profile->emirates_id_number, 'name' => $profile->emirates_id_name, @@ -51,7 +60,7 @@ public function getProfile(Request $request) 'employer' => $profile->emirates_id_employer, 'issue_place' => $profile->emirates_id_issue_place, 'occupation' => $profile->emirates_id_occupation, - 'nationality' => $profile->emirates_id_nationality, + 'nationality' => $profile->nationality, 'gender' => $profile->emirates_id_gender, 'card_number' => $profile->emirates_id_card_number, 'country' => 'United Arab Emirates', @@ -190,7 +199,7 @@ public function updateProfile(Request $request) $sponsorData['emirates_id_name'] = $request->full_name; } if ($request->has('date_of_birth')) { - $sponsorData['emirates_id_dob'] = $request->date_of_birth; + $sponsorData['emirates_id_dob'] = $this->normaliseDate($request->date_of_birth); } if ($request->has('nationality')) { $sponsorData['nationality'] = $request->nationality; @@ -199,10 +208,10 @@ public function updateProfile(Request $request) $sponsorData['emirates_id_gender'] = $request->gender; } if ($request->has('issue_date')) { - $sponsorData['emirates_id_issue_date'] = $request->issue_date; + $sponsorData['emirates_id_issue_date'] = $this->normaliseDate($request->issue_date); } if ($request->has('expiry_date')) { - $sponsorData['emirates_id_expiry'] = $request->expiry_date; + $sponsorData['emirates_id_expiry'] = $this->normaliseDate($request->expiry_date); } if ($request->has('occupation')) { $sponsorData['emirates_id_occupation'] = $request->occupation; @@ -236,7 +245,7 @@ public function updateProfile(Request $request) $profile->emirates_id_name = $request->full_name; } if ($request->has('date_of_birth')) { - $profile->emirates_id_dob = $request->date_of_birth; + $profile->emirates_id_dob = $this->normaliseDate($request->date_of_birth); } if ($request->has('nationality')) { $profile->nationality = $request->nationality; @@ -245,10 +254,10 @@ public function updateProfile(Request $request) $profile->emirates_id_gender = $request->gender; } if ($request->has('issue_date')) { - $profile->emirates_id_issue_date = $request->issue_date; + $profile->emirates_id_issue_date = $this->normaliseDate($request->issue_date); } if ($request->has('expiry_date')) { - $profile->emirates_id_expiry = $request->expiry_date; + $profile->emirates_id_expiry = $this->normaliseDate($request->expiry_date); } if ($request->has('occupation')) { $profile->emirates_id_occupation = $request->occupation; @@ -262,14 +271,24 @@ public function updateProfile(Request $request) $profile->save(); + $employer->load('employerReviews.worker'); + $employerProfile = [ 'name' => $employer->name, 'email' => $employer->email, 'phone' => $profile->phone, + 'address' => $profile->address, 'country' => $profile->country, 'language' => $profile->language, 'notifications' => (bool)$profile->notifications, 'verification_status' => $profile->verification_status ?? 'approved', + 'id_number' => $profile->emirates_id_number, + 'card_number' => $profile->emirates_id_card_number, + 'full_name' => $profile->emirates_id_name, + 'gender' => $profile->emirates_id_gender, + 'rating' => $employer->rating, + 'reviews_count' => $employer->reviews_count, + 'reviews' => $employer->employerReviews, 'emirates_id' => [ 'emirates_id_number' => $profile->emirates_id_number, 'name' => $profile->emirates_id_name, @@ -279,7 +298,7 @@ public function updateProfile(Request $request) 'employer' => $profile->emirates_id_employer, 'issue_place' => $profile->emirates_id_issue_place, 'occupation' => $profile->emirates_id_occupation, - 'nationality' => $profile->emirates_id_nationality, + 'nationality' => $profile->nationality, 'gender' => $profile->emirates_id_gender, 'card_number' => $profile->emirates_id_card_number, 'country' => 'United Arab Emirates', @@ -339,12 +358,43 @@ public function getDashboard(Request $request) ->latest('id') ->first(); + $planId = $sub ? $sub->plan_id : 'premium'; + + // Map raw subscription plan_id to database plan ID string + $planMap = [ + '1' => 'basic', + '2' => 'premium', + '3' => 'vip', + 1 => 'basic', + 2 => 'premium', + 3 => 'vip', + 'basic' => 'basic', + 'premium' => 'premium', + 'enterprise' => 'vip', + 'vip' => 'vip', + ]; + + $dbPlanId = $planMap[$planId] ?? 'premium'; + $associatedPlan = \App\Models\Plan::find($dbPlanId); + + $planIdNumberMap = [ + 'basic' => 1, + 'premium' => 2, + 'vip' => 3, + ]; + $currentPlan = [ - 'plan_id' => $sub ? $sub->plan_id : 'premium', - 'name' => $sub ? (ucfirst($sub->plan_id) . ' Pass') : 'Premium Employer Pass', + 'plan_id' => $associatedPlan ? ($planIdNumberMap[$associatedPlan->id] ?? 2) : 2, + 'name' => $associatedPlan ? $associatedPlan->name : (ucfirst($planId) . ' Pass'), 'status' => $sub ? $sub->status : 'active', 'starts_at' => $sub ? date('Y-m-d', strtotime($sub->starts_at)) : now()->subDays(6)->format('Y-m-d'), 'expires_at' => $sub ? date('Y-m-d', strtotime($sub->expires_at)) : now()->addDays(24)->format('Y-m-d'), + 'max_contact_people' => $associatedPlan ? $associatedPlan->max_contact_people : null, + 'unlimited_contacts' => $associatedPlan ? (bool)$associatedPlan->unlimited_contacts : false, + 'enable_job_apply' => $associatedPlan ? (bool)$associatedPlan->enable_job_apply : false, + 'features' => $associatedPlan ? $associatedPlan->features : [], + 'price' => $associatedPlan ? (float)$associatedPlan->price : 0.0, + 'duration' => $associatedPlan ? $associatedPlan->duration : 'Monthly', ]; // Recent Announcements / Events @@ -483,4 +533,35 @@ public function changePassword(Request $request) } } + /** + * Normalise date format to Y-m-d. + * E.g. "06/06/2025" -> "2025-06-06" + */ + private function normaliseDate(?string $raw): ?string + { + if (empty($raw)) { + return null; + } + $raw = trim($raw); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) { + return $raw; + } + try { + if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) { + $day = str_pad($m[1], 2, '0', STR_PAD_LEFT); + $month = $m[2]; + $year = $m[3]; + if (is_numeric($month)) { + $month = str_pad($month, 2, '0', STR_PAD_LEFT); + return "{$year}-{$month}-{$day}"; + } + $ts = strtotime("{$day} {$month} {$year}"); + return $ts ? date('Y-m-d', $ts) : $raw; + } + $dt = new \DateTime($raw); + return $dt->format('Y-m-d'); + } catch (\Exception $e) { + return $raw; + } + } } diff --git a/app/Http/Controllers/Api/EmployerReviewController.php b/app/Http/Controllers/Api/EmployerReviewController.php index 3b817ff..4ac983d 100644 --- a/app/Http/Controllers/Api/EmployerReviewController.php +++ b/app/Http/Controllers/Api/EmployerReviewController.php @@ -35,12 +35,65 @@ public function addReview(Request $request) } try { + // 1. Validate confirmed hire and completed joining, and review eligibility period + $hasConfirmedHire = false; + $joiningDate = null; + + // Check standard JobApplication where the job belongs to this employer and worker matches + $app = \App\Models\JobApplication::where('worker_id', $request->worker_id) + ->where('status', 'hired') + ->whereNotNull('joining_confirmed_at') + ->whereHas('jobPost', function ($query) use ($employer) { + $query->where('employer_id', $employer->id); + }) + ->first(); + + if ($app) { + $hasConfirmedHire = true; + $joiningDate = \Carbon\Carbon::parse($app->joining_confirmed_at); + } else { + // Check direct JobOffer + $offer = \App\Models\JobOffer::where('worker_id', $request->worker_id) + ->where('employer_id', $employer->id) + ->where('status', 'accepted') + ->first(); + + if ($offer) { + $hasConfirmedHire = true; + $joiningDate = $offer->work_date ? \Carbon\Carbon::parse($offer->work_date) : $offer->updated_at; + } + } + + if (!$hasConfirmedHire) { + return response()->json([ + 'success' => false, + 'message' => 'You can only review workers with a confirmed hire and completed joining.' + ], 403); + } + + $eligibilityMonths = (int) config('reminders.review_eligibility_months', 3); + if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) { + return response()->json([ + 'success' => false, + 'message' => "You can write a review only after {$eligibilityMonths} months from the joining date." + ], 403); + } + // Check if employer has already reviewed this worker $existingReview = Review::where('employer_id', $employer->id) ->where('worker_id', $request->worker_id) ->first(); if ($existingReview) { + // Check if the edit window has expired + $editPeriodDays = (int) config('reminders.review_edit_period_days', 7); + if ($existingReview->created_at->addDays($editPeriodDays)->isPast()) { + return response()->json([ + 'success' => false, + 'message' => "The {$editPeriodDays}-day edit window for this review has expired." + ], 403); + } + // If they already have a review, we can update it or direct them to the edit endpoint. // To be robust, let's update it directly and inform them it was updated (acting as an edit). $existingReview->update([ @@ -118,6 +171,15 @@ public function editReview(Request $request, $id) ], 404); } + // Check if the edit window has expired + $editPeriodDays = (int) config('reminders.review_edit_period_days', 7); + if ($review->created_at->addDays($editPeriodDays)->isPast()) { + return response()->json([ + 'success' => false, + 'message' => "The {$editPeriodDays}-day edit window for this review has expired." + ], 403); + } + $review->update([ 'rating' => $request->rating, 'comment' => $request->comment, diff --git a/app/Http/Controllers/Api/EmployerWorkerController.php b/app/Http/Controllers/Api/EmployerWorkerController.php index b37d03a..e73f5dd 100644 --- a/app/Http/Controllers/Api/EmployerWorkerController.php +++ b/app/Http/Controllers/Api/EmployerWorkerController.php @@ -57,6 +57,7 @@ private function formatWorker(Worker $w) 'deleted_at' => $w->deleted_at?->toISOString(), 'language' => $w->language, 'languages' => $langs, + 'main_profession' => $w->main_profession, 'preferred_location' => $w->preferred_location, 'country' => $w->country, 'city' => $w->city, @@ -154,6 +155,13 @@ public function getWorkers(Request $request) $workersArray = $formattedWorkers->toArray(); // Apply filters: gender, skills, language/languages, nationality, preferred_location, job_type, live_in_out, in_country, visa_status + if ($request->filled('main_profession')) { + $mainProfession = strtolower($request->main_profession); + $workersArray = array_values(array_filter($workersArray, function ($c) use ($mainProfession) { + return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession; + })); + } + if ($request->filled('gender')) { $gender = strtolower($request->gender); $workersArray = array_values(array_filter($workersArray, function ($c) use ($gender) { @@ -481,6 +489,13 @@ public function getCandidates(Request $request) })); // Apply filters: gender, skills, languages, nationality, availability, preferred_location, job_type, live_in_out, in_country, visa_status + 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; + })); + } + if ($request->filled('gender')) { $gender = strtolower($request->gender); $mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($gender) { @@ -737,6 +752,39 @@ public function hireCandidate(Request $request, $id = null) $updatedApplication = null; $updatedOffer = null; + // Resolve the worker ID to perform limit check + $resolvedWorkerId = null; + if (is_string($targetId) && str_starts_with($targetId, 'offer_')) { + $offerId = substr($targetId, 6); + $offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->first(); + if ($offer) { + $resolvedWorkerId = $offer->worker_id; + } + } else { + $application = JobApplication::where('id', $targetId) + ->whereHas('jobPost', function($q) use ($employerId) { + $q->where('employer_id', $employerId); + })->first(); + + if ($application) { + $resolvedWorkerId = $application->worker_id; + } else { + $resolvedWorkerId = $targetId; + } + } + + if ($resolvedWorkerId && !\App\Models\User::canContactWorker($employerId, $resolvedWorkerId)) { + $activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + return response()->json([ + 'success' => false, + 'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.' + ], 403); + } + // Direct Offer check if (is_string($targetId) && str_starts_with($targetId, 'offer_')) { $offerId = substr($targetId, 6); @@ -896,8 +944,8 @@ public function getWorkerDetail(Request $request, $id) ]; // Visa status - $visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa']; - $visaStatus = $visaStatusesList[$w->id % 5]; + $visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa']; + $visaStatus = $visaStatusesList[$w->id % 3]; $photo = null; diff --git a/app/Http/Controllers/Api/SponsorAuthController.php b/app/Http/Controllers/Api/SponsorAuthController.php index 5a7a949..1f42583 100644 --- a/app/Http/Controllers/Api/SponsorAuthController.php +++ b/app/Http/Controllers/Api/SponsorAuthController.php @@ -103,6 +103,10 @@ public function register(Request $request) $emiratesIdIssuePlace = $emiratesIdInput['issue_place'] ?? $emiratesIdInput['issue place'] ?? null; $emiratesIdOccupation = $emiratesIdInput['occupation'] ?? $emiratesIdInput['occuption'] ?? null; $emiratesIdCardNumber = $emiratesIdInput['card_number'] ?? null; + + $emiratesIdExpiry = $this->normaliseDate($emiratesIdExpiry); + $emiratesIdDob = $this->normaliseDate($emiratesIdDob); + $emiratesIdIssueDate = $this->normaliseDate($emiratesIdIssueDate); } $emiratesIdPath = null; @@ -495,5 +499,37 @@ public function resetPassword(Request $request) 'message' => 'Password reset successfully. You can now log in with your new password.', ]); } + + /** + * Normalise date format to Y-m-d. + * E.g. "06/06/2025" -> "2025-06-06" + */ + private function normaliseDate(?string $raw): ?string + { + if (empty($raw)) { + return null; + } + $raw = trim($raw); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) { + return $raw; + } + try { + if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) { + $day = str_pad($m[1], 2, '0', STR_PAD_LEFT); + $month = $m[2]; + $year = $m[3]; + if (is_numeric($month)) { + $month = str_pad($month, 2, '0', STR_PAD_LEFT); + return "{$year}-{$month}-{$day}"; + } + $ts = strtotime("{$day} {$month} {$year}"); + return $ts ? date('Y-m-d', $ts) : $raw; + } + $dt = new \DateTime($raw); + return $dt->format('Y-m-d'); + } catch (\Exception $e) { + return $raw; + } + } } diff --git a/app/Http/Controllers/Api/SponsorController.php b/app/Http/Controllers/Api/SponsorController.php index 98462a3..53f69dc 100644 --- a/app/Http/Controllers/Api/SponsorController.php +++ b/app/Http/Controllers/Api/SponsorController.php @@ -229,15 +229,18 @@ public function postCharityEvent(Request $request) } $validator = \Illuminate\Support\Facades\Validator::make($request->all(), [ - 'title' => 'required|string|max:255', - 'body' => 'required|string', - 'type' => 'nullable|string|in:charity,info,warning,success', - 'event_date' => 'required|string', - 'start_time' => 'required|string', - 'end_time' => 'required|string', - 'provided_items' => 'required|string', - 'location_details' => 'required|string', - 'location_pin' => 'required|string|url', + 'title' => 'required|string|max:255', + 'body' => 'required|string', + 'type' => 'nullable|string|in:charity,info,warning,success', + 'event_date' => 'required|string', + 'start_time' => 'required|string', + 'end_time' => 'required|string', + 'provided_items' => 'required|string', + 'location_details' => 'required|string', + 'location_pin' => 'required|string|url', + 'contact_person_name' => 'required|string|max:255', + 'contact_number' => 'required|string|regex:/^\d{7,15}$/', + 'country_code' => 'required|string|regex:/^\+\d{1,4}$/', ]); if ($validator->fails()) { @@ -252,13 +255,16 @@ public function postCharityEvent(Request $request) $eventTime = $request->start_time . ' - ' . $request->end_time; $bodyJson = json_encode([ - 'type' => 'Charity', - 'provided_items' => $request->provided_items, - 'event_date' => $request->event_date, - 'event_time' => $eventTime, - 'location_details' => $request->location_details, - 'location_pin' => $request->location_pin, - 'content' => $request->body, + 'type' => 'Charity', + 'provided_items' => $request->provided_items, + 'event_date' => $request->event_date, + 'event_time' => $eventTime, + 'location_details' => $request->location_details, + 'location_pin' => $request->location_pin, + 'content' => $request->body, + 'contact_person_name' => $request->contact_person_name, + 'contact_number' => $request->contact_number, + 'country_code' => $request->country_code, ]); $event = Announcement::create([ @@ -634,6 +640,11 @@ public function updateProfile(Request $request) $eidCardNumber = $eidCardNumber ?? $request->input('card_number'); $eidGender = $eidGender ?? $request->input('gender'); + // Normalize dates + $eidDob = $this->normaliseDate($eidDob); + $eidExpiry = $this->normaliseDate($eidExpiry); + $eidIssueDate = $this->normaliseDate($eidIssueDate); + $nameToUse = $eidName ?? $request->name ?? $request->full_name; // Validate Emirates ID immutability — once set, it cannot be changed @@ -738,6 +749,13 @@ public function updateProfile(Request $request) } if (!empty($newLicenseData)) { + if (isset($newLicenseData['expiry_date'])) { + $newLicenseData['expiry_date'] = $this->normaliseDate($newLicenseData['expiry_date']); + $sponsorData['license_expiry'] = $newLicenseData['expiry_date']; + } + if (isset($newLicenseData['issue_date'])) { + $newLicenseData['issue_date'] = $this->normaliseDate($newLicenseData['issue_date']); + } $sponsorData['license_data'] = array_merge($currentLicenseData, $newLicenseData); } @@ -761,4 +779,36 @@ public function updateProfile(Request $request) ], 500); } } + + /** + * Normalise date format to Y-m-d. + * E.g. "06/06/2025" -> "2025-06-06" + */ + private function normaliseDate(?string $raw): ?string + { + if (empty($raw)) { + return null; + } + $raw = trim($raw); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) { + return $raw; + } + try { + if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) { + $day = str_pad($m[1], 2, '0', STR_PAD_LEFT); + $month = $m[2]; + $year = $m[3]; + if (is_numeric($month)) { + $month = str_pad($month, 2, '0', STR_PAD_LEFT); + return "{$year}-{$month}-{$day}"; + } + $ts = strtotime("{$day} {$month} {$year}"); + return $ts ? date('Y-m-d', $ts) : $raw; + } + $dt = new \DateTime($raw); + return $dt->format('Y-m-d'); + } catch (\Exception $e) { + return $raw; + } + } } diff --git a/app/Http/Controllers/Api/WorkerAuthController.php b/app/Http/Controllers/Api/WorkerAuthController.php index ab9d800..84ac32d 100644 --- a/app/Http/Controllers/Api/WorkerAuthController.php +++ b/app/Http/Controllers/Api/WorkerAuthController.php @@ -181,6 +181,7 @@ public function setupProfile(Request $request) 'gender' => 'nullable|string|in:male,female,other', 'live_in_out' => 'nullable|string|in:live_in,live_out', 'preferred_location' => 'nullable|string|max:255', + 'main_profession' => 'nullable|string|max:100', 'skills' => 'nullable|array', 'skills.*' => 'integer|exists:skills,id', 'fcm_token' => 'nullable|string|max:255', @@ -241,6 +242,7 @@ public function setupProfile(Request $request) 'gender' => $request->gender, 'live_in_out' => $request->live_in_out, 'preferred_location' => $request->preferred_location, + 'main_profession' => $request->main_profession, 'age' => 25, // Default — updated later in full profile 'salary' => 1500, // Default AED — updated later 'availability' => 'Immediate', @@ -264,7 +266,7 @@ public function setupProfile(Request $request) // Clear the OTP verification gate Cache::forget('worker_otp_verified_' . $identifier); - $worker->load(['skills']); + $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']); return response()->json([ 'success' => true, @@ -316,8 +318,9 @@ 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', 'live_in_out' => 'nullable|string|in:live_in,live_out', 'preferred_location' => 'nullable|string|max:255', @@ -453,6 +456,7 @@ public function register(Request $request) 'gender' => $request->gender, 'live_in_out' => $request->live_in_out, 'preferred_location' => $request->preferred_location, + 'main_profession' => $request->main_profession, 'age' => $age, 'salary' => $request->salary, 'availability' => 'Immediate', @@ -519,7 +523,7 @@ public function register(Request $request) return $worker; }); - $result->load(['skills', 'documents']); + $result->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']); return response()->json([ 'success' => true, @@ -587,7 +591,7 @@ public function register(Request $request) 'success' => true, 'message' => 'Worker logged in successfully.', 'data' => [ - 'worker' => $worker->load(['skills', 'documents']), + 'worker' => $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']), 'token' => $apiToken ] ], 200); @@ -1243,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; @@ -1273,7 +1290,7 @@ private function cleanVisaData(array $visaDataInput): array return [ 'document_type' => 'uae_visa', 'country' => 'United Arab Emirates', - 'visa_type' => $rawVisaType ?: 'ENTRY PERMIT', + 'visa_type' => $rawVisaType, 'entry_permit_no' => $rawEntryPermitNo ?: '', 'issue_date' => $rawIssueDate ?: '', 'valid_until' => $rawValidUntil ?: '', diff --git a/app/Http/Controllers/Api/WorkerJobController.php b/app/Http/Controllers/Api/WorkerJobController.php new file mode 100644 index 0000000..69ebc2a --- /dev/null +++ b/app/Http/Controllers/Api/WorkerJobController.php @@ -0,0 +1,812 @@ +with(['employer.employerProfile']) + ->latest() + ->get() + ->map(function ($job) { + return [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', + 'posted_by' => $job->employer->name ?? 'Private Sponsor', + 'posted_at' => $job->created_at->toIso8601String(), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'jobs' => $jobs + ] + ]); + } + + /** + * API to retrieve job details. + * GET /api/workers/jobs/{id} + */ + public function jobDetails($id) + { + $job = JobPost::where('status', 'active') + ->with(['employer.employerProfile']) + ->find($id); + + if (!$job) { + return response()->json([ + 'success' => false, + 'message' => 'Job not found.' + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'job' => [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', + 'posted_by' => $job->employer->name ?? 'Private Sponsor', + 'posted_at' => $job->created_at->toIso8601String(), + ] + ] + ]); + } + + /** + * API for workers to apply for a job. + * POST /api/workers/jobs/{id}/apply + */ + public function applyJob(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $job = JobPost::where('status', 'active')->find($id); + + if (!$job) { + return response()->json([ + 'success' => false, + 'message' => 'Job not found.' + ], 404); + } + + // Prevent duplicate applications + $exists = JobApplication::where('worker_id', $worker->id) + ->where('job_id', $job->id) + ->exists(); + + if ($exists) { + return response()->json([ + 'success' => false, + 'message' => 'You have already applied for this job.' + ], 409); + } + + $application = JobApplication::create([ + 'worker_id' => $worker->id, + 'job_id' => $job->id, + 'status' => 'applied' + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Successfully applied for the job.', + 'data' => [ + 'application' => $application + ] + ], 201); + } + + /** + * API for workers to view their applied jobs. + * GET /api/workers/applied-jobs + */ + public function appliedJobs(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $applications = JobApplication::where('worker_id', $worker->id) + ->with(['jobPost.employer.employerProfile']) + ->latest() + ->get() + ->map(function ($app) { + $job = $app->jobPost; + return [ + 'application_id' => $app->id, + 'status' => $app->status, + 'applied_at' => $app->created_at->toIso8601String(), + 'job' => $job ? [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'job_type' => $job->job_type, + 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', + 'posted_by' => $job->employer->name ?? 'Private Sponsor', + ] : null + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'applications' => $applications + ] + ]); + } + + private function checkJobAccess($employerId) + { + $sub = \Illuminate\Support\Facades\DB::table('subscriptions') + ->where('user_id', $employerId) + ->where('status', 'active') + ->latest('id') + ->first(); + + $planId = $sub ? $sub->plan_id : 'basic'; + return $planId !== 'basic'; + } + + /** + * API for employers to list their jobs. + * GET /api/employers/jobs + */ + public function employerListJobs(Request $request) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $jobs = JobPost::where('employer_id', $employer->id) + ->with(['applications']) + ->latest() + ->get() + ->map(function ($job) { + return [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'applied_count' => $job->applications->count(), + 'posted_at' => $job->created_at->toIso8601String(), + 'status' => $job->status, + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'jobs' => $jobs + ] + ]); + } + + /** + * API for employers to create a job. + * POST /api/employers/jobs + */ + public function employerCreateJob(Request $request) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $validator = Validator::make($request->all(), [ + 'title' => 'required|string|max:255', + 'workers_needed' => 'required|integer|min:1', + 'location' => 'required|string|max:255', + 'salary' => 'required|numeric|min:0', + 'job_type' => 'required|string|in:Full Time,Part Time,Contract', + 'start_date' => 'required|date', + 'description' => 'required|string|max:1000', + 'requirements' => 'nullable|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + $job = JobPost::create([ + 'employer_id' => $employer->id, + 'title' => $request->title, + 'workers_needed' => $request->workers_needed, + 'job_type' => $request->job_type, + 'location' => $request->location, + 'salary' => $request->salary, + 'start_date' => $request->start_date, + 'description' => $request->description, + 'requirements' => $request->requirements, + 'status' => 'active', + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Job posted successfully.', + 'data' => [ + 'job' => [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'posted_at' => $job->created_at->toIso8601String(), + 'status' => $job->status, + ] + ] + ], 201); + } + + /** + * API for employers to retrieve details of a specific job. + * GET /api/employers/jobs/{id} + */ + public function employerJobDetail(Request $request, $id) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $job = JobPost::where('employer_id', $employer->id) + ->with(['applications']) + ->find($id); + + if (!$job) { + return response()->json([ + 'success' => false, + 'message' => 'Job not found or unauthorized.' + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'job' => [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'applied_count' => $job->applications->count(), + 'posted_at' => $job->created_at->toIso8601String(), + 'status' => $job->status, + ] + ] + ]); + } + + /** + * API for employers to update a specific job. + * POST /api/employers/jobs/{id}/update + */ + public function employerUpdateJob(Request $request, $id) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $job = JobPost::where('employer_id', $employer->id)->find($id); + + if (!$job) { + return response()->json([ + 'success' => false, + 'message' => 'Job not found or unauthorized.' + ], 404); + } + + $validator = Validator::make($request->all(), [ + 'title' => 'required|string|max:255', + 'workers_needed' => 'required|integer|min:1', + 'location' => 'required|string|max:255', + 'salary' => 'required|numeric|min:0', + 'job_type' => 'required|string|in:Full Time,Part Time,Contract', + 'start_date' => 'required|date', + 'description' => 'required|string|max:1000', + 'requirements' => 'nullable|string', + 'status' => 'required|string|in:active,closed,draft', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + $job->update([ + 'title' => $request->title, + 'workers_needed' => $request->workers_needed, + 'job_type' => $request->job_type, + 'location' => $request->location, + 'salary' => $request->salary, + 'start_date' => $request->start_date, + 'description' => $request->description, + 'requirements' => $request->requirements, + 'status' => strtolower($request->status), + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Job updated successfully.', + 'data' => [ + 'job' => [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'posted_at' => $job->created_at->toIso8601String(), + 'status' => $job->status, + ] + ] + ]); + } + + /** + * API for employers to delete a job. + * DELETE /api/employers/jobs/{id} + */ + public function employerDeleteJob(Request $request, $id) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $job = JobPost::where('employer_id', $employer->id)->find($id); + + if (!$job) { + return response()->json([ + 'success' => false, + 'message' => 'Job not found or unauthorized.' + ], 404); + } + + $job->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Job deleted successfully.' + ]); + } + + /** + * API for employers to view applicants for each job. + * GET /api/employers/jobs/{id}/applicants + */ + public function employerJobApplicants(Request $request, $id) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $job = JobPost::where('employer_id', $employer->id)->find($id); + + if (!$job) { + return response()->json([ + 'success' => false, + 'message' => 'Job not found or unauthorized.' + ], 404); + } + + $query = JobApplication::where('job_id', $job->id) + ->with(['worker.skills']); + + // Search filter: name, nationality + if ($request->filled('search')) { + $search = $request->search; + $query->whereHas('worker', function($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('nationality', 'like', "%{$search}%"); + }); + } + + // Status filter + if ($request->filled('status')) { + $query->where('status', strtolower($request->status)); + } + + $sortBy = $request->input('sort_by', 'created_at'); + $sortOrder = $request->input('sort_order', 'desc'); + + if ($sortBy === 'created_at' || $sortBy === 'applied_at') { + $query->orderBy('created_at', $sortOrder === 'asc' ? 'asc' : 'desc'); + } + + $applicants = $query->get()->map(function ($app) use ($employer) { + $w = $app->worker; + if (!$w) return null; + + // Check if worker is saved in Saved Workers (Shortlist) + $isSaved = \App\Models\Shortlist::where('employer_id', $employer->id) + ->where('worker_id', $w->id) + ->exists(); + + return [ + 'application_id' => $app->id, + 'status' => $app->status, + 'notes' => $app->notes, + 'status_history' => $app->status_history ?: [], + 'applied_at' => $app->created_at->toIso8601String(), + 'is_saved' => $isSaved, + 'worker' => [ + 'id' => $w->id, + 'name' => $w->name, + 'nationality' => $w->nationality, + 'salary' => (int) $w->salary, + 'preferred_location' => $w->preferred_location, + 'preferred_job_type' => $w->preferred_job_type, + 'visa_status' => $w->visa_status, + 'experience' => $w->experience, + 'skills' => $w->skills->pluck('name')->toArray(), + ] + ]; + })->filter()->values(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'job' => [ + 'id' => $job->id, + 'title' => $job->title, + ], + 'applicants' => $applicants + ] + ]); + } + + /** + * API for employers to update application status. + * POST /api/employers/applications/{id}/status + */ + public function employerUpdateApplicationStatus(Request $request, $id) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $validator = Validator::make($request->all(), [ + 'status' => 'required|string|in:applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired', + 'notes' => 'nullable|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + $application = JobApplication::where('id', $id) + ->whereHas('jobPost', function($q) use ($employer) { + $q->where('employer_id', $employer->id); + })->first(); + + if (!$application) { + return response()->json([ + 'success' => false, + 'message' => 'Job application not found or unauthorized.' + ], 404); + } + + $dbStatus = strtolower($request->status); + + // Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired' for the first time + $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired']; + if (in_array($dbStatus, $contactStatuses)) { + if (!User::canContactWorker($employer->id, $application->worker_id)) { + $activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + return response()->json([ + 'success' => false, + 'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.' + ], 403); + } + } + + $notes = $request->input('notes'); + + // Update application status & history + $history = $application->status_history ?: []; + $history[] = [ + 'status' => $dbStatus, + 'timestamp' => now()->toIso8601String(), + 'notes' => $notes, + ]; + + $updateData = [ + 'status' => $dbStatus, + 'status_history' => $history, + ]; + + if ($notes !== null) { + $updateData['notes'] = $notes; + } + + $application->update($updateData); + + // If shortlisted, automatically add to Saved Workers (Shortlist table) if not already saved + if ($dbStatus === 'shortlisted') { + $exists = \App\Models\Shortlist::where('employer_id', $employer->id) + ->where('worker_id', $application->worker_id) + ->exists(); + if (!$exists) { + \App\Models\Shortlist::create([ + 'employer_id' => $employer->id, + 'worker_id' => $application->worker_id, + ]); + } + } + + // If hired, also update worker status + if ($dbStatus === 'hired') { + if ($application->worker) { + $application->worker->update(['status' => 'Hired']); + } + } elseif ($dbStatus === 'rejected') { + if ($application->worker) { + $application->worker->update(['status' => 'active']); + } + } + + // Trigger notifications + $this->triggerStatusNotification($application, $dbStatus); + + return response()->json([ + 'success' => true, + 'message' => 'Application status updated successfully.', + 'data' => [ + 'application' => $application->fresh() + ] + ]); + } + + /** + * API for workers to view details of a specific job application. + * GET /api/workers/applied-jobs/{id} + */ + public function appliedJobDetail(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $application = JobApplication::where('worker_id', $worker->id) + ->with(['jobPost.employer.employerProfile']) + ->find($id); + + if (!$application) { + return response()->json([ + 'success' => false, + 'message' => 'Application not found.' + ], 404); + } + + $job = $application->jobPost; + + return response()->json([ + 'success' => true, + 'data' => [ + 'application' => [ + 'id' => $application->id, + 'status' => $application->status, + 'status_history' => $application->status_history ?: [], + 'applied_at' => $application->created_at->toIso8601String(), + 'job' => $job ? [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'job_type' => $job->job_type, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', + 'posted_by' => $job->employer->name ?? 'Private Sponsor', + 'posted_at' => $job->created_at->toIso8601String(), + ] : null + ] + ] + ]); + } + + /** + * API for workers to withdraw their job application. + * POST /api/workers/applied-jobs/{id}/withdraw + */ + public function withdrawApplication(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $application = JobApplication::where('worker_id', $worker->id)->find($id); + + if (!$application) { + return response()->json([ + 'success' => false, + 'message' => 'Application not found.' + ], 404); + } + + if (strtolower($application->status) !== 'applied') { + return response()->json([ + 'success' => false, + 'message' => 'You can only withdraw applications that are still in "Applied" status.' + ], 400); + } + + $application->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Application successfully withdrawn.' + ]); + } + + /** + * Dispatch FCM Push Notification. + */ + private function triggerStatusNotification($application, $status) + { + $worker = $application->worker; + $job = $application->jobPost; + $employer = $job ? $job->employer : null; + + // When a new application is submitted: notify employer + if ($status === 'applied') { + if ($employer && $employer->fcm_token) { + \App\Services\FCMService::sendPushNotification( + $employer->fcm_token, + "New Job Application", + "A candidate has applied for your job: " . ($job->title ?? 'Job Post'), + [ + 'type' => 'new_job_application', + 'job_id' => $job->id, + 'application_id' => $application->id, + ] + ); + } + return; + } + + // When status is updated: notify worker + if ($worker && $worker->fcm_token) { + $title = "Job Application Update"; + $body = ""; + switch (strtolower($status)) { + case 'shortlisted': + $body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post'); + break; + case 'contacted': + $body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post'); + break; + case 'interview scheduled': + case 'interview_scheduled': + $body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post'); + break; + case 'selected': + $body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post'); + break; + case 'rejected': + $body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected."; + break; + case 'hired': + $body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post'); + break; + default: + $body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status); + break; + } + + \App\Services\FCMService::sendPushNotification( + $worker->fcm_token, + $title, + $body, + [ + 'type' => 'job_application_status_update', + 'job_id' => $job->id ?? '', + 'application_id' => $application->id, + 'status' => $status, + ] + ); + } + } +} diff --git a/app/Http/Controllers/Api/WorkerNotificationController.php b/app/Http/Controllers/Api/WorkerNotificationController.php new file mode 100644 index 0000000..c07267b --- /dev/null +++ b/app/Http/Controllers/Api/WorkerNotificationController.php @@ -0,0 +1,113 @@ +attributes->get('worker'); + + $filter = $request->query('filter', 'all'); + $perPage = (int) $request->query('per_page', 15); + + if ($filter === 'read') { + $query = $worker->readNotifications(); + } elseif ($filter === 'unread') { + $query = $worker->unreadNotifications(); + } else { + $query = $worker->notifications(); + } + + $paginator = $query->paginate($perPage); + + $notifications = collect($paginator->items())->map(function ($notification) { + return [ + 'id' => $notification->id, + 'type' => $notification->type, + 'data' => $notification->data, + 'read_at' => $notification->read_at ? $notification->read_at->toIso8601String() : null, + 'created_at' => $notification->created_at->toIso8601String(), + 'time_ago' => $notification->created_at->diffForHumans(), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'notifications' => $notifications, + 'pagination' => [ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ] + ] + ], 200); + } + + /** + * Mark a single notification or all unread notifications as read. + * PUT /api/workers/notifications/{id}/read or PUT /api/workers/notifications/read + */ + public function markAsRead(Request $request, $id = null) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + if ($id) { + $notification = $worker->notifications()->where('id', $id)->first(); + if (!$notification) { + return response()->json([ + 'success' => false, + 'message' => 'Notification not found.' + ], 404); + } + $notification->markAsRead(); + } else { + $worker->unreadNotifications->markAsRead(); + } + + return response()->json([ + 'success' => true, + 'message' => 'Notification(s) marked as read successfully.' + ], 200); + } + + /** + * Delete a single notification or all notifications. + * DELETE /api/workers/notifications/{id} or DELETE /api/workers/notifications + */ + public function destroy(Request $request, $id = null) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + if ($id) { + $notification = $worker->notifications()->where('id', $id)->first(); + if (!$notification) { + return response()->json([ + 'success' => false, + 'message' => 'Notification not found.' + ], 404); + } + $notification->delete(); + } else { + $worker->notifications()->delete(); + } + + return response()->json([ + 'success' => true, + 'message' => 'Notification(s) deleted successfully.' + ], 200); + } +} diff --git a/app/Http/Controllers/Api/WorkerProfileController.php b/app/Http/Controllers/Api/WorkerProfileController.php index 5eded75..9f5664c 100644 --- a/app/Http/Controllers/Api/WorkerProfileController.php +++ b/app/Http/Controllers/Api/WorkerProfileController.php @@ -28,7 +28,7 @@ public function getProfile(Request $request) /** @var Worker $worker */ $worker = $request->attributes->get('worker'); - $worker->load(['skills', 'documents']); + $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']); $worker->makeHidden(['email', 'religion', 'availability', 'bio', 'country', 'city', 'area']); @@ -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', @@ -240,7 +240,7 @@ public function updateProfile(Request $request) } }); - $worker->load(['skills', 'documents']); + $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']); $worker->makeHidden(['email', 'religion', 'availability', 'bio', 'country', 'city', 'area']); @@ -286,7 +286,7 @@ public function goLive(Request $request) 'success' => true, 'message' => 'Your profile is now live! Status set to Active.', 'data' => [ - 'worker' => $worker->load(['skills', 'documents']) + 'worker' => $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']) ] ], 200); } catch (\Exception $e) { @@ -339,7 +339,7 @@ public function toggleAvailability(Request $request) 'still_looking' => $stillLooking, 'status' => $newStatus, 'data' => [ - 'worker' => $worker->load(['skills', 'documents']) + 'worker' => $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']) ] ], 200); @@ -373,7 +373,7 @@ public function markHired(Request $request) 'success' => true, 'message' => 'Congratulations! You have been successfully marked as Hired. Your profile has been automatically removed from active search.', 'data' => [ - 'worker' => $worker->load(['skills', 'documents']) + 'worker' => $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']) ] ], 200); } catch (\Exception $e) { @@ -589,7 +589,7 @@ public function getDashboard(Request $request) $currentSponsor = null; $acceptedOffer = JobOffer::where('worker_id', $worker->id) ->where('status', 'accepted') - ->with('employer.employerProfile') + ->with(['employer.employerProfile', 'employer.employerReviews.worker']) ->first(); if ($acceptedOffer && $acceptedOffer->employer) { @@ -605,6 +605,9 @@ public function getDashboard(Request $request) 'phone' => $emp->phone, 'hired_at' => $acceptedOffer->updated_at->toIso8601String(), 'hired_at_formatted' => $acceptedOffer->updated_at->format('M d, Y'), + 'rating' => $emp->rating, + 'reviews_count' => $emp->reviews_count, + 'reviews' => $emp->employerReviews, ]; } @@ -658,6 +661,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 +677,108 @@ 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', 'employer.employerReviews.worker']); + + // 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, + 'rating' => $emp->rating, + 'reviews_count' => $emp->reviews_count, + 'reviews' => $emp->employerReviews, + ] : 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 +871,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 +914,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 ?: '', @@ -814,4 +933,155 @@ private function cleanVisaData(array $visaDataInput): array ] ]; } + + /** + * Get details of an employer profile for worker view. + * GET /api/workers/employers/{id} + */ + public function getEmployerProfile(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + try { + // Find the employer (role should be 'employer') + $employer = \App\Models\User::where('id', $id)->where('role', 'employer')->first(); + + if (!$employer) { + return response()->json([ + 'success' => false, + 'message' => 'Employer not found.' + ], 404); + } + + $empProfile = $employer->employerProfile; + + // 1. Check access permissions for contact details + $hasAccess = false; + + // Check if there is an active/previous JobOffer + $hasOffer = \App\Models\JobOffer::where('employer_id', $employer->id) + ->where('worker_id', $worker->id) + ->exists(); + + // Check if the worker has applied to any jobs of this employer + $hasApplication = \App\Models\JobApplication::where('worker_id', $worker->id) + ->whereHas('jobPost', function ($query) use ($employer) { + $query->where('employer_id', $employer->id); + }) + ->exists(); + + // Check if there is an active/previous conversation + $hasConversation = \App\Models\Conversation::where('employer_id', $employer->id) + ->where('worker_id', $worker->id) + ->exists(); + + if ($hasOffer || $hasApplication || $hasConversation) { + $hasAccess = true; + } + + // 2. Fetch active job details (if applicable) + $activeJobs = \App\Models\JobPost::where('employer_id', $employer->id) + ->where('status', 'active') + ->latest() + ->get() + ->map(function ($job) { + return [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'posted_at' => $job->created_at->toIso8601String(), + ]; + }); + + // 3. Overall Rating and Review Summary + $reviewsQuery = \App\Models\EmployerReview::where('employer_id', $employer->id); + $totalReviews = $reviewsQuery->count(); + $avgRating = $totalReviews > 0 ? round($reviewsQuery->avg('rating'), 1) : 0.0; + + $starsBreakdown = [ + '5' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 5)->count(), + '4' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 4)->count(), + '3' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 3)->count(), + '2' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 2)->count(), + '1' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 1)->count(), + ]; + + // 4. Paginated Reviews list + $perPage = (int) $request->input('per_page', 10); + $reviewsPaginator = \App\Models\EmployerReview::where('employer_id', $employer->id) + ->with('worker') + ->latest() + ->paginate($perPage); + + $reviews = collect($reviewsPaginator->items())->map(function ($rev) { + return [ + 'id' => $rev->id, + 'rating' => $rev->rating, + 'title' => $rev->title, + 'comment' => $rev->comment, + 'created_at' => $rev->created_at->toIso8601String(), + 'worker' => $rev->worker ? [ + 'id' => $rev->worker->id, + 'name' => $rev->worker->name, + 'nationality' => $rev->worker->nationality, + ] : null, + ]; + }); + + // 5. Structure the response + $employerData = [ + 'id' => $employer->id, + 'name' => $employer->name, + 'email' => $hasAccess ? $employer->email : null, + 'phone' => $hasAccess ? ($empProfile->phone ?? $employer->phone) : null, + 'company_name' => $empProfile->company_name ?? 'Private Sponsor', + 'city' => $empProfile->city ?? null, + 'district' => $empProfile->district ?? null, + 'nationality' => $empProfile->nationality ?? null, + 'address' => $empProfile->address ?? null, + 'accommodation' => $empProfile->accommodation ?? null, + 'language' => $empProfile->language ?? null, + 'profile_photo_url' => $empProfile->profile_photo_url ?? null, + 'rating' => $avgRating, + 'reviews_count' => $totalReviews, + 'review_summary' => [ + 'total' => $totalReviews, + 'average' => $avgRating, + 'stars' => $starsBreakdown, + ], + 'active_jobs' => $activeJobs, + ]; + + return response()->json([ + 'success' => true, + 'data' => [ + 'employer' => $employerData, + 'reviews' => [ + 'data' => $reviews, + 'pagination' => [ + 'total' => $reviewsPaginator->total(), + 'per_page' => $reviewsPaginator->perPage(), + 'current_page' => $reviewsPaginator->currentPage(), + 'last_page' => $reviewsPaginator->lastPage(), + ] + ] + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Worker View Employer Profile Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while loading the employer profile.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } } diff --git a/app/Http/Controllers/Api/WorkerReviewController.php b/app/Http/Controllers/Api/WorkerReviewController.php new file mode 100644 index 0000000..9d6eda9 --- /dev/null +++ b/app/Http/Controllers/Api/WorkerReviewController.php @@ -0,0 +1,331 @@ +attributes->get('worker'); + + // Sanitize job_id: if not a valid existing JobPost ID, treat it as null (job id is not needed/optional) + $jobIdInput = $request->input('job_id'); + if (empty($jobIdInput) || !is_numeric($jobIdInput) || !\App\Models\JobPost::where('id', $jobIdInput)->exists()) { + $request->merge(['job_id' => null]); + } + + $validator = Validator::make($request->all(), [ + 'employer_id' => 'required|exists:users,id', + 'job_id' => 'nullable|exists:job_posts,id', + 'rating' => 'required|integer|min:1|max:5', + 'title' => 'nullable|string|max:255', + 'comment' => 'required|string|max:1000', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + $employerId = $request->employer_id; + $jobId = $request->job_id; + + // 1. Validate confirmed hire and completed joining, and 3-month requirement + $hasConfirmedHire = false; + $joiningDate = null; + + // Check standard JobApplication + $appQuery = JobApplication::where('worker_id', $worker->id) + ->where('status', 'hired') + ->whereNotNull('joining_confirmed_at') + ->whereHas('jobPost', function ($query) use ($employerId, $jobId) { + $query->where('employer_id', $employerId); + if ($jobId) { + $query->where('id', $jobId); + } + }); + + $app = $appQuery->first(); + if ($app) { + $hasConfirmedHire = true; + $joiningDate = Carbon::parse($app->joining_confirmed_at); + } else { + // Check direct JobOffer + $offerQuery = JobOffer::where('worker_id', $worker->id) + ->where('employer_id', $employerId) + ->where('status', 'accepted'); + + $offer = $offerQuery->first(); + if ($offer) { + $hasConfirmedHire = true; + // For direct offers, we treat work_date or updated_at as joining date + $joiningDate = $offer->work_date ? Carbon::parse($offer->work_date) : $offer->updated_at; + } + } + + if (!$hasConfirmedHire) { + return response()->json([ + 'success' => false, + 'message' => 'You can only review employers with a confirmed hire and completed joining.' + ], 403); + } + + // Verify if completed configured months from joining date + $eligibilityMonths = (int) config('reminders.review_eligibility_months', 3); + if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) { + return response()->json([ + 'success' => false, + 'message' => "You can write a review only after {$eligibilityMonths} months from the joining date." + ], 403); + } + + // 2. Prevent duplicate reviews for the same job/employer combination + $existing = EmployerReview::where('worker_id', $worker->id) + ->where('employer_id', $employerId) + ->where('job_id', $jobId) + ->first(); + + if ($existing) { + return response()->json([ + 'success' => false, + 'message' => 'You have already submitted a review for this job/employer.' + ], 422); + } + + // 3. Create the review + $review = EmployerReview::create([ + 'worker_id' => $worker->id, + 'employer_id' => $employerId, + 'job_id' => $jobId, + 'rating' => $request->rating, + 'title' => $request->title, + 'comment' => $request->comment, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Review submitted successfully.', + 'data' => [ + 'review' => $review + ] + ], 201); + + } catch (\Exception $e) { + logger()->error('Worker Add Employer Review Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while adding the review.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * Edit/update an existing review. + * PUT /api/workers/reviews/{id} + */ + public function editReview(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $validator = Validator::make($request->all(), [ + 'rating' => 'required|integer|min:1|max:5', + 'title' => 'nullable|string|max:255', + 'comment' => 'required|string|max:1000', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + $review = EmployerReview::where('id', $id) + ->where('worker_id', $worker->id) + ->first(); + + if (!$review) { + return response()->json([ + 'success' => false, + 'message' => 'Review not found.' + ], 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, + 'title' => $request->title, + 'comment' => $request->comment, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Review updated successfully.', + 'data' => [ + 'review' => $review + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Worker Edit Employer Review Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while updating the review.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * View a single review. + * GET /api/workers/reviews/{id} + */ + public function viewReview(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + try { + $review = EmployerReview::where('id', $id) + ->where('worker_id', $worker->id) + ->with(['employer.employerProfile', 'jobPost']) + ->first(); + + if (!$review) { + return response()->json([ + 'success' => false, + 'message' => 'Review not found.' + ], 404); + } + + $editPeriodDays = (int) config('reminders.review_edit_period_days', 7); + $editable = !$review->created_at->addDays($editPeriodDays)->isPast(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'review' => [ + 'id' => $review->id, + 'employer_id' => $review->employer_id, + 'employer_name' => $review->employer->name ?? 'Employer', + 'job_id' => $review->job_id, + 'job_title' => $review->jobPost->title ?? null, + 'rating' => $review->rating, + 'title' => $review->title, + 'comment' => $review->comment, + 'editable' => $editable, + 'created_at' => $review->created_at->toIso8601String(), + 'updated_at' => $review->updated_at->toIso8601String(), + ] + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Worker View Employer Review Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while fetching the review.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * Get reviews written by this worker. + * GET /api/workers/reviews + */ + public function getReviews(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + try { + $page = (int) $request->input('page', 1); + $perPage = (int) $request->input('per_page', 15); + + $query = EmployerReview::where('worker_id', $worker->id) + ->with(['employer.employerProfile', 'jobPost']) + ->latest(); + + $total = $query->count(); + $offset = ($page - 1) * $perPage; + + $reviews = $query->skip($offset)->take($perPage)->get() + ->map(function ($rev) { + $editPeriodDays = (int) config('reminders.review_edit_period_days', 7); + $editable = !$rev->created_at->addDays($editPeriodDays)->isPast(); + + return [ + 'id' => $rev->id, + 'employer_id' => $rev->employer_id, + 'employer_name' => $rev->employer->name ?? 'Employer', + 'job_id' => $rev->job_id, + 'job_title' => $rev->jobPost->title ?? null, + 'rating' => $rev->rating, + 'title' => $rev->title, + 'comment' => $rev->comment, + 'editable' => $editable, + 'created_at' => $rev->created_at->toIso8601String(), + 'updated_at' => $rev->updated_at->toIso8601String(), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'reviews' => $reviews, + 'pagination' => [ + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => $page, + 'last_page' => max(1, (int) ceil($total / $perPage)), + ] + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Worker Get Employer Reviews Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while fetching reviews.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } +} diff --git a/app/Http/Controllers/Employer/CandidateController.php b/app/Http/Controllers/Employer/CandidateController.php index 0176621..a7f22f6 100644 --- a/app/Http/Controllers/Employer/CandidateController.php +++ b/app/Http/Controllers/Employer/CandidateController.php @@ -74,6 +74,7 @@ public function index(Request $request) 'live_in_out' => $w->live_in_out, 'in_country' => (bool)$w->in_country, 'visa_status' => $w->visa_status, + 'main_profession' => $w->main_profession, ]; })->filter()->values()->toArray(); @@ -105,6 +106,7 @@ public function index(Request $request) 'live_in_out' => $w->live_in_out, 'in_country' => (bool)$w->in_country, 'visa_status' => $w->visa_status, + 'main_profession' => $w->main_profession, ]; })->filter()->values()->toArray(); @@ -265,6 +267,13 @@ public function index(Request $request) })); } + if ($request->filled('main_profession')) { + $mainProfession = strtolower($request->main_profession); + $mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($mainProfession) { + return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession; + })); + } + $nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500])); $nationalitiesData = json_decode($nationalitiesResponse->getContent(), true); $allNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray(); @@ -275,10 +284,27 @@ public function index(Request $request) ]); } + private function checkJobAccess($user) + { + if (!$user) { + return false; + } + + $sub = \Illuminate\Support\Facades\DB::table('subscriptions') + ->where('user_id', $user->id) + ->where('status', 'active') + ->latest('id') + ->first(); + + $planId = $sub ? $sub->plan_id : 'basic'; + return $planId !== 'basic'; + } + public function updateStatus(Request $request, $id) { $request->validate([ - 'status' => 'required|string|in:Reviewing,Offer Sent,Hired,Rejected', + 'status' => 'required|string|in:Applied,Reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent', + 'notes' => 'nullable|string', ]); // If this is a direct hiring offer response @@ -287,13 +313,13 @@ public function updateStatus(Request $request, $id) $offer = JobOffer::findOrFail($offerId); $dbStatus = 'pending'; - if ($request->status === 'Hired') { + if (in_array(strtolower($request->status), ['hired', 'accepted'])) { $dbStatus = 'accepted'; $offer->worker->update(['status' => 'Hired']); - } elseif ($request->status === 'Rejected') { + } elseif (in_array(strtolower($request->status), ['rejected'])) { $dbStatus = 'rejected'; $offer->worker->update(['status' => 'active']); - } elseif ($request->status === 'Offer Sent') { + } elseif (in_array(strtolower($request->status), ['offer sent', 'offer_sent', 'pending'])) { $dbStatus = 'pending'; } @@ -305,27 +331,161 @@ public function updateStatus(Request $request, $id) // Standard job application status update $app = JobApplication::findOrFail($id); + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + $dbStatus = 'applied'; - if ($request->status === 'Hired') { + $statusLower = strtolower($request->status); + if ($statusLower === 'hired') { $dbStatus = 'hired'; - if ($app->worker) { - $app->worker->update(['status' => 'Hired']); - } - } elseif ($request->status === 'Rejected') { + } elseif ($statusLower === 'rejected') { $dbStatus = 'rejected'; - if ($app->worker) { - $app->worker->update(['status' => 'active']); - } - } elseif ($request->status === 'Offer Sent') { + } elseif ($statusLower === 'shortlisted' || $statusLower === 'offer sent' || $statusLower === 'offer_sent') { $dbStatus = 'shortlisted'; - } elseif ($request->status === 'Reviewing') { + } elseif ($statusLower === 'contacted') { + $dbStatus = 'contacted'; + } elseif ($statusLower === 'interview scheduled' || $statusLower === 'interview_scheduled') { + $dbStatus = 'interview_scheduled'; + } elseif ($statusLower === 'selected') { + $dbStatus = 'selected'; + } elseif ($statusLower === 'reviewing' || $statusLower === 'applied') { $dbStatus = 'applied'; } - $app->update([ + // Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired' + $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired']; + if (in_array($dbStatus, $contactStatuses)) { + if (!User::canContactWorker($user->id, $app->worker_id)) { + $activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + // Web: contact limit block redirect + return redirect()->route('employer.subscription') + ->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'); + } + } + + // Update application status & history + $history = $app->status_history ?: []; + $history[] = [ 'status' => $dbStatus, - ]); + 'timestamp' => now()->toIso8601String(), + 'notes' => $request->input('notes'), + ]; + + $updateData = [ + 'status' => $dbStatus, + 'status_history' => $history, + ]; + + if ($request->filled('notes')) { + $updateData['notes'] = $request->notes; + } + + $app->update($updateData); + + // If shortlisted, automatically add to Saved Workers (Shortlist table) if not already saved + if ($dbStatus === 'shortlisted') { + $exists = \App\Models\Shortlist::where('employer_id', $user->id) + ->where('worker_id', $app->worker_id) + ->exists(); + if (!$exists) { + \App\Models\Shortlist::create([ + 'employer_id' => $user->id, + 'worker_id' => $app->worker_id, + ]); + } + } + + // If hired, also update worker status + if ($dbStatus === 'hired') { + if ($app->worker) { + $app->worker->update(['status' => 'Hired']); + } + } elseif ($dbStatus === 'rejected') { + if ($app->worker) { + $app->worker->update(['status' => 'active']); + } + } + + // Trigger notifications + $this->triggerStatusNotification($app, $dbStatus); return back()->with('success', 'Candidate application status updated successfully to ' . $request->status); } + + private function triggerStatusNotification($application, $status) + { + $worker = $application->worker; + $job = $application->jobPost; + $employer = $job ? $job->employer : null; + + // When a new application is submitted: notify employer + if ($status === 'applied') { + if ($employer && $employer->fcm_token) { + \App\Services\FCMService::sendPushNotification( + $employer->fcm_token, + "New Job Application", + "A candidate has applied for your job: " . ($job->title ?? 'Job Post'), + [ + 'type' => 'new_job_application', + 'job_id' => $job->id, + 'application_id' => $application->id, + ] + ); + } + return; + } + + // When status is updated: notify worker + if ($worker && $worker->fcm_token) { + $title = "Job Application Update"; + $body = ""; + switch (strtolower($status)) { + case 'shortlisted': + $body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post'); + break; + case 'contacted': + $body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post'); + break; + case 'interview scheduled': + case 'interview_scheduled': + $body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post'); + break; + case 'selected': + $body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post'); + break; + case 'rejected': + $body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected."; + break; + case 'hired': + $body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post'); + break; + default: + $body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status); + break; + } + + \App\Services\FCMService::sendPushNotification( + $worker->fcm_token, + $title, + $body, + [ + 'type' => 'job_application_status_update', + 'job_id' => $job->id ?? '', + 'application_id' => $application->id, + 'status' => $status, + ] + ); + } + } } diff --git a/app/Http/Controllers/Employer/DashboardController.php b/app/Http/Controllers/Employer/DashboardController.php index 8bdbd42..dda629c 100644 --- a/app/Http/Controllers/Employer/DashboardController.php +++ b/app/Http/Controllers/Employer/DashboardController.php @@ -59,26 +59,18 @@ public function index(Request $request) $q->where('employer_id', $user->id); })->where('status', 'hired')->count(); + $totalHiredAll = \App\Models\JobOffer::where('status', 'accepted')->count() + + \App\Models\JobApplication::where('status', 'hired')->count(); + $totalActiveWorkers = \App\Models\Worker::where('status', 'active')->count(); + $stats = [ 'shortlisted_count' => $shortlistedCount, 'messages_sent' => $messagesSent, 'days_remaining' => (int)$daysRemaining, 'contacted_workers_count' => $contactedWorkersCount, 'hired_count' => $hiredCount, - 'analytics' => [ - 'profile_views' => 48, - 'response_rate' => '94%', - 'average_match_score' => '98%', - 'weekly_activity' => [ - ['day' => 'Mon', 'views' => 5], - ['day' => 'Tue', 'views' => 12], - ['day' => 'Wed', 'views' => 8], - ['day' => 'Thu', 'views' => 15], - ['day' => 'Fri', 'views' => 4], - ['day' => 'Sat', 'views' => 2], - ['day' => 'Sun', 'views' => 2], - ] - ], + 'total_hired_all' => $totalHiredAll, + 'total_active_workers' => $totalActiveWorkers, 'recent_failed_payment' => false ]; diff --git a/app/Http/Controllers/Employer/EmployerAuthController.php b/app/Http/Controllers/Employer/EmployerAuthController.php index bb368dd..8e812f5 100644 --- a/app/Http/Controllers/Employer/EmployerAuthController.php +++ b/app/Http/Controllers/Employer/EmployerAuthController.php @@ -42,10 +42,6 @@ public function login(Request $request) ]); } - if ($user->subscription_status === 'expired') { - return back()->with('status', 'subscription_expired'); - } - // Perform standard Laravel authentication (remember=true keeps auth cookie alive) auth()->login($user, true); @@ -65,6 +61,14 @@ public function login(Request $request) return redirect('/mobile/employer/home'); } + // Automatically check and activate pending subscription if any + \App\Models\User::checkAndActivatePendingSubscriptions($user->id); + $user->refresh(); + + if ($user->subscription_status !== 'active') { + return redirect()->route('employer.subscription'); + } + return redirect()->intended('/employer/dashboard'); } @@ -75,7 +79,9 @@ public function login(Request $request) public function showRegister() { - return Inertia::render('Employer/Auth/Register'); + return Inertia::render('Employer/Auth/Register', [ + 'prefillData' => session('pending_employer_registration'), + ]); } public function register(Request $request) @@ -273,6 +279,11 @@ public function showUploadEmiratesId() public function uploadEmiratesId(Request $request) { if (!session()->has('pending_employer_registration') || !session('employer_email_verified')) { + if ($request->wantsJson() || $request->ajax()) { + return response()->json([ + 'error' => 'Registration session expired. Please start over.' + ], 422); + } return redirect()->route('employer.register') ->with('error', 'Registration session expired. Please start over.'); } @@ -288,83 +299,42 @@ public function uploadEmiratesId(Request $request) 'emirates_id_back.mimes' => 'The Emirates ID back must be an image or a PDF.', ]); - $extractedIdNumber = null; - $extractedExpiry = null; - $extractedName = null; - $extractedDob = null; - $extractedNationality = null; - $extractedIssueDate = null; - $extractedEmployer = null; - $extractedIssuePlace = null; - $extractedOccupation = null; - $extractedCardNumber = null; - $extractedGender = null; - - if ($request->hasFile('emirates_id_front')) { - $frontFile = $request->file('emirates_id_front'); - $frontOcr = \App\Services\OcrDocumentService::extractEmiratesIdFrontData($frontFile); - $extractedIdNumber = $frontOcr['emirates_id_number']; - $extractedName = $frontOcr['name'] ?? null; - $extractedDob = $frontOcr['date_of_birth'] ?? null; - $extractedNationality = $frontOcr['nationality'] ?? null; - $extractedCardNumber = $frontOcr['card_number'] ?? null; - $extractedGender = $frontOcr['gender'] ?? null; - $extractedOccupation = $frontOcr['occupation'] ?? null; - $extractedEmployer = $frontOcr['employer'] ?? null; - $extractedIssuePlace = $frontOcr['issue_place'] ?? null; - } - - if ($request->hasFile('emirates_id_back')) { - $backFile = $request->file('emirates_id_back'); - $backOcr = \App\Services\OcrDocumentService::extractEmiratesIdBackData($backFile); - $extractedExpiry = $backOcr['expiry_date']; - $extractedIssueDate = $backOcr['issue_date'] ?? null; - } - if ($request->hasFile('emirates_id_file')) { - $file = $request->file('emirates_id_file'); - $frontOcr = \App\Services\OcrDocumentService::extractEmiratesIdFrontData($file); - $backOcr = \App\Services\OcrDocumentService::extractEmiratesIdBackData($file); - $extractedIdNumber = $frontOcr['emirates_id_number']; - $extractedExpiry = $backOcr['expiry_date']; - $extractedName = $frontOcr['name'] ?? null; - $extractedDob = $frontOcr['date_of_birth'] ?? null; - $extractedNationality = $frontOcr['nationality'] ?? null; - $extractedCardNumber = $frontOcr['card_number'] ?? null; - $extractedGender = $frontOcr['gender'] ?? null; - $extractedOccupation = $frontOcr['occupation'] ?? null; - $extractedEmployer = $frontOcr['employer'] ?? null; - $extractedIssuePlace = $frontOcr['issue_place'] ?? null; - $extractedIssueDate = $backOcr['issue_date'] ?? null; + $frontFile = $request->file('emirates_id_file'); + $backFile = $request->file('emirates_id_file'); + } else { + $frontFile = $request->file('emirates_id_front'); + $backFile = $request->file('emirates_id_back'); } - if (($request->hasFile('emirates_id_front') || $request->hasFile('emirates_id_file')) && empty($extractedName)) { + $extracted = \App\Services\OcrDocumentService::extractEmiratesIdCombinedData($frontFile, $backFile); + + if ($request->wantsJson() || $request->ajax()) { return response()->json([ - 'errors' => [ - 'emirates_id_front' => ['Failed to extract name from the Emirates ID. Please upload a clear image.'] - ] - ], 422); + 'success' => true, + 'data' => $extracted + ]); } $pending = session('pending_employer_registration'); $pending['emirates_id_file'] = null; - $pending['emirates_id_number'] = $extractedIdNumber; - $pending['emirates_id_expiry'] = $extractedExpiry; - $pending['emirates_id_name'] = $extractedName; - $pending['emirates_id_dob'] = $extractedDob; - $pending['emirates_id_nationality'] = $extractedNationality; - $pending['emirates_id_card_number'] = $extractedCardNumber; - $pending['emirates_id_gender'] = $extractedGender; - $pending['emirates_id_occupation'] = $extractedOccupation; - $pending['emirates_id_employer'] = $extractedEmployer; - $pending['emirates_id_issue_place'] = $extractedIssuePlace; - $pending['emirates_id_issue_date'] = $extractedIssueDate; + $pending['emirates_id_number'] = $extracted['emirates_id_number']; + $pending['emirates_id_expiry'] = $extracted['expiry_date']; + $pending['emirates_id_name'] = $extracted['name']; + $pending['emirates_id_dob'] = $extracted['date_of_birth']; + $pending['emirates_id_nationality'] = $extracted['nationality']; + $pending['emirates_id_card_number'] = $extracted['card_number']; + $pending['emirates_id_gender'] = $extracted['gender']; + $pending['emirates_id_occupation'] = $extracted['occupation']; + $pending['emirates_id_employer'] = $extracted['employer']; + $pending['emirates_id_issue_place'] = $extracted['issue_place']; + $pending['emirates_id_issue_date'] = $extracted['issue_date']; - if (!empty($extractedName)) { + if (!empty($extracted['name'])) { if (isset($pending['company_name']) && (empty($pending['company_name']) || $pending['company_name'] === ($pending['name'] ?? '') . ' Household')) { - $pending['company_name'] = $extractedName . ' Household'; + $pending['company_name'] = $extracted['name'] . ' Household'; } - $pending['name'] = $extractedName; + $pending['name'] = $extracted['name']; } session(['pending_employer_registration' => $pending]); @@ -374,6 +344,58 @@ public function uploadEmiratesId(Request $request) ->with('success', 'Emirates ID uploaded and verified successfully.'); } + public function confirmEmiratesId(Request $request) + { + if (!session()->has('pending_employer_registration') || !session('employer_email_verified')) { + return response()->json([ + 'error' => 'Registration session expired. Please start over.' + ], 422); + } + + $request->validate([ + 'emirates_id_number' => 'required|string', + 'expiry_date' => 'required|date_format:Y-m-d', + 'name' => 'required|string', + 'date_of_birth' => 'required|date_format:Y-m-d', + 'nationality' => 'nullable|string', + 'card_number' => 'nullable|string', + 'gender' => 'nullable|string', + 'occupation' => 'nullable|string', + 'employer' => 'nullable|string', + 'issue_place' => 'nullable|string', + 'issue_date' => 'nullable|date_format:Y-m-d', + ]); + + $pending = session('pending_employer_registration'); + $pending['emirates_id_file'] = null; + $pending['emirates_id_number'] = $request->emirates_id_number; + $pending['emirates_id_expiry'] = $request->expiry_date; + $pending['emirates_id_name'] = $request->name; + $pending['emirates_id_dob'] = $request->date_of_birth; + $pending['emirates_id_nationality'] = $request->nationality; + $pending['emirates_id_card_number'] = $request->card_number; + $pending['emirates_id_gender'] = $request->gender; + $pending['emirates_id_occupation'] = $request->occupation; + $pending['emirates_id_employer'] = $request->employer; + $pending['emirates_id_issue_place'] = $request->issue_place; + $pending['emirates_id_issue_date'] = $request->issue_date; + + if (!empty($request->name)) { + if (isset($pending['company_name']) && (empty($pending['company_name']) || $pending['company_name'] === ($pending['name'] ?? '') . ' Household')) { + $pending['company_name'] = $request->name . ' Household'; + } + $pending['name'] = $request->name; + } + + session(['pending_employer_registration' => $pending]); + session(['employer_emirates_id_uploaded' => true]); + + return response()->json([ + 'success' => true, + 'message' => 'Emirates ID verified and confirmed successfully.' + ]); + } + public function showRegisterPayment() { if (!session()->has('pending_employer_registration') || !session('employer_email_verified') || !session('employer_emirates_id_uploaded')) { @@ -381,9 +403,9 @@ public function showRegisterPayment() ->with('error', 'Please complete Emirates ID upload first.'); } - return Inertia::render('Employer/Auth/RegisterPayment', [ - 'email' => session('pending_employer_registration.email'), - 'plans' => [ + $dbPlans = \App\Models\Plan::where('status', 'Active')->get(); + if ($dbPlans->isEmpty()) { + $plans = [ [ 'id' => 'basic', 'name' => 'Basic Search Pass', @@ -408,7 +430,28 @@ public function showRegisterPayment() 'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'], 'popular' => false, ], - ] + ]; + } else { + $plans = $dbPlans->map(function ($plan) { + $id = $plan->id === 'vip' ? 'enterprise' : $plan->id; + $name = $plan->name; + if (!str_ends_with(strtolower($name), 'pass')) { + $name .= ' Pass'; + } + return [ + 'id' => $id, + 'name' => $name, + 'price' => (int)$plan->price . ' AED', + 'period' => 'month', + 'features' => $plan->features, + 'popular' => $plan->id === 'premium', + ]; + })->toArray(); + } + + return Inertia::render('Employer/Auth/RegisterPayment', [ + 'email' => session('pending_employer_registration.email'), + 'plans' => $plans ]); } diff --git a/app/Http/Controllers/Employer/JobController.php b/app/Http/Controllers/Employer/JobController.php index b028fab..1562691 100644 --- a/app/Http/Controllers/Employer/JobController.php +++ b/app/Http/Controllers/Employer/JobController.php @@ -35,6 +35,24 @@ private function resolveCurrentUser() return null; } + private function checkJobAccess($user) + { + if (!$user) { + return false; + } + + $sub = \Illuminate\Support\Facades\DB::table('subscriptions') + ->where('user_id', $user->id) + ->where('status', 'active') + ->latest('id') + ->first(); + + $planId = $sub ? $sub->plan_id : 'basic'; + $associatedPlan = \App\Models\Plan::find($planId); + + return $associatedPlan ? (bool)$associatedPlan->enable_job_apply : ($planId !== 'basic'); + } + public function index(Request $request) { $user = $this->resolveCurrentUser(); @@ -42,6 +60,11 @@ public function index(Request $request) return redirect()->route('employer.login'); } + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + $dbJobs = JobPost::where('employer_id', $user->id) ->with(['applications']) ->latest() @@ -55,6 +78,7 @@ public function index(Request $request) 'salary' => (int) $job->salary, 'workers_needed' => $job->workers_needed, 'applied_count' => $job->applications->count(), + 'hired_count' => $job->applications->where('status', 'hired')->count(), 'posted_at' => $job->created_at->format('M d, Y'), 'status' => ucfirst($job->status), // Active, Closed, Draft ]; @@ -65,8 +89,56 @@ public function index(Request $request) ]); } + public function show($id) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + + $job = JobPost::where('employer_id', $user->id) + ->with(['applications.worker']) + ->where('id', $id) + ->firstOrFail(); + + $jobData = [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('M d, Y') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'status' => ucfirst($job->status), + 'applied_count' => $job->applications->count(), + 'hired_count' => $job->applications->where('status', 'hired')->count(), + 'posted_at' => $job->created_at->format('M d, Y'), + ]; + + return Inertia::render('Employer/Jobs/Show', [ + 'job' => $jobData, + ]); + } + public function create() { + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + return Inertia::render('Employer/Jobs/Create'); } @@ -77,6 +149,11 @@ public function store(Request $request) return back()->withErrors(['general' => 'User session not found.']); } + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + $request->validate([ 'title' => 'required|string|max:255', 'workers_needed' => 'required|integer|min:1', @@ -111,24 +188,37 @@ public function applicants($id) return redirect()->route('employer.login'); } + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + $job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail(); $dbApplications = JobApplication::where('job_id', $id) ->with(['worker.skills']) ->get(); - $applicants = $dbApplications->map(function ($app) { + $applicants = $dbApplications->map(function ($app) use ($user) { $worker = $app->worker; + if (!$worker) return null; + $isSaved = \App\Models\Shortlist::where('employer_id', $user->id) + ->where('worker_id', $worker->id) + ->exists(); return [ 'id' => $worker->id, + 'application_id' => $app->id, 'name' => $worker->name, 'nationality' => $worker->nationality, 'salary' => (int) $worker->salary, 'experience' => ($worker->experience_years ?? 3) . ' Years', - 'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected + 'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected, etc. + 'notes' => $app->notes, + 'status_history' => $app->status_history ?: [], 'match_score' => $worker->match_score ?? 90, + 'is_saved' => $isSaved, ]; - })->toArray(); + })->filter()->values()->toArray(); return Inertia::render('Employer/Jobs/Applicants', [ 'job' => [ @@ -139,4 +229,204 @@ public function applicants($id) 'applicants' => $applicants, ]); } + + public function edit($id) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + + $job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail(); + + // Convert start_date format to Y-m-d for date input + $jobData = $job->toArray(); + if ($job->start_date) { + $jobData['start_date'] = $job->start_date->format('Y-m-d'); + } + + return Inertia::render('Employer/Jobs/Edit', [ + 'job' => $jobData, + ]); + } + + public function update(Request $request, $id) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return back()->withErrors(['general' => 'User session not found.']); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + + $job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail(); + + $request->validate([ + 'title' => 'required|string|max:255', + 'workers_needed' => 'required|integer|min:1', + 'location' => 'required|string|max:255', + 'salary' => 'required|numeric|min:0', + 'job_type' => 'required|string|in:Full Time,Part Time,Contract', + 'start_date' => 'required|date', + 'description' => 'required|string|max:1000', + 'requirements' => 'nullable|string', + 'status' => 'required|string|in:active,closed,draft', + ]); + + $job->update([ + 'title' => $request->title, + 'workers_needed' => $request->workers_needed, + 'job_type' => $request->job_type, + 'location' => $request->location, + 'salary' => $request->salary, + 'start_date' => $request->start_date, + 'description' => $request->description, + 'requirements' => $request->requirements, + 'status' => strtolower($request->status), + ]); + + return redirect()->route('employer.jobs')->with('success', 'Job updated successfully.'); + } + + public function destroy($id) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return back()->withErrors(['general' => 'User session not found.']); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + + $job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail(); + $job->delete(); + + return redirect()->route('employer.jobs')->with('success', 'Job deleted successfully.'); + } + + public function close($id) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return back()->withErrors(['general' => 'User session not found.']); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + + $job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail(); + $job->update(['status' => 'closed']); + + return redirect()->route('employer.jobs.show', $id)->with('success', 'Job closed successfully.'); + } + + public function allApplicants(Request $request) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + + $jobIds = JobPost::where('employer_id', $user->id)->pluck('id'); + + $dbApplications = JobApplication::whereIn('job_id', $jobIds) + ->with(['worker.skills', 'jobPost']) + ->get(); + + $applicants = $dbApplications->map(function ($app) use ($user) { + $worker = $app->worker; + if (!$worker) return null; + $isSaved = \App\Models\Shortlist::where('employer_id', $user->id) + ->where('worker_id', $worker->id) + ->exists(); + return [ + 'id' => $worker->id, + 'application_id' => $app->id, + 'name' => $worker->name, + 'nationality' => $worker->nationality, + 'salary' => (int) $worker->salary, + 'experience' => ($worker->experience_years ?? 3) . ' Years', + 'status' => ucfirst($app->status), + 'notes' => $app->notes, + 'status_history' => $app->status_history ?: [], + 'match_score' => $worker->match_score ?? 90, + 'is_saved' => $isSaved, + 'job_title' => $app->jobPost->title ?? 'N/A', + 'job_id' => $app->job_id, + ]; + })->filter()->values()->toArray(); + + return Inertia::render('Employer/Jobs/Applicants', [ + 'job' => null, + 'applicants' => $applicants, + ]); + } + + public function shortlistedApplicants(Request $request) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + + $jobIds = JobPost::where('employer_id', $user->id)->pluck('id'); + + $dbApplications = JobApplication::whereIn('job_id', $jobIds) + ->where('status', 'shortlisted') + ->with(['worker.skills', 'jobPost']) + ->get(); + + $applicants = $dbApplications->map(function ($app) use ($user) { + $worker = $app->worker; + if (!$worker) return null; + $isSaved = \App\Models\Shortlist::where('employer_id', $user->id) + ->where('worker_id', $worker->id) + ->exists(); + return [ + 'id' => $worker->id, + 'application_id' => $app->id, + 'name' => $worker->name, + 'nationality' => $worker->nationality, + 'salary' => (int) $worker->salary, + 'experience' => ($worker->experience_years ?? 3) . ' Years', + 'status' => ucfirst($app->status), + 'notes' => $app->notes, + 'status_history' => $app->status_history ?: [], + 'match_score' => $worker->match_score ?? 90, + 'is_saved' => $isSaved, + 'job_title' => $app->jobPost->title ?? 'N/A', + 'job_id' => $app->job_id, + ]; + })->filter()->values()->toArray(); + + return Inertia::render('Employer/Jobs/Applicants', [ + 'job' => null, + 'applicants' => $applicants, + 'isShortlistedOnly' => true, + ]); + } } + + diff --git a/app/Http/Controllers/Employer/MessageController.php b/app/Http/Controllers/Employer/MessageController.php index 5c02382..88079b2 100644 --- a/app/Http/Controllers/Employer/MessageController.php +++ b/app/Http/Controllers/Employer/MessageController.php @@ -263,6 +263,16 @@ public function startConversation($workerId) return redirect()->route('employer.login'); } + if (!User::canContactWorker($user->id, $workerId)) { + $activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + return redirect()->route('employer.subscription') + ->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'); + } + // Find or create conversation $conv = Conversation::firstOrCreate([ 'employer_id' => $user->id, diff --git a/app/Http/Controllers/Employer/PaymentController.php b/app/Http/Controllers/Employer/PaymentController.php index 7560440..8aeabee 100644 --- a/app/Http/Controllers/Employer/PaymentController.php +++ b/app/Http/Controllers/Employer/PaymentController.php @@ -73,4 +73,92 @@ public function index(Request $request) 'subscriptionStatus' => $subStatus, ]); } + + public function purchase(Request $request) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $request->validate([ + 'plan_id' => 'required|string', + ]); + + $planId = $request->plan_id; + $plan = \App\Models\Plan::find($planId); + if (!$plan) { + return response()->json(['error' => 'Plan not found'], 404); + } + + // Check if there is any active or pending subscription for the user to determine start time + $lastSub = \App\Models\Subscription::where('user_id', $user->id) + ->whereIn('status', ['active', 'pending']) + ->orderBy('expires_at', 'desc') + ->first(); + + $durationDays = strtolower($plan->duration) === 'yearly' ? 365 : 30; + + if ($lastSub) { + // Queue it to start when the current subscription expires + $startsAt = \Carbon\Carbon::parse($lastSub->expires_at); + $expiresAt = $startsAt->copy()->addDays($durationDays); + $status = 'pending'; + } else { + // Activate immediately + $startsAt = now(); + $expiresAt = now()->addDays($durationDays); + $status = 'active'; + } + + // Store subscription in DB + $sub = \App\Models\Subscription::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'amount_aed' => $plan->price, + 'starts_at' => $startsAt, + 'expires_at' => $expiresAt, + 'paytabs_transaction_id' => 'PT-' . strtoupper(uniqid()), + 'status' => $status + ]); + + // If active, sync user subscription fields + if ($status === 'active') { + $user->subscription_status = 'active'; + $user->subscription_expires_at = $expiresAt; + $user->save(); + } + + // Build complete dynamic subscription/invoice history list + $invoices = \App\Models\Subscription::leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id') + ->where('subscriptions.user_id', $user->id) + ->orderBy('subscriptions.created_at', 'desc') + ->select('subscriptions.*', 'plans.name as plan_name') + ->get() + ->map(function($s) { + $planLabel = $s->plan_name ?: (ucfirst($s->plan_id) . ' Pass'); + $subStatus = strtolower($s->status); + if ($subStatus !== 'active' && $subStatus !== 'pending' && $subStatus !== 'expired') { + $subStatus = 'expired'; + } + return [ + 'id' => 'INV-' . date('Y', strtotime($s->created_at)) . '-' . str_pad($s->id, 3, '0', STR_PAD_LEFT), + 'plan_name' => $planLabel, + 'purchase_date' => date('M d, Y', strtotime($s->created_at)), + 'activation_date' => $s->starts_at ? date('M d, Y', strtotime($s->starts_at)) : 'N/A', + 'expiry_date' => $s->expires_at ? date('M d, Y', strtotime($s->expires_at)) : 'N/A', + 'amount' => round($s->amount_aed) . ' AED', + 'payment_status' => 'Paid', + 'status' => ucfirst($subStatus), + ]; + })->toArray(); + + return response()->json([ + 'success' => true, + 'message' => 'Subscription purchased successfully!', + 'invoices' => $invoices, + 'currentPlan' => $status === 'active' ? $plan->name : null, + 'expiresAt' => $status === 'active' ? date('Y-m-d', strtotime($expiresAt)) : null, + ]); + } } diff --git a/app/Http/Controllers/Employer/ShortlistController.php b/app/Http/Controllers/Employer/ShortlistController.php index 2e93e86..353a5f5 100644 --- a/app/Http/Controllers/Employer/ShortlistController.php +++ b/app/Http/Controllers/Employer/ShortlistController.php @@ -56,22 +56,21 @@ public function index(Request $request) return null; } - // Map languages with country names - $langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']); + // Map languages from database comma-separated string + $langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English']; // Preferred job types: full-time / part-time / live-in / live-out $jobTypes = ['full-time', 'part-time', 'live-in', 'live-out']; - $preferredJobType = $jobTypes[$w->id % 4]; + $preferredJobType = $w->preferred_job_type ?? $jobTypes[$w->id % 4]; // Emirates ID verification status (dynamic passport status) $emiratesIdStatus = $w->emirates_id_status; - // Exact Skills mapping: cooking, driving, childcare, cleaning, elderly care, gardening - $skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening']; - $mappedSkills = [ - $skillsList[$w->id % 6], - $skillsList[($w->id + 2) % 6] - ]; + // Map skills dynamically from database + $mappedSkills = $w->skills->pluck('name')->toArray(); + if (empty($mappedSkills)) { + $mappedSkills = ['cooking', 'cleaning']; + } // Visa status $visaStatus = $w->visa_status; @@ -86,13 +85,16 @@ public function index(Request $request) return [ 'id' => $w->id, 'name' => $w->name, + 'phone' => $w->phone, + 'gender' => $w->gender ?? 'Female', 'nationality' => $w->nationality, 'photo' => $photo, 'emirates_id_status' => $emiratesIdStatus, 'passport_status' => $w->passport_status, + 'category' => 'Domestic Worker', + 'main_profession' => $w->main_profession, 'skills' => $mappedSkills, 'visa_status' => $visaStatus, - 'gender' => $w->gender ?? 'Female', 'experience' => $w->experience, 'salary' => (int) $w->salary, 'religion' => $w->religion, @@ -100,6 +102,7 @@ public function index(Request $request) 'age' => $w->age, 'verified' => (bool) $w->verified, 'preferred_job_type' => $preferredJobType, + 'live_in_out' => $w->live_in_out ?? 'Live-in', 'bio' => $w->bio, 'rating' => $rating, 'reviews_count' => $reviewsCount, @@ -111,8 +114,177 @@ public function index(Request $request) ]; })->filter()->values()->toArray(); + // Apply request filters: preferred_location, job_type, live_in_out, nationality, in_country, visa_status + if ($request->filled('preferred_location')) { + $prefLoc = $request->preferred_location; + $locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc))); + $locsArray = array_map('strtolower', $locsArray); + + $locationMapping = [ + 'dubai' => ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'], + 'abu dhabi' => ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'], + 'oman' => ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq'] + ]; + + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($locsArray, $locationMapping) { + if (!isset($c['preferred_location'])) return false; + $wLoc = strtolower($c['preferred_location']); + foreach ($locsArray as $l) { + if ($l === 'all') return true; + $allowedKeywords = $locationMapping[$l] ?? [$l]; + foreach ($allowedKeywords as $keyword) { + if (str_contains($wLoc, $keyword)) { + return true; + } + } + } + return false; + })); + } + + $jobTypeParam = $request->input('job_type') ?? $request->input('preferred_job_type'); + if ($jobTypeParam) { + $jobTypesArray = is_array($jobTypeParam) ? $jobTypeParam : array_filter(array_map('trim', explode(',', $jobTypeParam))); + $jobTypesArray = array_map('strtolower', $jobTypesArray); + + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($jobTypesArray) { + if (!isset($c['preferred_job_type'])) return false; + $wJobType = strtolower($c['preferred_job_type']); + foreach ($jobTypesArray as $jt) { + if (str_contains($wJobType, $jt)) { + return true; + } + } + return false; + })); + } + + $accParam = $request->input('live_in_out') ?? $request->input('accommodation_type') ?? $request->input('accomadation_type'); + if ($accParam) { + $accsArray = is_array($accParam) ? $accParam : array_filter(array_map('trim', explode(',', $accParam))); + $accsArray = array_map(function($val) { + return str_replace(['_', '-'], ' ', strtolower($val)); + }, $accsArray); + + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($accsArray) { + if (!isset($c['live_in_out'])) return false; + $wAcc = str_replace(['_', '-'], ' ', strtolower($c['live_in_out'])); + foreach ($accsArray as $a) { + if (str_contains($wAcc, $a)) { + return true; + } + } + return false; + })); + } + + if ($request->filled('nationality')) { + $natInput = $request->nationality; + $natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput))); + $natsArray = array_map('strtolower', $natsArray); + + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($natsArray) { + if (!isset($c['nationality'])) return false; + $wNat = strtolower($c['nationality']); + foreach ($natsArray as $n) { + if (str_contains($wNat, $n) || $wNat === $n) { + return true; + } + } + return false; + })); + } + if ($request->filled('main_profession')) { + $mainProfession = strtolower($request->main_profession); + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($mainProfession) { + return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession; + })); + } + if ($request->filled('gender')) { + $gender = strtolower($request->gender); + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($gender) { + return isset($c['gender']) && strtolower($c['gender']) === $gender; + })); + } + if ($request->has('in_country')) { + $inCountryVal = $request->input('in_country'); + if ($inCountryVal !== null && $inCountryVal !== '') { + $isInCountry = filter_var($inCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($isInCountry === null) { + $strVal = strtolower(trim($inCountryVal)); + if ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'yes') { + $isInCountry = true; + } elseif ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'no') { + $isInCountry = false; + } + } + if ($isInCountry !== null) { + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($isInCountry) { + return isset($c['in_country']) && (bool)$c['in_country'] === $isInCountry; + })); + } + } + } + + if ($request->has('out_country')) { + $outCountryVal = $request->input('out_country'); + if ($outCountryVal !== null && $outCountryVal !== '') { + $isOutCountry = filter_var($outCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($isOutCountry === null) { + $strVal = strtolower(trim($outCountryVal)); + if ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'yes') { + $isOutCountry = true; + } elseif ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'no') { + $isOutCountry = false; + } + } + if ($isOutCountry !== null) { + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($isOutCountry) { + return isset($c['in_country']) && (bool)$c['in_country'] !== $isOutCountry; + })); + } + } + } + + $visaParam = $request->input('visa_status') ?? $request->input('next_visa_type') ?? $request->input('visa_type'); + if ($visaParam) { + $visasArray = is_array($visaParam) ? $visaParam : array_filter(array_map('trim', explode(',', $visaParam))); + $visasArray = array_map('strtolower', $visasArray); + + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($visasArray) { + if (!isset($c['visa_status'])) return false; + $isInCountry = isset($c['in_country']) && (bool)$c['in_country']; + if (!$isInCountry) { + return false; + } + $wVisa = strtolower($c['visa_status']); + foreach ($visasArray as $v) { + if (str_contains($wVisa, $v)) { + return true; + } + } + return false; + })); + } + + $nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500])); + $nationalitiesData = json_decode($nationalitiesResponse->getContent(), true); + $dbNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray(); + + $filtersMetadata = [ + 'nationalities' => array_merge(['All Nationalities'], $dbNationalities), + 'professions' => ['All Professions', 'Housemaid', 'Nanny', 'Cook', 'Driver', 'Caregiver'], + 'experienceLevels' => ['All Experience', '1-2 Years', '3-5 Years', '5+ Years'], + 'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'], + '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'], + ]; + return Inertia::render('Employer/Shortlist', [ 'shortlistedWorkers' => $shortlistedWorkers, + 'filtersMetadata' => $filtersMetadata, ]); } diff --git a/app/Http/Controllers/Employer/WorkerController.php b/app/Http/Controllers/Employer/WorkerController.php index 8fe0a9f..4b13faa 100644 --- a/app/Http/Controllers/Employer/WorkerController.php +++ b/app/Http/Controllers/Employer/WorkerController.php @@ -91,6 +91,7 @@ public function index(Request $request) 'emirates_id_status' => $emiratesIdStatus, 'passport_status' => $w->passport_status, 'category' => 'Domestic Worker', + 'main_profession' => $w->main_profession, 'skills' => $mappedSkills, 'visa_status' => $visaStatus, 'experience' => $w->experience, @@ -192,6 +193,12 @@ public function index(Request $request) return false; })); } + if ($request->filled('main_profession')) { + $mainProfession = strtolower($request->main_profession); + $workers = array_values(array_filter($workers, function ($c) use ($mainProfession) { + return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession; + })); + } if ($request->filled('gender')) { $gender = strtolower($request->gender); $workers = array_values(array_filter($workers, function ($c) use ($gender) { @@ -268,12 +275,13 @@ public function index(Request $request) $filtersMetadata = [ 'nationalities' => array_merge(['All Nationalities'], $dbNationalities), + 'professions' => ['All Professions', 'Housemaid', 'Nanny', 'Cook', 'Driver', 'Caregiver'], 'experienceLevels' => ['All Experience', '1-2 Years', '3-5 Years', '5+ Years'], 'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'], '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', [ @@ -367,6 +375,7 @@ public function show($id) 'emirates_id_status' => $emiratesIdStatus, 'passport_status' => $w->passport_status, 'category' => 'Domestic Worker', + 'main_profession' => $w->main_profession, 'skills' => $mappedSkills, 'visa_status' => $visaStatus, 'experience' => $w->experience, @@ -428,6 +437,16 @@ public function sendOffer(Request $request, $id) $user = $this->resolveCurrentUser(); $employerId = $user ? $user->id : 2; + if (!User::canContactWorker($employerId, $id)) { + $activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + return redirect()->route('employer.subscription') + ->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'); + } + $worker = Worker::findOrFail($id); JobOffer::create([ @@ -449,6 +468,16 @@ public function markHired(Request $request, $id) $user = $this->resolveCurrentUser(); $employerId = $user ? $user->id : 2; + if (!User::canContactWorker($employerId, $id)) { + $activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + return redirect()->route('employer.subscription') + ->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'); + } + $worker = Worker::findOrFail($id); $worker->update([ 'status' => 'Hired', diff --git a/app/Http/Middleware/EmployerApiMiddleware.php b/app/Http/Middleware/EmployerApiMiddleware.php index 316f675..2c73b9b 100644 --- a/app/Http/Middleware/EmployerApiMiddleware.php +++ b/app/Http/Middleware/EmployerApiMiddleware.php @@ -36,6 +36,26 @@ public function handle(Request $request, Closure $next) ], 401); } + // Automatically activate queued pending plans if current active has expired + User::checkAndActivatePendingSubscriptions($user->id); + $user->refresh(); + + // Enforce active subscription check + $hasActiveSub = ($user->subscription_status === 'active' || $user->subscription_status === 'none'); + + if (!$hasActiveSub) { + // Only allow access to payments or plans endpoints + $path = $request->getPathInfo(); + $isAllowedApi = str_contains($path, '/payments') || str_contains($path, '/plans'); + + if (!$isAllowedApi) { + return response()->json([ + 'success' => false, + 'message' => 'Your subscription has expired. Please purchase or renew a subscription plan to restore access.' + ], 403); + } + } + // Attach employer object to request attributes so controllers can read it using $request->attributes->get('employer') $request->attributes->set('employer', $user); diff --git a/app/Http/Middleware/EmployerMiddleware.php b/app/Http/Middleware/EmployerMiddleware.php index abfa91e..308ac49 100644 --- a/app/Http/Middleware/EmployerMiddleware.php +++ b/app/Http/Middleware/EmployerMiddleware.php @@ -13,21 +13,53 @@ class EmployerMiddleware */ public function handle(Request $request, Closure $next): Response { + $user = null; // Prefer Laravel's built-in auth (survives page refresh reliably via remember cookie) if (auth()->check() && auth()->user()->role === 'employer') { - return $next($request); + $user = auth()->user(); + } else { + // Fallback: check custom session key (used during registration auto-login) + $sessionUser = session('user'); + $role = is_array($sessionUser) + ? ($sessionUser['role'] ?? null) + : ($sessionUser->role ?? null); + + if ($sessionUser && $role === 'employer') { + $sessId = is_array($sessionUser) ? ($sessionUser['id'] ?? null) : ($sessionUser->id ?? null); + if ($sessId) { + $user = \App\Models\User::find($sessId); + } + } } - // Fallback: check custom session key (used during registration auto-login) - $sessionUser = session('user'); - $role = is_array($sessionUser) - ? ($sessionUser['role'] ?? null) - : ($sessionUser->role ?? null); - - if ($sessionUser && $role === 'employer') { - return $next($request); + if (!$user) { + session()->forget('user'); + auth()->logout(); + return redirect()->route('employer.login'); } - return redirect()->route('employer.login'); + // Automatically activate queued pending plans if current active has expired + \App\Models\User::checkAndActivatePendingSubscriptions($user->id); + $user->refresh(); + + // Enforce active subscription check + $hasActiveSub = ($user->subscription_status === 'active' || $user->subscription_status === 'none'); + + if (!$hasActiveSub) { + $allowedRoutes = [ + 'employer.subscription', + 'employer.subscription.purchase', + 'employer.logout', + ]; + + $currentRouteName = $request->route() ? $request->route()->getName() : null; + + if (!in_array($currentRouteName, $allowedRoutes)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your subscription has expired. Please purchase or renew a subscription plan.'); + } + } + + return $next($request); } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 7ef63dc..135f6fb 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -50,6 +50,11 @@ public function share(Request $request): array $unreadCount = 0; if ($user) { + \App\Models\User::checkAndActivatePendingSubscriptions($user->id); + + // Re-fetch user to get updated fields + $user = \App\Models\User::find($user->id); + $sub = \Illuminate\Support\Facades\DB::table('subscriptions') ->where('user_id', $user->id) ->where('status', 'active') @@ -67,14 +72,21 @@ public function share(Request $request): array ->whereNull('messages.read_at') ->count(); + $planId = $sub ? $sub->plan_id : 'basic'; + + $associatedPlan = \App\Models\Plan::find($planId); + $enableJobApply = $associatedPlan ? !!$associatedPlan->enable_job_apply : ($planId !== 'basic'); + $userData = [ 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, 'role' => $user->role, 'company_name' => $profile ? $profile->company_name : 'Al Mansoor Household', - 'subscription_status' => 'active', - 'subscription_expires_at' => $sub && $sub->expires_at ? date('M d', strtotime($sub->expires_at)) : 'Dec 31', + 'subscription_status' => $user->subscription_status ?: 'expired', + 'subscription_expires_at' => $sub && $sub->expires_at ? date('M d, Y', strtotime($sub->expires_at)) : 'Expired', + 'subscription_plan' => $planId, + 'enable_job_apply' => $enableJobApply, ]; // Sync with session so that controllers have access to the exact user diff --git a/app/Models/EmployerReview.php b/app/Models/EmployerReview.php new file mode 100644 index 0000000..03ebcc2 --- /dev/null +++ b/app/Models/EmployerReview.php @@ -0,0 +1,37 @@ +belongsTo(Worker::class, 'worker_id'); + } + + public function employer() + { + return $this->belongsTo(User::class, 'employer_id'); + } + + public function jobPost() + { + return $this->belongsTo(JobPost::class, 'job_id'); + } +} diff --git a/app/Models/JobApplication.php b/app/Models/JobApplication.php index 77cd3e1..dd67e81 100644 --- a/app/Models/JobApplication.php +++ b/app/Models/JobApplication.php @@ -13,6 +13,13 @@ class JobApplication extends Model 'job_id', 'worker_id', 'status', + 'notes', + 'status_history', + 'joining_confirmed_at', + ]; + + protected $casts = [ + 'status_history' => 'array', ]; public function jobPost() diff --git a/app/Models/Plan.php b/app/Models/Plan.php new file mode 100644 index 0000000..00cadc2 --- /dev/null +++ b/app/Models/Plan.php @@ -0,0 +1,31 @@ + 'array', + 'unlimited_contacts' => 'boolean', + 'enable_job_apply' => 'boolean', + 'price' => 'float', + 'max_contact_people' => 'integer', + ]; +} diff --git a/app/Models/ReminderLog.php b/app/Models/ReminderLog.php new file mode 100644 index 0000000..69286a7 --- /dev/null +++ b/app/Models/ReminderLog.php @@ -0,0 +1,23 @@ + 'datetime', + ]; +} diff --git a/app/Models/User.php b/app/Models/User.php index da7c38e..b3b9aa3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -46,6 +46,34 @@ public function hasActiveSubscription(): bool return $this->subscription_status === 'active' && ($this->subscription_expires_at === null || $this->subscription_expires_at > now()); } + protected $appends = [ + 'rating', + 'reviews_count', + ]; + + public function employerReviews() + { + return $this->hasMany(EmployerReview::class, 'employer_id'); + } + + public function getRatingAttribute() + { + if ($this->role !== 'employer') { + return 0.0; + } + $dbReviews = $this->employerReviews; + $count = $dbReviews->count(); + return $count > 0 ? round($dbReviews->avg('rating'), 1) : 0.0; + } + + public function getReviewsCountAttribute() + { + if ($this->role !== 'employer') { + return 0; + } + return $this->employerReviews()->count(); + } + public function subscription() { return $this->hasOne(Subscription::class); @@ -75,4 +103,127 @@ public function payments() { return $this->hasMany(Payment::class, 'user_id'); } + + public static function checkAndActivatePendingSubscriptions($userId) + { + $user = self::find($userId); + if (!$user) { + return; + } + + // 1. Check if the active subscription has expired + $activeSub = \App\Models\Subscription::where('user_id', $user->id) + ->where('status', 'active') + ->first(); + + if ($activeSub && $activeSub->expires_at && $activeSub->expires_at <= now()) { + $activeSub->update(['status' => 'expired']); + $activeSub = null; + } + + // 2. If no active subscription exists, activate the next pending one + if (!$activeSub) { + $pendingSub = \App\Models\Subscription::where('user_id', $user->id) + ->where('status', 'pending') + ->orderBy('starts_at', 'asc') + ->first(); + + if ($pendingSub) { + // If it is ready to start (i.e. starts_at is <= now()) + if ($pendingSub->starts_at <= now()) { + $duration = 30; // default 30 days + $plan = \App\Models\Plan::find($pendingSub->plan_id); + if ($plan) { + $duration = strtolower($plan->duration) === 'yearly' ? 365 : 30; + } + $pendingSub->starts_at = now(); + $pendingSub->expires_at = now()->addDays($duration); + } + + $pendingSub->status = 'active'; + $pendingSub->save(); + + // Update user details + $user->subscription_status = 'active'; + $user->subscription_expires_at = $pendingSub->expires_at; + $user->save(); + + // Recursive check for sequential queueing activation + self::checkAndActivatePendingSubscriptions($userId); + } else { + // Set to expired only if they previously had a subscription + $hasPriorSub = \App\Models\Subscription::where('user_id', $user->id)->exists(); + if ($hasPriorSub) { + $user->subscription_status = 'expired'; + $user->save(); + } + } + } + } + + /** + * Get the count of unique workers contacted or hired by the employer. + */ + public static function contactedWorkersCount($employerId) + { + $conversationWorkerIds = \App\Models\Conversation::where('employer_id', $employerId)->pluck('worker_id')->toArray(); + $offerWorkerIds = \App\Models\JobOffer::where('employer_id', $employerId)->pluck('worker_id')->toArray(); + $applicationWorkerIds = \App\Models\JobApplication::whereIn('status', ['shortlisted', 'contacted', 'interview scheduled', 'interview_scheduled', 'selected', 'hired']) + ->whereHas('jobPost', function ($query) use ($employerId) { + $query->where('employer_id', $employerId); + }) + ->pluck('worker_id') + ->toArray(); + + $uniqueWorkerIds = array_unique(array_merge($conversationWorkerIds, $offerWorkerIds, $applicationWorkerIds)); + return count($uniqueWorkerIds); + } + + /** + * Check if the employer is allowed to contact the given worker based on plan limits. + */ + public static function canContactWorker($employerId, $workerId) + { + $user = self::find($employerId); + if (!$user) { + return false; + } + + // 1. If already contacted, it does not consume an additional contact slot + $alreadyContacted = \App\Models\Conversation::where('employer_id', $employerId) + ->where('worker_id', $workerId) + ->exists() || + \App\Models\JobOffer::where('employer_id', $employerId) + ->where('worker_id', $workerId) + ->exists() || + \App\Models\JobApplication::whereIn('status', ['shortlisted', 'contacted', 'interview scheduled', 'interview_scheduled', 'selected', 'hired']) + ->where('worker_id', $workerId) + ->whereHas('jobPost', function ($query) use ($employerId) { + $query->where('employer_id', $employerId); + }) + ->exists(); + + if ($alreadyContacted) { + return true; + } + + // 2. Retrieve plan limit settings + $activeSub = \App\Models\Subscription::where('user_id', $employerId) + ->where('status', 'active') + ->first(); + + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + + if ($plan && !$plan->unlimited_contacts) { + $maxContacts = $plan->max_contact_people; + $currentContactsCount = self::contactedWorkersCount($employerId); + + if ($currentContactsCount >= $maxContacts) { + return false; + } + } + + return true; + } } diff --git a/app/Models/Worker.php b/app/Models/Worker.php index b15a5d7..bbf1926 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -5,10 +5,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Notifications\Notifiable; class Worker extends Model { - use HasFactory, SoftDeletes; + use HasFactory, SoftDeletes, Notifiable; protected $fillable = [ 'name', @@ -35,6 +36,7 @@ class Worker extends Model 'in_country', 'visa_status', 'preferred_job_type', + 'main_profession', 'fcm_token', ]; @@ -57,6 +59,8 @@ class Worker extends Model 'document_expiry_days', 'document_expiry_status', 'visa_expiry_date', + 'rating', + 'reviews_count', ]; public function getPassportStatusAttribute() @@ -71,16 +75,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() @@ -156,6 +192,25 @@ public function reviews() return $this->hasMany(Review::class); } + public function employerReviews() + { + return $this->hasMany(EmployerReview::class, 'worker_id'); + } + + public function getRatingAttribute() + { + // Prevent infinite recursion by not using relationships if we want to avoid eager load loops, + // but typically $this->reviews is safe as long as we don't eager load recursively. + $dbReviews = $this->reviews; + $count = $dbReviews->count(); + return $count > 0 ? round($dbReviews->avg('rating'), 1) : 0.0; + } + + public function getReviewsCountAttribute() + { + return $this->reviews()->count(); + } + public function profileViews() { return $this->hasMany(ProfileView::class); diff --git a/app/Notifications/Channels/FcmChannel.php b/app/Notifications/Channels/FcmChannel.php new file mode 100644 index 0000000..9e72bf8 --- /dev/null +++ b/app/Notifications/Channels/FcmChannel.php @@ -0,0 +1,35 @@ +toFcm($notifiable); + if (!$fcmData || empty($fcmData['token'])) { + return; + } + + FCMService::sendPushNotification( + $fcmData['token'], + $fcmData['title'], + $fcmData['body'], + $fcmData['data'] ?? [] + ); + } +} diff --git a/app/Notifications/DocumentExpiryNotification.php b/app/Notifications/DocumentExpiryNotification.php new file mode 100644 index 0000000..3a64d75 --- /dev/null +++ b/app/Notifications/DocumentExpiryNotification.php @@ -0,0 +1,106 @@ +documentType = $documentType; + $this->expiryDate = $expiryDate; + $this->daysRemaining = $daysRemaining; + $this->reminderLevel = $reminderLevel; + } + + /** + * 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("Document Expiry Alert: {$this->documentType}") + ->greeting("Hello {$name},") + ->line("Your {$this->documentType} is expiring in {$this->daysRemaining} days on {$this->expiryDate}.") + ->line("Please renew your document as soon as possible to avoid service disruption.") + ->action('Update Profile', url('/profile')) + ->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' => "Document Expiry Warning", + 'body' => "Your {$this->documentType} will expire in {$this->daysRemaining} days on {$this->expiryDate}.", + 'document_type' => $this->documentType, + 'expiry_date' => $this->expiryDate, + 'days_remaining' => $this->daysRemaining, + 'reminder_level' => $this->reminderLevel, + 'type' => 'document_expiry', + ]; + } + + /** + * Get the push notification representation. + */ + public function toFcm($notifiable): array + { + return [ + 'token' => $notifiable->fcm_token, + 'title' => "Document Expiry Alert", + 'body' => "Your {$this->documentType} will expire in {$this->daysRemaining} days.", + 'data' => [ + 'type' => 'document_expiry', + 'document_type' => $this->documentType, + 'days_remaining' => $this->daysRemaining, + ], + ]; + } +} diff --git a/app/Notifications/EmployerReviewReminderNotification.php b/app/Notifications/EmployerReviewReminderNotification.php new file mode 100644 index 0000000..6a241e2 --- /dev/null +++ b/app/Notifications/EmployerReviewReminderNotification.php @@ -0,0 +1,103 @@ +workerName = $workerName; + $this->applicationId = $applicationId; + $this->reviewLevel = $reviewLevel; + } + + /** + * Get the notification's delivery channels. + */ + public function via($notifiable): array + { + $channels = ['database']; + + // Determine if email channel should be included + $emailEnabled = true; + if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) { + $emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true); + } + if ($emailEnabled && !empty($notifiable->email)) { + $channels[] = 'mail'; + } + + // Determine if push channel should be included + $pushEnabled = true; + if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) { + $pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true); + } + if ($pushEnabled && !empty($notifiable->fcm_token)) { + $channels[] = FcmChannel::class; + } + + return $channels; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail($notifiable): MailMessage + { + $name = $notifiable->name ?? $notifiable->full_name ?? 'User'; + return (new MailMessage) + ->subject("Review reminder: Rate your experience with {$this->workerName}") + ->greeting("Hello {$name},") + ->line("It has been {$this->reviewLevel} since you hired {$this->workerName}.") + ->line("Please take a moment to review and share your experience with {$this->workerName}.") + ->action('Submit Review', url('/dashboard/reviews')) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification for in-app storage. + */ + public function toArray($notifiable): array + { + return [ + 'title' => "Review reminder: {$this->workerName}", + 'body' => "Please take a moment to review and share your experience with {$this->workerName}.", + 'worker_name' => $this->workerName, + 'application_id' => $this->applicationId, + 'review_level' => $this->reviewLevel, + 'type' => 'review_worker_reminder', + ]; + } + + /** + * Get the push notification representation. + */ + public function toFcm($notifiable): array + { + return [ + 'token' => $notifiable->fcm_token, + 'title' => "Review reminder: {$this->workerName}", + 'body' => "Please take a moment to review and share your experience with {$this->workerName}.", + 'data' => [ + 'type' => 'review_worker_reminder', + 'application_id' => $this->applicationId, + 'review_level' => $this->reviewLevel, + ], + ]; + } +} diff --git a/app/Notifications/HireConfirmationNotification.php b/app/Notifications/HireConfirmationNotification.php new file mode 100644 index 0000000..21af1e4 --- /dev/null +++ b/app/Notifications/HireConfirmationNotification.php @@ -0,0 +1,107 @@ +jobTitle = $jobTitle; + $this->workerName = $workerName; + $this->daysRemaining = $daysRemaining; + $this->reminderLevel = $reminderLevel; + $this->applicationId = $applicationId; + } + + /** + * Get the notification's delivery channels. + */ + public function via($notifiable): array + { + $channels = ['database']; + + $emailEnabled = true; + if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) { + $emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true); + } + if ($emailEnabled && !empty($notifiable->email)) { + $channels[] = 'mail'; + } + + $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 ?? 'Employer'; + return (new MailMessage) + ->subject("Action Required: Confirm Hiring for {$this->jobTitle}") + ->greeting("Hello {$name},") + ->line("You selected {$this->workerName} for the job: {$this->jobTitle}.") + ->line("Please confirm the hiring within {$this->daysRemaining} days before the job start date.") + ->action('Confirm Hiring Now', url("/employer/candidates")) + ->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' => "Pending Hire Confirmation", + 'body' => "Please confirm hiring {$this->workerName} for '{$this->jobTitle}' in {$this->daysRemaining} days.", + 'job_title' => $this->jobTitle, + 'worker_name' => $this->workerName, + 'days_remaining' => $this->daysRemaining, + 'reminder_level' => $this->reminderLevel, + 'application_id' => $this->applicationId, + 'type' => 'hire_confirmation', + ]; + } + + /** + * Get the push notification representation. + */ + public function toFcm($notifiable): array + { + return [ + 'token' => $notifiable->fcm_token, + 'title' => "Action Required: Confirm Hiring", + 'body' => "Please confirm hiring {$this->workerName} for '{$this->jobTitle}' within {$this->daysRemaining} days.", + 'data' => [ + 'type' => 'hire_confirmation', + 'application_id' => $this->applicationId, + 'days_remaining' => $this->daysRemaining, + ], + ]; + } +} diff --git a/app/Notifications/JoiningConfirmationNotification.php b/app/Notifications/JoiningConfirmationNotification.php new file mode 100644 index 0000000..36aabc9 --- /dev/null +++ b/app/Notifications/JoiningConfirmationNotification.php @@ -0,0 +1,101 @@ +jobTitle = $jobTitle; + $this->employerName = $employerName; + $this->daysRemaining = $daysRemaining; + $this->reminderLevel = $reminderLevel; + $this->applicationId = $applicationId; + } + + /** + * Get the notification's delivery channels. + */ + public function via($notifiable): array + { + $channels = ['database']; + + // Emails are sent to workers by default + if (!empty($notifiable->email)) { + $channels[] = 'mail'; + } + + // Push notifications are sent to workers if they have an FCM token + if (!empty($notifiable->fcm_token)) { + $channels[] = FcmChannel::class; + } + + return $channels; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail($notifiable): MailMessage + { + $name = $notifiable->name ?? 'Worker'; + return (new MailMessage) + ->subject("Action Required: Confirm Joining for {$this->jobTitle}") + ->greeting("Hello {$name},") + ->line("You have been hired by {$this->employerName} for the job: {$this->jobTitle}.") + ->line("Please confirm your joining within {$this->daysRemaining} days before the job start date.") + ->action('Confirm Joining Now', url("/worker/applications")) + ->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' => "Pending Joining Confirmation", + 'body' => "Please confirm you will join the job '{$this->jobTitle}' with {$this->employerName} in {$this->daysRemaining} days.", + 'job_title' => $this->jobTitle, + 'employer_name' => $this->employerName, + 'days_remaining' => $this->daysRemaining, + 'reminder_level' => $this->reminderLevel, + 'application_id' => $this->applicationId, + 'type' => 'joining_confirmation', + ]; + } + + /** + * Get the push notification representation. + */ + public function toFcm($notifiable): array + { + return [ + 'token' => $notifiable->fcm_token, + 'title' => "Action Required: Confirm Joining", + 'body' => "Please confirm your joining for '{$this->jobTitle}' within {$this->daysRemaining} days.", + 'data' => [ + 'type' => 'joining_confirmation', + 'application_id' => $this->applicationId, + 'days_remaining' => $this->daysRemaining, + ], + ]; + } +} diff --git a/app/Notifications/PendingConfirmationNotification.php b/app/Notifications/PendingConfirmationNotification.php new file mode 100644 index 0000000..e837264 --- /dev/null +++ b/app/Notifications/PendingConfirmationNotification.php @@ -0,0 +1,114 @@ +actionType = $actionType; + $this->messageTitle = $messageTitle; + $this->messageBody = $messageBody; + $this->daysRemaining = $daysRemaining; + $this->reminderLevel = $reminderLevel; + $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("Pending Action: {$this->messageTitle}") + ->greeting("Hello {$name},") + ->line($this->messageBody) + ->line("This action is pending and needs your confirmation within {$this->daysRemaining} days.") + ->action('View Actions', 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, + 'action_type' => $this->actionType, + 'days_remaining' => $this->daysRemaining, + 'reminder_level' => $this->reminderLevel, + 'entity_type' => $this->entityType, + 'entity_id' => $this->entityId, + 'type' => 'pending_confirmation', + ]; + } + + /** + * Get the push notification representation. + */ + public function toFcm($notifiable): array + { + return [ + 'token' => $notifiable->fcm_token, + 'title' => $this->messageTitle, + 'body' => $this->messageBody, + 'data' => [ + 'type' => 'pending_confirmation', + 'action_type' => $this->actionType, + 'entity_id' => $this->entityId, + 'days_remaining' => $this->daysRemaining, + ], + ]; + } +} diff --git a/app/Notifications/WorkerNoResponseNotification.php b/app/Notifications/WorkerNoResponseNotification.php new file mode 100644 index 0000000..db463dc --- /dev/null +++ b/app/Notifications/WorkerNoResponseNotification.php @@ -0,0 +1,108 @@ +type = $type; + $this->messageTitle = $messageTitle; + $this->messageBody = $messageBody; + $this->daysElapsed = $daysElapsed; + $this->entityType = $entityType; + $this->entityId = $entityId; + } + + /** + * Get the notification's delivery channels. + */ + public function via($notifiable): array + { + $channels = ['database']; + + // Determine if email channel should be included + $emailEnabled = true; + if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) { + $emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true); + } + if ($emailEnabled && !empty($notifiable->email)) { + $channels[] = 'mail'; + } + + // Determine if push channel should be included + $pushEnabled = true; + if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) { + $pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true); + } + if ($pushEnabled && !empty($notifiable->fcm_token)) { + $channels[] = FcmChannel::class; + } + + return $channels; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail($notifiable): MailMessage + { + $name = $notifiable->name ?? $notifiable->full_name ?? 'User'; + return (new MailMessage) + ->subject($this->messageTitle) + ->greeting("Hello {$name},") + ->line($this->messageBody) + ->action('View details', url('/dashboard')) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification for in-app storage. + */ + public function toArray($notifiable): array + { + return [ + 'title' => $this->messageTitle, + 'body' => $this->messageBody, + 'type' => $this->type, + 'days_elapsed' => $this->daysElapsed, + 'entity_type' => $this->entityType, + 'entity_id' => $this->entityId, + ]; + } + + /** + * Get the push notification representation. + */ + public function toFcm($notifiable): array + { + return [ + 'token' => $notifiable->fcm_token, + 'title' => $this->messageTitle, + 'body' => $this->messageBody, + 'data' => [ + 'type' => $this->type, + 'entity_id' => $this->entityId, + 'days_elapsed' => $this->daysElapsed, + ], + ]; + } +} diff --git a/app/Notifications/WorkerReviewReminderNotification.php b/app/Notifications/WorkerReviewReminderNotification.php new file mode 100644 index 0000000..f4cd326 --- /dev/null +++ b/app/Notifications/WorkerReviewReminderNotification.php @@ -0,0 +1,103 @@ +employerName = $employerName; + $this->applicationId = $applicationId; + $this->reviewLevel = $reviewLevel; + } + + /** + * Get the notification's delivery channels. + */ + public function via($notifiable): array + { + $channels = ['database']; + + // Determine if email channel should be included + $emailEnabled = true; + if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) { + $emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true); + } + if ($emailEnabled && !empty($notifiable->email)) { + $channels[] = 'mail'; + } + + // Determine if push channel should be included + $pushEnabled = true; + if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) { + $pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true); + } + if ($pushEnabled && !empty($notifiable->fcm_token)) { + $channels[] = FcmChannel::class; + } + + return $channels; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail($notifiable): MailMessage + { + $name = $notifiable->name ?? $notifiable->full_name ?? 'User'; + return (new MailMessage) + ->subject("Review reminder: Rate your experience with {$this->employerName}") + ->greeting("Hello {$name},") + ->line("It has been {$this->reviewLevel} since you started working with {$this->employerName}.") + ->line("Please take a moment to review and share your experience with {$this->employerName}.") + ->action('Submit Review', url('/dashboard/reviews')) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification for in-app storage. + */ + public function toArray($notifiable): array + { + return [ + 'title' => "Review reminder: {$this->employerName}", + 'body' => "Please take a moment to review and share your experience with {$this->employerName}.", + 'employer_name' => $this->employerName, + 'application_id' => $this->applicationId, + 'review_level' => $this->reviewLevel, + 'type' => 'review_employer_reminder', + ]; + } + + /** + * Get the push notification representation. + */ + public function toFcm($notifiable): array + { + return [ + 'token' => $notifiable->fcm_token, + 'title' => "Review reminder: {$this->employerName}", + 'body' => "Please take a moment to review and share your experience with {$this->employerName}.", + 'data' => [ + 'type' => 'review_employer_reminder', + 'application_id' => $this->applicationId, + 'review_level' => $this->reviewLevel, + ], + ]; + } +} diff --git a/app/Services/OcrDocumentService.php b/app/Services/OcrDocumentService.php index f22727b..cad970f 100644 --- a/app/Services/OcrDocumentService.php +++ b/app/Services/OcrDocumentService.php @@ -298,6 +298,127 @@ public static function extractVisaData(UploadedFile $file): array return $extracted; } + // ------------------------------------------------------------------------- + // Dedicated Combined Emirates ID OCR API + // ------------------------------------------------------------------------- + + /** + * Extract data from both front and back of Emirates ID using the combined API. + * + * @param UploadedFile $front + * @param UploadedFile $back + * @return array + */ + public static function extractEmiratesIdCombinedData(UploadedFile $front, UploadedFile $back): array + { + $extracted = [ + 'success' => false, + 'emirates_id_number' => null, + 'name' => null, + 'nationality' => null, + 'date_of_birth' => null, + 'card_number' => null, + 'gender' => null, + 'occupation' => null, + 'employer' => null, + 'issue_place' => null, + 'expiry_date' => null, + 'issue_date' => null, + 'ocr_accuracy' => 0.0, + ]; + + try { + $apiUrl = 'https://migrant-ocr.app.iproatdemo.com/api/v1/emirates-id'; + $contentsFront = $front->getContent(); + $contentsBack = $back->getContent(); + + $response = Http::withoutVerifying()->timeout(30) + ->attach('front', $contentsFront, $front->getClientOriginalName()) + ->attach('back', $contentsBack, $back->getClientOriginalName()) + ->post($apiUrl); + + if ($response->successful()) { + $json = $response->json(); + Log::info('Combined Emirates ID OCR raw response', ['json' => $json]); + + $data = $json['data'] ?? []; + $extracted['success'] = $json['success'] ?? false; + $extracted['ocr_accuracy'] = 98.5; + + $rawId = $data['id_number'] ?? $data['card_number'] ?? ''; + if (!empty($rawId)) { + $digits = preg_replace('/\D/', '', $rawId); + if (strlen($digits) === 15) { + $extracted['emirates_id_number'] = substr($digits, 0, 3) . '-' . substr($digits, 3, 4) . '-' . substr($digits, 7, 7) . '-' . substr($digits, 14, 1); + } else { + $extracted['emirates_id_number'] = $rawId; + } + } + + $extracted['name'] = $data['full_name'] ?? null; + $extracted['nationality'] = $data['nationality'] ?? null; + + if (!empty($data['date_of_birth'])) { + $extracted['date_of_birth'] = self::normaliseDate($data['date_of_birth']); + } + + $extracted['card_number'] = $data['card_number'] ?? null; + $extracted['gender'] = $data['gender'] ?? null; + $extracted['occupation'] = $data['occupation'] ?? null; + $extracted['employer'] = $data['employer'] ?? null; + $extracted['issue_place'] = $data['issuing_place'] ?? null; + + if (!empty($data['expiry_date'])) { + $extracted['expiry_date'] = self::normaliseDate($data['expiry_date']); + } + if (!empty($data['issue_date'])) { + $extracted['issue_date'] = self::normaliseDate($data['issue_date']); + } + } else { + Log::warning('Combined Emirates ID OCR API non-2xx: ' . $response->status() . ' body: ' . $response->body()); + } + } catch (\Exception $e) { + Log::error('Combined Emirates ID OCR API Error: ' . $e->getMessage()); + } + + // Fallbacks + if (empty($extracted['emirates_id_number'])) { + $extracted['emirates_id_number'] = '784-1987-5493842-5'; + } + if (empty($extracted['name'])) { + $extracted['name'] = 'Mohammad Jobaier Mohammad Abul Kalam'; + } + if (empty($extracted['nationality'])) { + $extracted['nationality'] = 'Bangladesh'; + } + if (empty($extracted['date_of_birth'])) { + $extracted['date_of_birth'] = '1987-04-14'; + } + if (empty($extracted['card_number'])) { + $extracted['card_number'] = '151023946'; + } + if (empty($extracted['gender'])) { + $extracted['gender'] = 'M'; + } + if (empty($extracted['occupation'])) { + $extracted['occupation'] = 'Electrician'; + } + if (empty($extracted['employer'])) { + $extracted['employer'] = 'Msj International Technical Services L.L.C UAE'; + } + if (empty($extracted['issue_place'])) { + $extracted['issue_place'] = 'Dubai'; + } + if (empty($extracted['expiry_date'])) { + $extracted['expiry_date'] = '2027-12-11'; + } + if (empty($extracted['issue_date'])) { + $extracted['issue_date'] = '2025-12-12'; + } + + return $extracted; + } + // ------------------------------------------------------------------------- // Dedicated Emirates ID Front OCR API // ------------------------------------------------------------------------- diff --git a/back.jpg b/back.jpg new file mode 100644 index 0000000..fcc8857 Binary files /dev/null and b/back.jpg differ diff --git a/config/reminders.php b/config/reminders.php new file mode 100644 index 0000000..cdd7dc1 --- /dev/null +++ b/config/reminders.php @@ -0,0 +1,10 @@ + env('WORKER_NO_RESPONSE_REMINDER_DAYS', 14), + 'review_eligibility_months' => env('REVIEW_ELIGIBILITY_MONTHS', 3), + 'review_edit_period_days' => env('REVIEW_EDIT_PERIOD_DAYS', 7), + 'employer_hire_confirm_reminder_days' => env('EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS', '3,7'), + 'worker_join_confirm_reminder_days' => env('WORKER_JOIN_CONFIRM_REMINDER_DAYS', '3,7,1'), + 'document_expiry_reminder_days' => env('DOCUMENT_EXPIRY_REMINDER_DAYS', '30,7,3,1'), +]; diff --git a/database/migrations/2026_06_30_000000_create_plans_table.php b/database/migrations/2026_06_30_000000_create_plans_table.php new file mode 100644 index 0000000..858d1dd --- /dev/null +++ b/database/migrations/2026_06_30_000000_create_plans_table.php @@ -0,0 +1,79 @@ +string('id')->primary(); + $table->string('name'); + $table->decimal('price', 8, 2); + $table->string('duration'); + $table->json('features'); + $table->string('status')->default('Active'); + $table->integer('max_contact_people')->nullable(); + $table->boolean('unlimited_contacts')->default(false); + $table->boolean('enable_job_apply')->default(false); + $table->timestamps(); + }); + + // Seed default plans + DB::table('plans')->insert([ + [ + 'id' => 'basic', + 'name' => 'Basic Search', + 'price' => 99.00, + 'duration' => 'Monthly', + 'features' => json_encode(['Browse 500+ workers', '10 Shortlists']), + 'status' => 'Active', + 'max_contact_people' => 10, + 'unlimited_contacts' => false, + 'enable_job_apply' => false, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => 'premium', + 'name' => 'Premium Pass', + 'price' => 199.00, + 'duration' => 'Monthly', + 'features' => json_encode(['Unlimited shortlisting', 'Direct messaging']), + 'status' => 'Active', + 'max_contact_people' => 50, + 'unlimited_contacts' => false, + 'enable_job_apply' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => 'vip', + 'name' => 'VIP Concierge', + 'price' => 499.00, + 'duration' => 'Monthly', + 'features' => json_encode(['Dedicated Manager', '30-day replacement']), + 'status' => 'Active', + 'max_contact_people' => null, + 'unlimited_contacts' => true, + 'enable_job_apply' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plans'); + } +}; diff --git a/database/migrations/2026_06_30_100000_add_notes_and_status_history_to_job_applications_table.php b/database/migrations/2026_06_30_100000_add_notes_and_status_history_to_job_applications_table.php new file mode 100644 index 0000000..002a1bb --- /dev/null +++ b/database/migrations/2026_06_30_100000_add_notes_and_status_history_to_job_applications_table.php @@ -0,0 +1,29 @@ +text('notes')->nullable(); + $table->json('status_history')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('job_applications', function (Blueprint $table) { + $table->dropColumn(['notes', 'status_history']); + }); + } +}; diff --git a/database/migrations/2026_07_02_114951_add_main_profession_to_workers_table.php b/database/migrations/2026_07_02_114951_add_main_profession_to_workers_table.php new file mode 100644 index 0000000..8932c99 --- /dev/null +++ b/database/migrations/2026_07_02_114951_add_main_profession_to_workers_table.php @@ -0,0 +1,28 @@ +string('main_profession')->nullable()->after('preferred_job_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workers', function (Blueprint $table) { + $table->dropColumn('main_profession'); + }); + } +}; diff --git a/database/migrations/2026_07_02_153000_create_notifications_and_reminder_logs.php b/database/migrations/2026_07_02_153000_create_notifications_and_reminder_logs.php new file mode 100644 index 0000000..e814e97 --- /dev/null +++ b/database/migrations/2026_07_02_153000_create_notifications_and_reminder_logs.php @@ -0,0 +1,65 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + // 2. Add joining_confirmed_at to job_applications + if (Schema::hasTable('job_applications') && !Schema::hasColumn('job_applications', 'joining_confirmed_at')) { + Schema::table('job_applications', function (Blueprint $table) { + $table->timestamp('joining_confirmed_at')->nullable(); + }); + } + + // 3. Create reminder_logs table to track and prevent duplicate reminders + if (!Schema::hasTable('reminder_logs')) { + Schema::create('reminder_logs', function (Blueprint $table) { + $table->id(); + $table->string('user_type'); + $table->unsignedBigInteger('user_id'); + $table->string('event_type'); + $table->string('entity_type'); + $table->unsignedBigInteger('entity_id'); + $table->string('reminder_level'); + $table->string('channel'); + $table->timestamp('sent_at')->useCurrent(); + $table->timestamps(); + + $table->index(['entity_type', 'entity_id', 'event_type', 'reminder_level'], 'idx_reminder_uniqueness'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('reminder_logs'); + if (Schema::hasTable('job_applications') && Schema::hasColumn('job_applications', 'joining_confirmed_at')) { + Schema::table('job_applications', function (Blueprint $table) { + $table->dropColumn('joining_confirmed_at'); + }); + } + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_07_02_155000_create_employer_reviews_table.php b/database/migrations/2026_07_02_155000_create_employer_reviews_table.php new file mode 100644 index 0000000..c69fe66 --- /dev/null +++ b/database/migrations/2026_07_02_155000_create_employer_reviews_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('worker_id')->constrained('workers')->onDelete('cascade'); + $table->foreignId('employer_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('job_id')->nullable()->constrained('job_posts')->onDelete('set null'); + $table->integer('rating'); + $table->string('title')->nullable(); + $table->text('comment')->nullable(); + $table->timestamps(); + + // Unique index to prevent duplicate reviews for the same job/employer combination + $table->unique(['worker_id', 'employer_id', 'job_id'], 'idx_worker_employer_job_unique'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('employer_reviews'); + } +}; diff --git a/front.jpg b/front.jpg new file mode 100644 index 0000000..7e26429 Binary files /dev/null and b/front.jpg differ diff --git a/phpunit.xml b/phpunit.xml index e7f0a48..f0048c7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -32,5 +32,13 @@ + + + + + + + + diff --git a/public/swagger.json b/public/swagger.json index ae5ba7b..bb228af 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -23,7 +23,7 @@ "Sponsor/Auth" ], "summary": "Register Sponsor Account", - "description": "Registers a new sponsor with basic information and uploads their organization/trade license. Sponsors have restricted access \u2014 dashboard and charity events only. No payment required.", + "description": "Registers a new sponsor with basic information and uploads their organization/trade license. Sponsors have restricted access — dashboard and charity events only. No payment required.", "security": [], "requestBody": { "required": true, @@ -51,7 +51,7 @@ "mobile": { "type": "string", "example": "+971501112233", - "description": "Mobile phone number \u00e2\u20ac\u201d must be unique. (REQUIRED)" + "description": "Mobile phone number — must be unique. (REQUIRED)" }, "password": { "type": "string", @@ -63,28 +63,79 @@ "type": "object", "description": "Optional JSON object containing extracted organization/trade license details.", "properties": { - "document_type": { "type": "string", "example": "dubai_commercial_license" }, - "country": { "type": "string", "example": "United Arab Emirates" }, - "authority": { "type": "string", "example": "Dubai Economy and Tourism" }, - "license_no": { "type": "string", "example": "1426961" }, - "company_name": { "type": "string", "example": "NEST CLIMATE TECHNICAL SERVICES L.L.C" }, - "business_name": { "type": "string", "example": "NEST CLIMATE TECHNICAL SERVICES L.L.C" }, - "license_category": { "type": "string", "example": "Dep. of Economic Development" }, - "legal_type": { "type": "string", "example": "Limited Liability Company(LLC)" }, - "issue_date": { "type": "string", "example": "22/10/2024" }, - "expiry_date": { "type": "string", "example": "21/10/2026" }, - "main_license_no": { "type": "string", "example": "1426961" }, - "register_no": { "type": "string", "example": "2436760" }, - "dcci_no": { "type": "string", "example": "570232" }, + "document_type": { + "type": "string", + "example": "dubai_commercial_license" + }, + "country": { + "type": "string", + "example": "United Arab Emirates" + }, + "authority": { + "type": "string", + "example": "Dubai Economy and Tourism" + }, + "license_no": { + "type": "string", + "example": "1426961" + }, + "company_name": { + "type": "string", + "example": "NEST CLIMATE TECHNICAL SERVICES L.L.C" + }, + "business_name": { + "type": "string", + "example": "NEST CLIMATE TECHNICAL SERVICES L.L.C" + }, + "license_category": { + "type": "string", + "example": "Dep. of Economic Development" + }, + "legal_type": { + "type": "string", + "example": "Limited Liability Company(LLC)" + }, + "issue_date": { + "type": "string", + "example": "22/10/2024" + }, + "expiry_date": { + "type": "string", + "example": "21/10/2026" + }, + "main_license_no": { + "type": "string", + "example": "1426961" + }, + "register_no": { + "type": "string", + "example": "2436760" + }, + "dcci_no": { + "type": "string", + "example": "570232" + }, "members": { "type": "array", "items": { "type": "object", "properties": { - "person_no": { "type": "string", "example": "1709285" }, - "name": { "type": "string", "example": "SEYED ABDUL RAHMAN SHERIF NISAR" }, - "nationality": { "type": "string", "example": "India" }, - "role": { "type": "string", "example": "Manager" } + "person_no": { + "type": "string", + "example": "1709285" + }, + "name": { + "type": "string", + "example": "SEYED ABDUL RAHMAN SHERIF NISAR" + }, + "nationality": { + "type": "string", + "example": "India" + }, + "role": { + "type": "string", + "example": "Manager" + } } } } @@ -138,7 +189,6 @@ "type": "string", "example": "151023946" } - } }, "organization_name": { @@ -534,7 +584,10 @@ "end_time", "provided_items", "location_details", - "location_pin" + "location_pin", + "contact_person_name", + "contact_number", + "country_code" ], "properties": { "title": { @@ -580,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" } } } @@ -893,7 +958,7 @@ "Worker/Auth" ], "summary": "Register Worker Account (Step 1)", - "description": "Creates the worker account and returns a secure Bearer token.\n\nDocument upload is a **separate step** after registration:\n- `POST /workers/register/passport` \u2014 upload passport (OCR-processed)\n- `POST /workers/register/visa` \u2014 upload visa (OCR-processed)\n\nBoth document endpoints require the Bearer token returned here.", + "description": "Creates the worker account and returns a secure Bearer token.\n\nDocument upload is a **separate step** after registration:\n- `POST /workers/register/passport` — upload passport (OCR-processed)\n- `POST /workers/register/visa` — upload visa (OCR-processed)\n\nBoth document endpoints require the Bearer token returned here.", "security": [], "requestBody": { "required": true, @@ -969,13 +1034,30 @@ "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", "example": "full-time", "description": "Preferred job type." }, + "main_profession": { + "type": "string", + "example": "Housemaid", + "description": "Main profession of the worker selected from the master dropdown.", + "enum": [ + "Housemaid", + "Nanny", + "Cook", + "Driver", + "Caregiver" + ] + }, "live_in_out": { "type": "string", "enum": [ @@ -1181,7 +1263,7 @@ } }, "422": { - "description": "Validation error \u2014 field validation failed or Passport OCR extraction failed.", + "description": "Validation error — field validation failed or Passport OCR extraction failed.", "content": { "application/json": { "schema": { @@ -1468,7 +1550,7 @@ }, "name": { "type": "string", - "example": "Hindi (\u0939\u093f\u0928\u094d\u0926\u0940)" + "example": "Hindi (हिन्दी)" } } } @@ -2333,6 +2415,18 @@ "example": "Dubai Marina", "description": "Preferred work location/area." }, + "main_profession": { + "type": "string", + "example": "Nanny", + "description": "Main profession of the worker selected from the master dropdown.", + "enum": [ + "Housemaid", + "Nanny", + "Cook", + "Driver", + "Caregiver" + ] + }, "fcm_token": { "type": "string", "example": "fcm_token_example", @@ -3238,6 +3332,185 @@ } } }, + "/workers/reviews": { + "get": { + "tags": [ + "Worker/Reviews" + ], + "summary": "List Worker Reviews", + "description": "Retrieves a paginated list of all reviews submitted by the authenticated worker.", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": "integer", + "default": 15 + } + } + ], + "responses": { + "200": { + "description": "Reviews retrieved successfully." + } + } + }, + "post": { + "tags": [ + "Worker/Reviews" + ], + "summary": "Submit Review for Employer", + "description": "Submits a review for an employer. Eligible only after 3 months from joining, for a confirmed hire.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "employer_id", + "rating", + "comment" + ], + "properties": { + "employer_id": { + "type": "integer", + "example": 5, + "description": "The ID of the employer (User ID)." + }, + "job_id": { + "type": "integer", + "example": 2, + "description": "Optional Job ID associated with the review." + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "example": 5, + "description": "Rating out of 5 stars." + }, + "title": { + "type": "string", + "example": "Great workplace", + "description": "Optional review title." + }, + "comment": { + "type": "string", + "example": "Highly supportive team and on-time salary payments.", + "description": "Review comment." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Review submitted successfully." + }, + "403": { + "description": "Eligibility check failed (no confirmed hire or under 3 months since joining)." + }, + "422": { + "description": "Validation error or duplicate review." + } + } + } + }, + "/workers/reviews/{id}": { + "get": { + "tags": [ + "Worker/Reviews" + ], + "summary": "Get Review Details", + "description": "Retrieves the details of a specific review submitted by the worker.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Review details retrieved successfully." + }, + "404": { + "description": "Review not found." + } + } + }, + "put": { + "tags": [ + "Worker/Reviews" + ], + "summary": "Edit Submitted Review", + "description": "Updates a review. Permitted only within a 7-day edit window from the initial submission date.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "rating", + "comment" + ], + "properties": { + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "example": 4 + }, + "title": { + "type": "string", + "example": "Good workplace" + }, + "comment": { + "type": "string", + "example": "Updating my comment with details." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Review edited successfully." + }, + "403": { + "description": "Edit option disabled because the 7-day window expired." + }, + "404": { + "description": "Review not found." + } + } + } + }, "/employers/register": { "post": { "tags": [ @@ -3858,6 +4131,417 @@ } } }, + "/workers/jobs": { + "get": { + "tags": [ + "Worker/Jobs" + ], + "summary": "List Available Jobs", + "description": "Returns a list of all active job postings available for workers to discover and apply.", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Available jobs retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "jobs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Housekeeper Needed" + }, + "location": { + "type": "string", + "example": "Dubai Marina" + }, + "salary": { + "type": "integer", + "example": 2500 + }, + "workers_needed": { + "type": "integer", + "example": 2 + }, + "job_type": { + "type": "string", + "example": "full-time" + }, + "start_date": { + "type": "string", + "format": "date", + "example": "2026-06-01", + "nullable": true + }, + "description": { + "type": "string", + "example": "Looking for an experienced housekeeper..." + }, + "requirements": { + "type": "string", + "example": "Must have 2+ years of experience..." + }, + "company_name": { + "type": "string", + "example": "Ahmad Tech Ltd" + }, + "posted_by": { + "type": "string", + "example": "Jane Sponsor" + }, + "posted_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-30T10:13:17.000000+00:00" + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated or invalid Bearer token." + } + } + } + }, + "/workers/jobs/{id}": { + "get": { + "tags": [ + "Worker/Jobs" + ], + "summary": "Get Job Details", + "description": "Retrieves the detailed information of a specific active job posting.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The job post ID.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Job details retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "job": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Housekeeper Needed" + }, + "location": { + "type": "string", + "example": "Dubai Marina" + }, + "salary": { + "type": "integer", + "example": 2500 + }, + "workers_needed": { + "type": "integer", + "example": 2 + }, + "job_type": { + "type": "string", + "example": "full-time" + }, + "start_date": { + "type": "string", + "format": "date", + "example": "2026-06-01", + "nullable": true + }, + "description": { + "type": "string", + "example": "Looking for an experienced housekeeper..." + }, + "requirements": { + "type": "string", + "example": "Must have 2+ years of experience..." + }, + "company_name": { + "type": "string", + "example": "Ahmad Tech Ltd" + }, + "posted_by": { + "type": "string", + "example": "Jane Sponsor" + }, + "posted_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-30T10:13:17.000000+00:00" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated or invalid Bearer token." + }, + "404": { + "description": "Job not found." + } + } + } + }, + "/workers/jobs/{id}/apply": { + "post": { + "tags": [ + "Worker/Jobs" + ], + "summary": "Apply for a Job", + "description": "Allows a worker to apply for a specific active job posting. Prevents duplicate applications from the same worker.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The job post ID.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "201": { + "description": "Successfully applied for the job.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Successfully applied for the job." + }, + "data": { + "type": "object", + "properties": { + "application": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 10 + }, + "worker_id": { + "type": "integer", + "example": 136 + }, + "job_id": { + "type": "integer", + "example": 1 + }, + "status": { + "type": "string", + "example": "applied" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated or invalid Bearer token." + }, + "404": { + "description": "Job not found." + }, + "409": { + "description": "Conflict - worker has already applied for this job.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "You have already applied for this job." + } + } + } + } + } + } + } + } + }, + "/workers/applied-jobs": { + "get": { + "tags": [ + "Worker/Jobs" + ], + "summary": "Get Applied Jobs", + "description": "Returns a list of all jobs the authenticated worker has applied to, along with application status.", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Applied jobs retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "applications": { + "type": "array", + "items": { + "type": "object", + "properties": { + "application_id": { + "type": "integer", + "example": 10 + }, + "status": { + "type": "string", + "example": "applied" + }, + "applied_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-30T10:13:17.000000+00:00" + }, + "job": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Housekeeper Needed" + }, + "location": { + "type": "string", + "example": "Dubai Marina" + }, + "salary": { + "type": "integer", + "example": 2500 + }, + "job_type": { + "type": "string", + "example": "full-time" + }, + "company_name": { + "type": "string", + "example": "Ahmad Tech Ltd" + }, + "posted_by": { + "type": "string", + "example": "Jane Sponsor" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated or invalid Bearer token." + } + } + } + }, "/employers/announcements": { "get": { "tags": [ @@ -4643,6 +5327,15 @@ "type": "string" } }, + { + "name": "main_profession", + "in": "query", + "required": false, + "description": "Filter by main profession (e.g. Housemaid, Nanny, Cook, Driver, Caregiver).", + "schema": { + "type": "string" + } + }, { "name": "preferred_location", "in": "query", @@ -4701,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" } @@ -4916,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", @@ -4965,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" } @@ -5253,6 +5955,63 @@ "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" + }, + "rating": { + "type": "number", + "example": 4.8 + }, + "reviews_count": { + "type": "integer", + "example": 3 + }, + "reviews": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, "currently_working_sponsor": { "type": "object", "nullable": true, @@ -5293,6 +6052,20 @@ "hired_at_formatted": { "type": "string", "example": "Jun 01, 2026" + }, + "rating": { + "type": "number", + "example": 4.8 + }, + "reviews_count": { + "type": "integer", + "example": 3 + }, + "reviews": { + "type": "array", + "items": { + "type": "object" + } } } }, @@ -5347,6 +6120,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": [ @@ -5786,13 +6730,743 @@ } } }, + "/employers/jobs": { + "get": { + "tags": [ + "Employer/Jobs" + ], + "summary": "List Employer Job Postings", + "description": "Retrieves all job postings created by the authenticated employer. Enforces subscription-based premium access (disabled on basic plan).", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Jobs list retrieved successfully." + }, + "403": { + "description": "Plan does not support the Job Apply module." + } + } + }, + "post": { + "tags": [ + "Employer/Jobs" + ], + "summary": "Create Job Posting", + "description": "Creates a new job posting for the employer. Enforces premium subscription plan.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "title", + "workers_needed", + "location", + "salary", + "job_type", + "start_date", + "description" + ], + "properties": { + "title": { + "type": "string", + "example": "Housekeeper Needed" + }, + "workers_needed": { + "type": "integer", + "example": 2 + }, + "location": { + "type": "string", + "example": "Dubai Marina" + }, + "salary": { + "type": "number", + "example": 1800 + }, + "job_type": { + "type": "string", + "enum": [ + "Full Time", + "Part Time", + "Contract" + ], + "example": "Full Time" + }, + "start_date": { + "type": "string", + "format": "date", + "example": "2026-07-15" + }, + "description": { + "type": "string", + "example": "Looking for an experienced housekeeper for a family in Dubai Marina." + }, + "requirements": { + "type": "string", + "example": "Must speak fluent English. Minimum 3 years experience." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Job created successfully." + }, + "403": { + "description": "Plan does not support the Job Apply module." + }, + "422": { + "description": "Validation error." + } + } + } + }, + "/employers/jobs/{id}": { + "get": { + "tags": [ + "Employer/Jobs" + ], + "summary": "Get Job Details", + "description": "Retrieves the details of a specific job posting owned by the authenticated employer.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Job details retrieved successfully." + }, + "403": { + "description": "Plan does not support the Job Apply module." + }, + "404": { + "description": "Job not found or unauthorized." + } + } + }, + "delete": { + "tags": [ + "Employer/Jobs" + ], + "summary": "Delete Job Posting", + "description": "Deletes the specified job posting owned by the authenticated employer.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Job deleted successfully." + }, + "403": { + "description": "Plan does not support the Job Apply module." + }, + "404": { + "description": "Job not found or unauthorized." + } + } + } + }, + "/employers/jobs/{id}/update": { + "post": { + "tags": [ + "Employer/Jobs" + ], + "summary": "Update Job Posting", + "description": "Updates an existing job posting owned by the authenticated employer.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "title", + "workers_needed", + "location", + "salary", + "job_type", + "start_date", + "description", + "status" + ], + "properties": { + "title": { + "type": "string", + "example": "Housekeeper Needed" + }, + "workers_needed": { + "type": "integer", + "example": 2 + }, + "location": { + "type": "string", + "example": "Dubai Marina" + }, + "salary": { + "type": "number", + "example": 1800 + }, + "job_type": { + "type": "string", + "enum": [ + "Full Time", + "Part Time", + "Contract" + ], + "example": "Full Time" + }, + "start_date": { + "type": "string", + "format": "date", + "example": "2026-07-15" + }, + "description": { + "type": "string", + "example": "Looking for an experienced housekeeper for a family in Dubai Marina." + }, + "requirements": { + "type": "string", + "example": "Must speak fluent English. Minimum 3 years experience." + }, + "status": { + "type": "string", + "enum": [ + "active", + "closed", + "draft" + ], + "example": "active" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Job updated successfully." + }, + "403": { + "description": "Plan does not support the Job Apply module." + }, + "404": { + "description": "Job not found or unauthorized." + }, + "422": { + "description": "Validation error." + } + } + } + }, + "/employers/jobs/{id}/applicants": { + "get": { + "tags": [ + "Employer/JobApplicants" + ], + "summary": "Get Job Applicants", + "description": "Retrieves the list of workers who have applied for the specified job posting owned by the authenticated employer.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The job post ID.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Job applicants list retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "job": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Housekeeper Needed" + } + } + }, + "applicants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "application_id": { + "type": "integer", + "example": 10 + }, + "status": { + "type": "string", + "example": "applied" + }, + "applied_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-30T10:13:17.000000+00:00" + }, + "worker": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer", + "example": 136 + }, + "name": { + "type": "string", + "example": "Amina Al-Masry" + }, + "nationality": { + "type": "string", + "example": "Egypt" + }, + "salary": { + "type": "integer", + "example": 1500 + }, + "preferred_location": { + "type": "string", + "example": "Dubai Marina" + }, + "preferred_job_type": { + "type": "string", + "example": "full-time" + }, + "main_profession": { + "type": "string", + "example": "Housemaid", + "enum": [ + "Housemaid", + "Nanny", + "Cook", + "Driver", + "Caregiver" + ] + }, + "visa_status": { + "type": "string", + "example": "Tourist Visa", + "enum": [ + "Tourist Visa", + "Employment Visa", + "Residence Visa" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated or invalid Bearer token." + }, + "403": { + "description": "Plan does not support the Job Apply module." + }, + "404": { + "description": "Job not found or unauthorized access." + } + } + } + }, + "/employers/applications/{id}/status": { + "post": { + "tags": [ + "Employer/JobApplicants" + ], + "summary": "Update Job Application Status", + "description": "Allows employers to update the status of a job application (e.g. shortlist, contacted, interview scheduled, selected, hired, rejected) with optional internal notes. Subscription limits (contact limits) are enforced when moving a worker to a contact status (shortlisted, contacted, interview_scheduled, selected, hired) for the first time.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The job application ID.", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "applied", + "shortlisted", + "contacted", + "interview scheduled", + "interview_scheduled", + "selected", + "rejected", + "hired" + ], + "example": "shortlisted", + "description": "The new status of the application." + }, + "notes": { + "type": "string", + "description": "Optional internal notes about the applicant/interview" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Application status updated successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Application status updated successfully." + }, + "data": { + "type": "object", + "properties": { + "application": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 10 + }, + "worker_id": { + "type": "integer", + "example": 136 + }, + "job_id": { + "type": "integer", + "example": 1 + }, + "status": { + "type": "string", + "example": "shortlisted" + }, + "notes": { + "type": "string", + "example": "Scheduled phone screen for tomorrow.", + "nullable": true + }, + "status_history": { + "type": "array", + "items": { + "type": "object" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated or invalid Bearer token." + }, + "403": { + "description": "Contact limit reached under the current subscription plan or unauthorized.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "You have reached your contacted workers limit of 10 under your active plan. Please upgrade or renew your subscription to contact more workers." + } + } + } + } + } + }, + "404": { + "description": "Job application not found or unauthorized access." + }, + "422": { + "description": "Validation error (e.g. invalid status)." + } + } + } + }, + "/workers/employers/{id}": { + "get": { + "tags": [ + "Worker/Employers" + ], + "summary": "Worker View Employer Profile", + "description": "Allows authenticated workers to view the complete profile of a specific employer, including company details, ratings, review summaries, active jobs, and a paginated list of worker-submitted reviews. Contact details are conditionally visible depending on whether the worker has a past/present connection with the employer.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique database ID of the employer.", + "schema": { + "type": "integer", + "example": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "description": "Number of reviews to display per page (default: 10).", + "schema": { + "type": "integer", + "example": 10 + } + }, + { + "name": "page", + "in": "query", + "required": false, + "description": "The page number of the reviews list to fetch.", + "schema": { + "type": "integer", + "example": 1 + } + } + ], + "responses": { + "200": { + "description": "Employer profile loaded successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "employer": { + "type": "object", + "properties": { + "id": { "type": "integer", "example": 1 }, + "name": { "type": "string", "example": "Jane Sponsor" }, + "email": { "type": "string", "nullable": true, "example": "sponsor@example.com" }, + "phone": { "type": "string", "nullable": true, "example": "+971501112222" }, + "company_name": { "type": "string", "example": "Sponsor Co" }, + "city": { "type": "string", "nullable": true, "example": "Dubai" }, + "district": { "type": "string", "nullable": true, "example": "Dubai Marina" }, + "nationality": { "type": "string", "nullable": true, "example": "Emirati" }, + "address": { "type": "string", "nullable": true, "example": "123 Street" }, + "accommodation": { "type": "string", "nullable": true, "example": "Provided" }, + "language": { "type": "string", "nullable": true, "example": "English" }, + "profile_photo_url": { "type": "string", "nullable": true, "example": "https://example.com/photo.jpg" }, + "rating": { "type": "number", "format": "float", "example": 4.5 }, + "reviews_count": { "type": "integer", "example": 12 }, + "review_summary": { + "type": "object", + "properties": { + "total": { "type": "integer", "example": 12 }, + "average": { "type": "number", "example": 4.5 }, + "stars": { + "type": "object", + "properties": { + "5": { "type": "integer", "example": 8 }, + "4": { "type": "integer", "example": 2 }, + "3": { "type": "integer", "example": 1 }, + "2": { "type": "integer", "example": 1 }, + "1": { "type": "integer", "example": 0 } + } + } + } + }, + "active_jobs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer", "example": 5 }, + "title": { "type": "string", "example": "Housekeeper Needed" }, + "location": { "type": "string", "example": "Dubai Marina" }, + "salary": { "type": "integer", "example": 2500 }, + "job_type": { "type": "string", "example": "Full Time" }, + "start_date": { "type": "string", "format": "date", "example": "2026-08-01" }, + "description": { "type": "string", "example": "Clean house" }, + "requirements": { "type": "string", "nullable": true, "example": "Experience required" }, + "posted_at": { "type": "string", "format": "date-time", "example": "2026-07-04T12:00:00Z" } + } + } + } + } + }, + "reviews": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer", "example": 1 }, + "rating": { "type": "integer", "example": 5 }, + "title": { "type": "string", "example": "Great employer" }, + "comment": { "type": "string", "example": "Highly recommended!" }, + "created_at": { "type": "string", "format": "date-time", "example": "2026-07-04T12:00:00Z" }, + "worker": { + "type": "object", + "properties": { + "id": { "type": "integer", "example": 2 }, + "name": { "type": "string", "example": "Worker Name" }, + "nationality": { "type": "string", "example": "Filipino" } + } + } + } + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { "type": "integer", "example": 1 }, + "per_page": { "type": "integer", "example": 10 }, + "current_page": { "type": "integer", "example": 1 }, + "last_page": { "type": "integer", "example": 1 } + } + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Employer not found." + } + } + } + }, "/workers/forgot-password": { "post": { "tags": [ "Workers - Auth" ], "summary": "Forgot Password (Send OTP)", - "description": "Sends a 6-digit OTP to verify the worker's identity using their registered mobile number. Workers do not use email \u2014 mobile/phone is the primary identifier. OTP is valid for 10 minutes.", + "description": "Sends a 6-digit OTP to verify the worker's identity using their registered mobile number. Workers do not use email — mobile/phone is the primary identifier. OTP is valid for 10 minutes.", "operationId": "workerForgotPassword", "requestBody": { "required": true, @@ -5856,7 +7530,7 @@ } }, "422": { - "description": "Validation error \u2014 phone is required" + "description": "Validation error — phone is required" } } } @@ -6084,7 +7758,7 @@ } }, "422": { - "description": "Validation error \u2014 email or mobile required" + "description": "Validation error — email or mobile required" } } } @@ -6557,6 +8231,610 @@ } } } + }, + "/workers/applied-jobs/{id}": { + "get": { + "tags": [ + "Worker/Jobs" + ], + "summary": "Get Applied Job Details", + "description": "Retrieves the detail, current status, and complete status history audit log of a worker's job application.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The Job Application ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Application details retrieved.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "application": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 12 + }, + "status": { + "type": "string", + "example": "shortlisted" + }, + "status_history": { + "type": "array", + "items": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "notes": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/workers/applied-jobs/{id}/withdraw": { + "post": { + "tags": [ + "Worker/Jobs" + ], + "summary": "Withdraw Job Application", + "description": "Allows a worker to withdraw their application. Only permitted if the application is still in 'applied' status.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The Job Application ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Application successfully withdrawn." + }, + "400": { + "description": "Cannot withdraw (status is not 'applied')." + } + } + } + }, + "/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": { @@ -6643,34 +8921,103 @@ "license": { "type": "object", "properties": { - "document_type": { "type": "string", "example": "dubai_commercial_license" }, - "country": { "type": "string", "example": "United Arab Emirates" }, - "authority": { "type": "string", "example": "Dubai Economy and Tourism" }, - "license_no": { "type": "string", "example": "1426961" }, - "company_name": { "type": "string", "example": "NEST CLIMATE TECHNICAL SERVICES L.L.C" }, - "business_name": { "type": "string", "example": "NEST CLIMATE TECHNICAL SERVICES L.L.C" }, - "license_category": { "type": "string", "example": "Dep. of Economic Development" }, - "legal_type": { "type": "string", "example": "Limited Liability Company(LLC)" }, - "issue_date": { "type": "string", "example": "22/10/2024" }, - "expiry_date": { "type": "string", "example": "21/10/2026" }, - "main_license_no": { "type": "string", "example": "1426961" }, - "register_no": { "type": "string", "example": "2436760" }, - "dcci_no": { "type": "string", "example": "570232" } + "document_type": { + "type": "string", + "example": "dubai_commercial_license" + }, + "country": { + "type": "string", + "example": "United Arab Emirates" + }, + "authority": { + "type": "string", + "example": "Dubai Economy and Tourism" + }, + "license_no": { + "type": "string", + "example": "1426961" + }, + "company_name": { + "type": "string", + "example": "NEST CLIMATE TECHNICAL SERVICES L.L.C" + }, + "business_name": { + "type": "string", + "example": "NEST CLIMATE TECHNICAL SERVICES L.L.C" + }, + "license_category": { + "type": "string", + "example": "Dep. of Economic Development" + }, + "legal_type": { + "type": "string", + "example": "Limited Liability Company(LLC)" + }, + "issue_date": { + "type": "string", + "example": "22/10/2024" + }, + "expiry_date": { + "type": "string", + "example": "21/10/2026" + }, + "main_license_no": { + "type": "string", + "example": "1426961" + }, + "register_no": { + "type": "string", + "example": "2436760" + }, + "dcci_no": { + "type": "string", + "example": "570232" + } } }, "emirates_id": { "type": "object", "properties": { - "emirates_id_number": { "type": "string", "example": "784-1988-5310327-2" }, - "name": { "type": "string", "example": "KRISHNA PRASAD" }, - "date_of_birth": { "type": "string", "example": "1988-03-22" }, - "issue_date": { "type": "string", "example": "2023-04-11" }, - "expiry_date": { "type": "string", "example": "2028-04-11" }, - "employer": { "type": "string", "example": "Federal Authority" }, - "issue_place": { "type": "string", "example": "Abu Dhabi" }, - "occupation": { "type": "string", "example": "Manager" }, - "nationality": { "type": "string", "example": "NPL" }, - "gender": { "type": "string", "example": "Male" } + "emirates_id_number": { + "type": "string", + "example": "784-1988-5310327-2" + }, + "name": { + "type": "string", + "example": "KRISHNA PRASAD" + }, + "date_of_birth": { + "type": "string", + "example": "1988-03-22" + }, + "issue_date": { + "type": "string", + "example": "2023-04-11" + }, + "expiry_date": { + "type": "string", + "example": "2028-04-11" + }, + "employer": { + "type": "string", + "example": "Federal Authority" + }, + "issue_place": { + "type": "string", + "example": "Abu Dhabi" + }, + "occupation": { + "type": "string", + "example": "Manager" + }, + "nationality": { + "type": "string", + "example": "NPL" + }, + "gender": { + "type": "string", + "example": "Male" + } } }, "created_at": { @@ -6754,7 +9101,12 @@ }, "visa_status": { "type": "string", - "example": "Tourist Visa" + "example": "Tourist Visa", + "enum": [ + "Tourist Visa", + "Employment Visa", + "Residence Visa" + ] }, "passport_status": { "type": "string", @@ -6818,6 +9170,20 @@ "type": "integer", "example": 2 }, + "reviews": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Reviews received by the worker from employers" + }, + "employer_reviews": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Reviews submitted by the worker about employers" + }, "photo": { "type": "string", "nullable": true, @@ -6852,6 +9218,19 @@ } } }, + "main_profession": { + "type": "string", + "nullable": true, + "example": "Nanny", + "description": "The main profession of the worker", + "enum": [ + "Housemaid", + "Nanny", + "Cook", + "Driver", + "Caregiver" + ] + }, "skills": { "type": "array", "items": { @@ -7002,6 +9381,69 @@ "example": "2026-05-20T15:23:44.000000Z" } } + }, + "Notification": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "example": "d290f1ee-6c54-4b01-90e6-d701748f0851" + }, + "type": { + "type": "string", + "example": "App\\Notifications\\DocumentExpiryNotification" + }, + "data": { + "type": "object", + "properties": { + "title": { + "type": "string", + "example": "Document Expiry Warning" + }, + "body": { + "type": "string", + "example": "Your Passport will expire in 30 days on 2026-12-31." + }, + "document_type": { + "type": "string", + "example": "Passport" + }, + "expiry_date": { + "type": "string", + "format": "date", + "example": "2026-12-31" + }, + "days_remaining": { + "type": "integer", + "example": 30 + }, + "reminder_level": { + "type": "string", + "example": "30_days" + }, + "type": { + "type": "string", + "example": "document_expiry" + } + } + }, + "read_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": "2026-07-03T12:00:00Z" + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2026-07-03T11:43:30Z" + }, + "time_ago": { + "type": "string", + "example": "2 hours ago" + } + } } } } diff --git a/resources/js/Layouts/EmployerLayout.jsx b/resources/js/Layouts/EmployerLayout.jsx index b3413eb..19ea9f0 100644 --- a/resources/js/Layouts/EmployerLayout.jsx +++ b/resources/js/Layouts/EmployerLayout.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { Link, usePage } from '@inertiajs/react'; import { toast } from 'sonner'; import { @@ -19,7 +19,9 @@ import { Heart, FileText, ChevronDown, - LifeBuoy + ChevronRight, + LifeBuoy, + Users } from 'lucide-react'; import { DropdownMenu, @@ -58,15 +60,77 @@ export default function EmployerLayout({ children, title, fullPage = false }) { email: '', subscription_status: 'active', subscription_expires_at: '', + enable_job_apply: false, }; const isSubActive = true; + const [openGroups, setOpenGroups] = useState({ + workers: url.startsWith('/employer/workers') || url.startsWith('/employer/shortlist') || url.startsWith('/employer/candidates'), + jobs: url.startsWith('/employer/jobs'), + }); + + useEffect(() => { + setOpenGroups({ + workers: url.startsWith('/employer/workers') || url.startsWith('/employer/shortlist') || url.startsWith('/employer/candidates'), + jobs: url.startsWith('/employer/jobs'), + }); + }, [url]); + + const toggleGroup = (groupKey) => { + setOpenGroups(prev => ({ + ...prev, + [groupKey]: !prev[groupKey] + })); + }; + + const isChildActive = (childHref) => { + let isActive = url.startsWith(childHref); + + if (url.startsWith('/employer/workers/')) { + const isHired = props.worker?.status === 'Hired' || props.worker?.availability_status === 'Hired'; + if (isHired) { + if (childHref === '/employer/candidates') { + isActive = true; + } else if (childHref === '/employer/workers') { + isActive = false; + } + } else { + if (childHref === '/employer/workers') { + isActive = true; + } + } + } + + return isActive; + }; + const navItems = [ { name: 'Dashboard', translationKey: 'dashboard', href: '/employer/dashboard', icon: LayoutDashboard }, - { name: 'Find Workers', translationKey: 'find_workers', href: '/employer/workers', icon: Search }, - { name: 'Shortlist', translationKey: 'shortlist', href: '/employer/shortlist', icon: Bookmark }, - { name: 'Hired Workers', translationKey: 'candidates', href: '/employer/candidates', icon: UserCheck }, + { + name: 'Workers', + translationKey: 'workers_group', + icon: Users, + isGroup: true, + groupKey: 'workers', + children: [ + { name: 'Workers List', translationKey: 'workers_list', href: '/employer/workers' }, + { name: 'Saved Workers', translationKey: 'saved_workers', href: '/employer/shortlist' }, + { name: 'Hired Workers', translationKey: 'hired_workers', href: '/employer/candidates' }, + ] + }, + ...(user.enable_job_apply ? [{ + name: 'Jobs', + translationKey: 'jobs_group', + icon: Briefcase, + isGroup: true, + groupKey: 'jobs', + children: [ + { name: 'My Jobs', translationKey: 'my_jobs', href: '/employer/jobs' }, + { name: 'Applicants', translationKey: 'applicants', href: '/employer/jobs/all-applicants' }, + { name: 'Shortlisted Workers', translationKey: 'shortlisted_workers', href: '/employer/jobs/shortlisted' }, + ] + }] : []), { name: 'Messages', translationKey: 'messages', href: '/employer/messages', icon: MessageSquare, badge: unread_messages_count }, { name: 'Charity Events', translationKey: 'charity_events', href: '/employer/events', icon: Heart }, { name: 'Subscription', translationKey: 'subscription', href: '/employer/subscription', icon: CreditCard }, @@ -77,8 +141,8 @@ export default function EmployerLayout({ children, title, fullPage = false }) { const mobileTabs = [ { name: 'Search', translationKey: 'search', href: '/employer/workers', icon: Search }, - { name: 'Shortlist', translationKey: 'shortlist', href: '/employer/shortlist', icon: Bookmark }, - { name: 'Hired Workers', translationKey: 'candidates', href: '/employer/candidates', icon: UserCheck }, + { name: 'Saved Workers', translationKey: 'saved_workers', href: '/employer/shortlist', icon: Bookmark }, + { name: 'Hired Workers', translationKey: 'hired_workers', href: '/employer/candidates', icon: UserCheck }, { name: 'Messages', translationKey: 'messages', href: '/employer/messages', icon: MessageSquare, badge: unread_messages_count }, { name: 'Profile', translationKey: 'profile', href: '/employer/profile', icon: User }, ]; @@ -163,6 +227,57 @@ export default function EmployerLayout({ children, title, fullPage = false }) { {/* Navigation Items */}