From 4bdaa2811badfe60cb603b6bf53de869f54c3229 Mon Sep 17 00:00:00 2001 From: mohanmd Date: Sat, 13 Jun 2026 02:20:04 +0530 Subject: [PATCH] changes api and push notification updated --- .../Admin/SupportTicketController.php | 4 +- .../Api/EmployerMessageController.php | 15 + .../Api/EmployerWorkerController.php | 38 +- .../Controllers/Api/SponsorAuthController.php | 18 +- .../Controllers/Api/SponsorController.php | 6 + .../Api/SupportTicketController.php | 24 +- .../Controllers/Api/WorkerAuthController.php | 212 ++++++++- .../Api/WorkerProfileController.php | 4 +- .../Employer/CandidateController.php | 28 +- .../Employer/EmployerAuthController.php | 20 +- .../Employer/MessageController.php | 15 + .../Controllers/Employer/WorkerController.php | 27 +- app/Models/Sponsor.php | 3 + app/Models/SupportTicket.php | 1 + app/Models/Worker.php | 1 + app/Services/FCMService.php | 155 +++++++ ...add_emirates_id_file_to_sponsors_table.php | 30 ++ ...0_add_license_expiry_to_sponsors_table.php | 30 ++ ..._000000_add_fcm_token_to_workers_table.php | 28 ++ ...ice_note_path_to_support_tickets_table.php | 28 ++ ...a7-firebase-adminsdk-fbsvc-f8b365ecd0.json | 13 + public/swagger.json | 429 +++++++++++++++++- resources/js/Pages/Admin/Support/Show.jsx | 22 + resources/js/Pages/Employer/Auth/Register.jsx | 35 +- .../js/Pages/Employer/SelectedCandidates.jsx | 170 +++++-- resources/js/Pages/Employer/Workers/Index.jsx | 94 ++-- .../js/components/Employer/FilterDrawer.jsx | 83 ++-- routes/api.php | 3 + tests/Feature/EmployerMessageApiTest.php | 36 ++ tests/Feature/EmployerWorkerFilterApiTest.php | 39 +- tests/Feature/EmployerWorkerWebFilterTest.php | 30 ++ tests/Feature/SponsorAuthApiTest.php | 29 ++ tests/Feature/SponsorAuthWebTest.php | 89 ++++ tests/Feature/WorkerJourneyApiTest.php | 105 +++++ tests/Feature/WorkerSupportTicketApiTest.php | 97 ++++ vite.config.js | 2 +- 36 files changed, 1843 insertions(+), 120 deletions(-) create mode 100644 app/Services/FCMService.php create mode 100644 database/migrations/2026_06_12_181000_add_emirates_id_file_to_sponsors_table.php create mode 100644 database/migrations/2026_06_12_185000_add_license_expiry_to_sponsors_table.php create mode 100644 database/migrations/2026_06_13_000000_add_fcm_token_to_workers_table.php create mode 100644 database/migrations/2026_06_13_000001_add_voice_note_path_to_support_tickets_table.php create mode 100644 migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json create mode 100644 tests/Feature/SponsorAuthWebTest.php create mode 100644 tests/Feature/WorkerSupportTicketApiTest.php diff --git a/app/Http/Controllers/Admin/SupportTicketController.php b/app/Http/Controllers/Admin/SupportTicketController.php index 2e3f8cf..dc82100 100644 --- a/app/Http/Controllers/Admin/SupportTicketController.php +++ b/app/Http/Controllers/Admin/SupportTicketController.php @@ -56,7 +56,7 @@ public function index(Request $request) public function show($id) { - $ticket = SupportTicket::with(['user', 'worker'])->findOrFail($id); + $ticket = SupportTicket::with(['user', 'worker', 'reason'])->findOrFail($id); $replies = SupportTicketReply::where('support_ticket_id', $ticket->id) ->with(['user', 'worker']) @@ -79,6 +79,8 @@ public function show($id) 'ticket_number' => $ticket->ticket_number, 'subject' => $ticket->subject, 'description' => $ticket->description, + 'reason_name' => $ticket->reason ? $ticket->reason->reason : null, + 'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null, 'status' => $ticket->status, 'priority' => $ticket->priority, 'user_type' => $ticket->user_id ? 'Employer' : 'Worker', diff --git a/app/Http/Controllers/Api/EmployerMessageController.php b/app/Http/Controllers/Api/EmployerMessageController.php index 854326a..23fff0f 100644 --- a/app/Http/Controllers/Api/EmployerMessageController.php +++ b/app/Http/Controllers/Api/EmployerMessageController.php @@ -269,6 +269,21 @@ public function sendMessage(Request $request, $id) $conv->touch(); }); + // Dispatch push notification to worker + $worker = $conv->worker; + if ($worker && $worker->fcm_token) { + \App\Services\FCMService::sendPushNotification( + $worker->fcm_token, + "New Message from " . ($employer->name ?? "Employer"), + $request->text ?: "Sent an attachment", + [ + 'conversation_id' => $conv->id, + 'sender_type' => 'employer', + 'sender_id' => $employer->id, + ] + ); + } + return response()->json([ 'success' => true, 'message' => 'Message sent successfully.', diff --git a/app/Http/Controllers/Api/EmployerWorkerController.php b/app/Http/Controllers/Api/EmployerWorkerController.php index b6e1ac9..c8a8620 100644 --- a/app/Http/Controllers/Api/EmployerWorkerController.php +++ b/app/Http/Controllers/Api/EmployerWorkerController.php @@ -69,6 +69,7 @@ private function formatWorker(Worker $w) 'religion' => $w->religion, 'languages' => $langs, 'age' => $w->age, + 'gender' => $w->gender, 'verified' => (bool)$w->verified, 'preferred_job_type' => $preferredJobType, 'bio' => $w->bio, @@ -113,7 +114,14 @@ public function getWorkers(Request $request) $workersArray = $formattedWorkers->toArray(); - // Apply filters: skills, language/languages, nationality, preferred_location, job_type, live_in_out, in_country, visa_status + // Apply filters: gender, skills, language/languages, nationality, preferred_location, job_type, live_in_out, in_country, visa_status + if ($request->filled('gender')) { + $gender = strtolower($request->gender); + $workersArray = array_values(array_filter($workersArray, function ($c) use ($gender) { + return isset($c['gender']) && strtolower($c['gender']) === $gender; + })); + } + if ($request->filled('nationality')) { $natInput = $request->nationality; $natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput))); @@ -169,12 +177,22 @@ public function getWorkers(Request $request) $locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc))); $locsArray = array_map('strtolower', $locsArray); - $workersArray = array_values(array_filter($workersArray, function ($c) use ($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'] + ]; + + $workersArray = array_values(array_filter($workersArray, function ($c) use ($locsArray, $locationMapping) { if (!isset($c['preferred_location'])) return false; $wLoc = strtolower($c['preferred_location']); foreach ($locsArray as $l) { - if (str_contains($wLoc, $l)) { - return true; + if ($l === 'all') return true; + $allowedKeywords = $locationMapping[$l] ?? [$l]; + foreach ($allowedKeywords as $keyword) { + if (str_contains($wLoc, $keyword)) { + return true; + } } } return false; @@ -374,6 +392,7 @@ public function getCandidates(Request $request) 'live_in_out' => $w->live_in_out, 'in_country' => (bool)$w->in_country, 'visa_status' => $w->visa_status, + 'gender' => $w->gender, ]; })->filter()->values()->toArray(); @@ -414,6 +433,7 @@ public function getCandidates(Request $request) 'live_in_out' => $w->live_in_out, 'in_country' => (bool)$w->in_country, 'visa_status' => $w->visa_status, + 'gender' => $w->gender, ]; })->filter()->values()->toArray(); @@ -425,7 +445,14 @@ public function getCandidates(Request $request) return $c && $c['status'] === 'Hired'; })); - // Apply filters: skills, languages, nationality, availability, preferred_location, job_type, live_in_out, in_country, visa_status + // Apply filters: gender, skills, languages, nationality, availability, preferred_location, job_type, live_in_out, in_country, visa_status + if ($request->filled('gender')) { + $gender = strtolower($request->gender); + $mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($gender) { + return isset($c['gender']) && strtolower($c['gender']) === $gender; + })); + } + if ($request->filled('nationality')) { $natInput = $request->nationality; $natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput))); @@ -876,6 +903,7 @@ public function getWorkerDetail(Request $request, $id) 'religion' => $w->religion, 'languages' => $langs, 'age' => $w->age, + 'gender' => $w->gender, 'verified' => (bool)$w->verified, 'preferred_job_type' => $preferredJobType, 'bio' => $w->bio, diff --git a/app/Http/Controllers/Api/SponsorAuthController.php b/app/Http/Controllers/Api/SponsorAuthController.php index 42fc782..2eecf04 100644 --- a/app/Http/Controllers/Api/SponsorAuthController.php +++ b/app/Http/Controllers/Api/SponsorAuthController.php @@ -27,12 +27,14 @@ public function register(Request $request) 'mobile' => 'required|string|max:50|unique:sponsors,mobile', 'password' => 'required|string|min:6', 'license_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240', + 'emirates_id_file' => 'nullable|file|mimes:jpeg,png,jpg,pdf|max:10240', 'organization_name' => 'nullable|string|max:255', 'email' => 'nullable|email|max:255|unique:sponsors,email', 'nationality' => 'nullable|string|max:100', 'city' => 'nullable|string|max:100', 'address' => 'nullable|string|max:255', 'country_code' => 'nullable|string|max:10', + 'license_expiry' => 'nullable|date_format:Y-m-d', ], [ 'mobile.unique' => 'This mobile number is already registered.', 'email.unique' => 'This email address is already registered.', @@ -55,7 +57,7 @@ public function register(Request $request) $email = "sponsor.{$mobileClean}." . rand(10, 99) . "@migrant.ae"; } - // Store license file + // Store files $destinationPath = public_path('uploads/licenses'); if (!file_exists($destinationPath)) { mkdir($destinationPath, 0755, true); @@ -66,9 +68,19 @@ public function register(Request $request) $licenseFile->move($destinationPath, $licenseFileName); $licensePath = 'uploads/licenses/' . $licenseFileName; + $emiratesIdPath = null; + if ($request->hasFile('emirates_id_file')) { + $emiratesIdFile = $request->file('emirates_id_file'); + $emiratesIdFileName = time() . '_emirates_id_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $emiratesIdFile->getClientOriginalName()); + $emiratesIdFile->move($destinationPath, $emiratesIdFileName); + $emiratesIdPath = 'uploads/licenses/' . $emiratesIdFileName; + } + $apiToken = Str::random(80); - $sponsor = DB::transaction(function () use ($request, $email, $licensePath, $apiToken) { + $licenseExpiry = $request->license_expiry ?? now()->addYears(3)->toDateString(); + + $sponsor = DB::transaction(function () use ($request, $email, $licensePath, $emiratesIdPath, $apiToken, $licenseExpiry) { return Sponsor::create([ 'full_name' => $request->full_name, 'organization_name' => $request->organization_name, @@ -80,6 +92,8 @@ public function register(Request $request) 'city' => $request->city, 'address' => $request->address, 'license_file' => $licensePath, + 'emirates_id_file' => $emiratesIdPath, + 'license_expiry' => $licenseExpiry, 'status' => 'active', 'is_verified' => false, 'subscription_status' => 'none', diff --git a/app/Http/Controllers/Api/SponsorController.php b/app/Http/Controllers/Api/SponsorController.php index 7da8172..5e59704 100644 --- a/app/Http/Controllers/Api/SponsorController.php +++ b/app/Http/Controllers/Api/SponsorController.php @@ -50,6 +50,9 @@ public function getDashboard(Request $request) 'is_verified' => $sponsor->is_verified, 'status' => $sponsor->status, 'license_file' => $sponsor->license_file ? asset($sponsor->license_file) : null, + 'emirates_id_file' => $sponsor->emirates_id_file ? asset($sponsor->emirates_id_file) : null, + 'license_expiry' => $sponsor->license_expiry ? $sponsor->license_expiry->toDateString() : null, + 'validity' => $sponsor->license_expiry ? ($sponsor->license_expiry->isFuture() ? 'Valid' : 'Expired') : 'Pending Review', 'joined_at' => $sponsor->created_at->toIso8601String(), ], 'recent_charity_events' => $recentEvents, @@ -279,6 +282,9 @@ public function getProfile(Request $request) 'is_verified' => $sponsor->is_verified, 'status' => $sponsor->status, 'license_file' => $sponsor->license_file ? asset($sponsor->license_file) : null, + 'emirates_id_file' => $sponsor->emirates_id_file ? asset($sponsor->emirates_id_file) : null, + 'license_expiry' => $sponsor->license_expiry ? $sponsor->license_expiry->toDateString() : null, + 'validity' => $sponsor->license_expiry ? ($sponsor->license_expiry->isFuture() ? 'Valid' : 'Expired') : 'Pending Review', 'joined_at' => $sponsor->created_at->toIso8601String(), ] ] diff --git a/app/Http/Controllers/Api/SupportTicketController.php b/app/Http/Controllers/Api/SupportTicketController.php index cbd29c3..8a11e7b 100644 --- a/app/Http/Controllers/Api/SupportTicketController.php +++ b/app/Http/Controllers/Api/SupportTicketController.php @@ -22,7 +22,7 @@ public function getTicketsForWorker(Request $request) return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401); } - $tickets = SupportTicket::where('worker_id', $worker->id) + $tickets = SupportTicket::with('reason')->where('worker_id', $worker->id) ->orderBy('created_at', 'desc') ->get() ->map(function ($ticket) { @@ -31,6 +31,9 @@ public function getTicketsForWorker(Request $request) 'ticket_number' => $ticket->ticket_number, 'subject' => $ticket->subject, 'description' => $ticket->description, + 'reason_id' => $ticket->reason_id, + 'reason_name' => $ticket->reason ? $ticket->reason->reason : null, + 'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null, 'status' => $ticket->status, 'priority' => $ticket->priority, 'created_at' => $ticket->created_at->toIso8601String(), @@ -54,6 +57,8 @@ public function createTicketFromWorker(Request $request) 'subject' => 'required|string|max:255', 'description' => 'required|string', 'priority' => 'nullable|string|in:low,medium,high', + 'reason_id' => 'nullable|exists:report_reasons,id', + 'voice_note' => 'nullable|file|mimes:mp3,wav,m4a,ogg,webm,aac,3gp,amr|max:10240', // 10MB limit ]); if ($validator->fails()) { @@ -64,15 +69,24 @@ public function createTicketFromWorker(Request $request) ], 422); } + $voiceNotePath = null; + if ($request->hasFile('voice_note')) { + $voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public'); + } + $ticket = SupportTicket::create([ 'ticket_number' => 'TKT-' . rand(100000, 999999), 'worker_id' => $worker->id, + 'reason_id' => $request->reason_id, 'subject' => $request->subject, 'description' => $request->description, + 'voice_note_path' => $voiceNotePath, 'priority' => $request->priority ?? 'medium', 'status' => 'open', ]); + $ticket->load('reason'); + return response()->json([ 'success' => true, 'message' => 'Ticket created successfully.', @@ -81,6 +95,9 @@ public function createTicketFromWorker(Request $request) 'ticket_number' => $ticket->ticket_number, 'subject' => $ticket->subject, 'description' => $ticket->description, + 'reason_id' => $ticket->reason_id, + 'reason_name' => $ticket->reason ? $ticket->reason->reason : null, + 'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null, 'status' => $ticket->status, 'priority' => $ticket->priority, 'created_at' => $ticket->created_at->toIso8601String(), @@ -280,7 +297,7 @@ public function getTicketDetail(Request $request, $id) return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401); } - $query = SupportTicket::where('id', $id); + $query = SupportTicket::with('reason')->where('id', $id); if ($worker) { $query->where('worker_id', $worker->id); } else { @@ -314,6 +331,9 @@ public function getTicketDetail(Request $request, $id) 'ticket_number' => $ticket->ticket_number, 'subject' => $ticket->subject, 'description' => $ticket->description, + 'reason_id' => $ticket->reason_id, + 'reason_name' => $ticket->reason ? $ticket->reason->reason : null, + 'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null, 'status' => $ticket->status, 'priority' => $ticket->priority, 'created_at' => $ticket->created_at->toIso8601String(), diff --git a/app/Http/Controllers/Api/WorkerAuthController.php b/app/Http/Controllers/Api/WorkerAuthController.php index b85be59..a11689a 100644 --- a/app/Http/Controllers/Api/WorkerAuthController.php +++ b/app/Http/Controllers/Api/WorkerAuthController.php @@ -762,7 +762,10 @@ public function nationalities(Request $request) // Apply Pagination $page = (int) $request->input('page', 1); - $perPage = (int) $request->input('per_page', 15); + $perPage = (int) $request->input('per_page', 500); + if ($perPage === 15) { + $perPage = 500; + } $total = count($list); $offset = ($page - 1) * $perPage; $paginatedList = array_slice($list, $offset, $perPage); @@ -780,4 +783,211 @@ public function nationalities(Request $request) ], ], 200); } + + /** + * Get list of preferred locations translated to requested language. + * Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta). + */ + public function preferredLocations(Request $request) + { + $lang = strtolower($request->input('lang') ?? $request->header('Accept-Language') ?? 'en'); + if (str_contains($lang, ',')) { + $lang = explode(',', $lang)[0]; + } + if (str_contains($lang, '-')) { + $lang = explode('-', $lang)[0]; + } + + $langMap = [ + 'hindi' => 'hi', + 'sawahi' => 'sw', + 'swahili' => 'sw', + 'taglo' => 'tl', + 'tagalog' => 'tl', + 'tamil' => 'ta', + ]; + + if (isset($langMap[$lang])) { + $lang = $langMap[$lang]; + } + + if (!in_array($lang, ['en', 'hi', 'sw', 'tl', 'ta'])) { + $lang = 'en'; + } + + $rawLocations = [ + ['code' => 'dubai', 'name' => 'Dubai', 'hi' => 'दुबई', 'sw' => 'Dubai', 'tl' => 'Dubai', 'ta' => 'துபாய்'], + ['code' => 'abu dhabi', 'name' => 'Abu Dhabi', 'hi' => 'अबू धाबी', 'sw' => 'Abu Dhabi', 'tl' => 'Abu Dhabi', 'ta' => 'அபுதாபி'], + ['code' => 'oman', 'name' => 'Oman', 'hi' => 'ओमान', 'sw' => 'Omani', 'tl' => 'Oman', 'ta' => 'ஓமன்'] + ]; + + $list = []; + foreach ($rawLocations as $item) { + $list[] = [ + 'code' => $item['code'], + 'name' => $item[$lang] ?? $item['name'], + ]; + } + + $search = strtolower($request->input('search') ?? $request->input('q') ?? ''); + if ($search !== '') { + $list = array_filter($list, function ($item) use ($search) { + return stripos($item['name'], $search) !== false || stripos($item['code'], $search) !== false; + }); + $list = array_values($list); + } + + // Apply Pagination + $page = (int) $request->input('page', 1); + $perPage = (int) $request->input('per_page', 15); + $total = count($list); + $offset = ($page - 1) * $perPage; + $paginatedList = array_slice($list, $offset, $perPage); + + return response()->json([ + 'success' => true, + 'data' => [ + 'preferred_locations' => $paginatedList, + 'pagination' => [ + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => $page, + 'last_page' => max(1, (int) ceil($total / $perPage)), + ] + ], + ], 200); + } + + /** + * Get list of areas for a specific preferred location translated to requested language. + * Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta). + */ + public function preferredLocationAreas(Request $request, $location = null) + { + // Get location from path or query parameter + $loc = strtolower(trim($location ?? $request->input('location') ?? $request->input('city') ?? '')); + + // Handle URL-friendly formats like abu-dhabi, abu_dhabi, abu dhabi + $loc = str_replace(['_', '-'], ' ', $loc); + + if (empty($loc)) { + return response()->json([ + 'success' => false, + 'message' => 'Please provide a valid location (e.g., dubai, abu dhabi, oman).' + ], 400); + } + + $lang = strtolower($request->input('lang') ?? $request->header('Accept-Language') ?? 'en'); + if (str_contains($lang, ',')) { + $lang = explode(',', $lang)[0]; + } + if (str_contains($lang, '-')) { + $lang = explode('-', $lang)[0]; + } + + $langMap = [ + 'hindi' => 'hi', + 'sawahi' => 'sw', + 'swahili' => 'sw', + 'taglo' => 'tl', + 'tagalog' => 'tl', + 'tamil' => 'ta', + ]; + + if (isset($langMap[$lang])) { + $lang = $langMap[$lang]; + } + + if (!in_array($lang, ['en', 'hi', 'sw', 'tl', 'ta'])) { + $lang = 'en'; + } + + $areasMapping = [ + 'dubai' => [ + ['code' => 'dubai', 'name' => 'Dubai', 'hi' => 'दुबई', 'sw' => 'Dubai', 'tl' => 'Dubai', 'ta' => 'துபாய்'], + ['code' => 'marina', 'name' => 'Dubai Marina', 'hi' => 'दुबई मरीना', 'sw' => 'Dubai Marina', 'tl' => 'Dubai Marina', 'ta' => 'துபாய் மெரினா'], + ['code' => 'barsha', 'name' => 'Al Barsha', 'hi' => 'अल बरशा', 'sw' => 'Al Barsha', 'tl' => 'Al Barsha', 'ta' => 'அல் பர்ஷா'], + ['code' => 'nahda', 'name' => 'Al Nahda', 'hi' => 'अल नहदा', 'sw' => 'Al Nahda', 'tl' => 'Al Nahda', 'ta' => 'அல் நஹ்தா'], + ['code' => 'jumeirah', 'name' => 'Jumeirah', 'hi' => 'जुमेराह', 'sw' => 'Jumeirah', 'tl' => 'Jumeirah', 'ta' => 'ஜுமேரா'], + ['code' => 'deira', 'name' => 'Deira', 'hi' => 'देइरा', 'sw' => 'Deira', 'tl' => 'Deira', 'ta' => 'தேரா'], + ['code' => 'downtown', 'name' => 'Downtown Dubai', 'hi' => 'डाउनटाउन दुबई', 'sw' => 'Downtown Dubai', 'tl' => 'Downtown Dubai', 'ta' => 'டவுன்டவுன் துபாய்'], + ['code' => 'silicon', 'name' => 'Silicon Oasis', 'hi' => 'सिलिकॉन ओएसिस', 'sw' => 'Silicon Oasis', 'tl' => 'Silicon Oasis', 'ta' => 'சிலிக்கான் ஓசிஸ்'], + ['code' => 'sports', 'name' => 'Sports City', 'hi' => 'स्पोर्ट्स सिटी', 'sw' => 'Sports City', 'tl' => 'Sports City', 'ta' => 'ஸ்போர்ட்ஸ் சிட்டி'], + ['code' => 'motor', 'name' => 'Motor City', 'hi' => 'मोटर सिटी', 'sw' => 'Motor City', 'tl' => 'Motor City', 'ta' => 'மோட்டார் சிட்டி'], + ['code' => 'jlt', 'name' => 'JLT', 'hi' => 'JLT', 'sw' => 'JLT', 'tl' => 'JLT', 'ta' => 'ஜே.எல்.டி'], + ['code' => 'jbr', 'name' => 'JBR', 'hi' => 'JBR', 'sw' => 'JBR', 'tl' => 'JBR', 'ta' => 'ஜே.பி.ஆர்'], + ['code' => 'meydan', 'name' => 'Meydan', 'hi' => 'मेदान', 'sw' => 'Meydan', 'tl' => 'Meydan', 'ta' => 'மெய்தான்'], + ['code' => 'ranches', 'name' => 'Arabian Ranches', 'hi' => 'अरेबियन रैंचेस', 'sw' => 'Arabian Ranches', 'tl' => 'Arabian Ranches', 'ta' => 'அரேபியன் ரான்சஸ்'], + ['code' => 'bay', 'name' => 'Business Bay', 'hi' => 'बिजनेस बे', 'sw' => 'Business Bay', 'tl' => 'Business Bay', 'ta' => 'பிசினஸ் பே'], + ['code' => 'mirdif', 'name' => 'Mirdif', 'hi' => 'मिर्डिफ', 'sw' => 'Mirdif', 'tl' => 'Mirdif', 'ta' => 'மிர்திஃப்'], + ['code' => 'quoz', 'name' => 'Al Quoz', 'hi' => 'अल क्वोज़', 'sw' => 'Al Quoz', 'tl' => 'Al Quoz', 'ta' => 'அல் கூஸ்'], + ], + 'abu dhabi' => [ + ['code' => 'abu dhabi', 'name' => 'Abu Dhabi City', 'hi' => 'अबू धाबी शहर', 'sw' => 'Abu Dhabi City', 'tl' => 'Abu Dhabi City', 'ta' => 'அபுதாபி நகரம்'], + ['code' => 'yas', 'name' => 'Yas Island', 'hi' => 'यास द्वीप', 'sw' => 'Yas Island', 'tl' => 'Yas Island', 'ta' => 'யாஸ் தீவு'], + ['code' => 'khalifa', 'name' => 'Khalifa City', 'hi' => 'खलीफा शहर', 'sw' => 'Khalifa City', 'tl' => 'Khalifa City', 'ta' => 'கலீஃபா நகரம்'], + ['code' => 'reem', 'name' => 'Reem Island', 'hi' => 'रीम द्वीप', 'sw' => 'Reem Island', 'tl' => 'Reem Island', 'ta' => 'ரீம் தீவு'], + ['code' => 'saadiyat', 'name' => 'Saadiyat Island', 'hi' => 'सादियात द्वीप', 'sw' => 'Saadiyat Island', 'tl' => 'Saadiyat Island', 'ta' => 'சாதியாத் தீவு'], + ['code' => 'raha', 'name' => 'Al Raha', 'hi' => 'अल राहा', 'sw' => 'Al Raha', 'tl' => 'Al Raha', 'ta' => 'அல் ராஹா'], + ['code' => 'mussafah', 'name' => 'Mussafah', 'hi' => 'मुसफ्फह', 'sw' => 'Mussafah', 'tl' => 'Mussafah', 'ta' => 'முஸாஃபா'], + ['code' => 'zahiyah', 'name' => 'Al Zahiyah', 'hi' => 'अल ज़ाहिया', 'sw' => 'Al Zahiyah', 'tl' => 'Al Zahiyah', 'ta' => 'அல் ஜாஹியா'], + ['code' => 'karamah', 'name' => 'Al Karamah', 'hi' => 'अल करमा', 'sw' => 'Al Karamah', 'tl' => 'Al Karamah', 'ta' => 'அல் கராமா'], + ], + 'oman' => [ + ['code' => 'oman', 'name' => 'Oman', 'hi' => 'ओमान', 'sw' => 'Omani', 'tl' => 'Oman', 'ta' => 'ஓமன்'], + ['code' => 'muscat', 'name' => 'Muscat', 'hi' => 'मस्कट', 'sw' => 'Muscat', 'tl' => 'Muscat', 'ta' => 'மस्कट'], + ['code' => 'salalah', 'name' => 'Salalah', 'hi' => 'सलालाह', 'sw' => 'Salalah', 'tl' => 'Salalah', 'ta' => 'சலாலா'], + ['code' => 'sohar', 'name' => 'Sohar', 'hi' => 'सोहर', 'sw' => 'Sohar', 'tl' => 'Sohar', 'ta' => 'சோஹார்'], + ['code' => 'nizwa', 'name' => 'Nizwa', 'hi' => 'निज़वा', 'sw' => 'Nizwa', 'tl' => 'Nizwa', 'ta' => 'நிஸ்வா'], + ['code' => 'sur', 'name' => 'Sur', 'hi' => 'सुर', 'sw' => 'Sur', 'tl' => 'Sur', 'ta' => 'சூர்'], + ['code' => 'ibri', 'name' => 'Ibri', 'hi' => 'इबरी', 'sw' => 'Ibri', 'tl' => 'Ibri', 'ta' => 'இப்ரி'], + ['code' => 'rustaq', 'name' => 'Rustaq', 'hi' => 'रुस्तक़', 'sw' => 'Rustaq', 'tl' => 'Rustaq', 'ta' => 'रुस्तक़'], + ] + ]; + + if (!isset($areasMapping[$loc])) { + return response()->json([ + 'success' => false, + 'message' => 'Areas for the specified location were not found. Supported locations: dubai, abu dhabi, oman.' + ], 404); + } + + $rawAreas = $areasMapping[$loc]; + $list = []; + foreach ($rawAreas as $item) { + $list[] = [ + 'code' => $item['code'], + 'name' => $item[$lang] ?? $item['name'], + ]; + } + + $search = strtolower($request->input('search') ?? $request->input('q') ?? ''); + if ($search !== '') { + $list = array_filter($list, function ($item) use ($search) { + return stripos($item['name'], $search) !== false || stripos($item['code'], $search) !== false; + }); + $list = array_values($list); + } + + // Apply Pagination + $page = (int) $request->input('page', 1); + $perPage = (int) $request->input('per_page', 15); + $total = count($list); + $offset = ($page - 1) * $perPage; + $paginatedList = array_slice($list, $offset, $perPage); + + return response()->json([ + 'success' => true, + 'data' => [ + 'location' => $loc, + 'areas' => $paginatedList, + 'pagination' => [ + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => $page, + 'last_page' => max(1, (int) ceil($total / $perPage)), + ] + ], + ], 200); + } } diff --git a/app/Http/Controllers/Api/WorkerProfileController.php b/app/Http/Controllers/Api/WorkerProfileController.php index 2020713..c8d1577 100644 --- a/app/Http/Controllers/Api/WorkerProfileController.php +++ b/app/Http/Controllers/Api/WorkerProfileController.php @@ -71,6 +71,7 @@ public function updateProfile(Request $request) 'country' => 'nullable|string|max:100', 'city' => 'nullable|string|max:100', 'area' => 'nullable|string|max:100', + 'fcm_token' => 'nullable|string|max:255', ]); if ($validator->fails()) { @@ -102,7 +103,8 @@ public function updateProfile(Request $request) 'preferred_location', 'country', 'city', - 'area' + 'area', + 'fcm_token' ]); // Filter out null inputs if we want to support partial updates diff --git a/app/Http/Controllers/Employer/CandidateController.php b/app/Http/Controllers/Employer/CandidateController.php index f7e5577..4012932 100644 --- a/app/Http/Controllers/Employer/CandidateController.php +++ b/app/Http/Controllers/Employer/CandidateController.php @@ -124,12 +124,22 @@ public function index(Request $request) $locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc))); $locsArray = array_map('strtolower', $locsArray); - $mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($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'] + ]; + + $mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($locsArray, $locationMapping) { if (!isset($c['preferred_location'])) return false; $wLoc = strtolower($c['preferred_location']); foreach ($locsArray as $l) { - if (str_contains($wLoc, $l)) { - return true; + if ($l === 'all') return true; + $allowedKeywords = $locationMapping[$l] ?? [$l]; + foreach ($allowedKeywords as $keyword) { + if (str_contains($wLoc, $keyword)) { + return true; + } } } return false; @@ -189,6 +199,13 @@ public function index(Request $request) })); } + if ($request->filled('gender')) { + $gender = strtolower($request->gender); + $mergedCandidates = array_values(array_filter($mergedCandidates, 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 !== '') { @@ -250,8 +267,13 @@ public function index(Request $request) })); } + $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(); + return Inertia::render('Employer/SelectedCandidates', [ 'selectedWorkers' => $mergedCandidates, + 'allNationalities' => $allNationalities, ]); } diff --git a/app/Http/Controllers/Employer/EmployerAuthController.php b/app/Http/Controllers/Employer/EmployerAuthController.php index 5a8b4a0..73c1903 100644 --- a/app/Http/Controllers/Employer/EmployerAuthController.php +++ b/app/Http/Controllers/Employer/EmployerAuthController.php @@ -85,6 +85,7 @@ public function register(Request $request) 'name' => 'required|string|max:255', 'email' => 'required|string|email|max:255', 'phone' => 'required|string|regex:/^[0-9]{7,15}$/', + 'emirates_id_file' => 'nullable|file|mimes:jpeg,png,jpg,pdf|max:10240', ], [ 'phone.regex' => 'The mobile number must be between 7 and 15 digits without any country code (e.g. 501234567).', ]); @@ -98,6 +99,19 @@ public function register(Request $request) ], 409); } + // Store file if provided + $emiratesIdPath = null; + if ($request->hasFile('emirates_id_file')) { + $destinationPath = public_path('uploads/licenses'); + if (!file_exists($destinationPath)) { + mkdir($destinationPath, 0755, true); + } + $emiratesIdFile = $request->file('emirates_id_file'); + $emiratesIdFileName = time() . '_emirates_id_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $emiratesIdFile->getClientOriginalName()); + $emiratesIdFile->move($destinationPath, $emiratesIdFileName); + $emiratesIdPath = 'uploads/licenses/' . $emiratesIdFileName; + } + // 3. Generate 6-digit OTP, hash & store with 10-min expiry $otp = (string) mt_rand(100000, 999999); $hashedOtp = Hash::make($otp); @@ -108,6 +122,7 @@ public function register(Request $request) 'name' => $request->name, 'email' => $request->email, 'phone' => $request->phone, + 'emirates_id_file' => $emiratesIdPath, ], 'employer_otp' => [ 'hash' => $hashedOtp, @@ -172,8 +187,8 @@ public function verifyEmail(Request $request) ]); } - // Hash comparison (allow 000000 or 111111 in local environment for automated testing) - $isLocalDebug = app()->environment('local') && ($request->otp === '000000' || $request->otp === '111111'); + // Hash comparison (allow 000000 or 111111 in local/testing environment for automated testing) + $isLocalDebug = (app()->environment('local') || app()->environment('testing')) && ($request->otp === '000000' || $request->otp === '111111'); if (!$isLocalDebug && !Hash::check($request->otp, $otpSession['hash'])) { $otpSession['attempts']++; session(['employer_otp' => $otpSession]); @@ -374,6 +389,7 @@ public function createPassword(Request $request) 'otp_verified_at' => now(), 'status' => 'active', 'last_login_at' => now(), + 'emirates_id_file' => $pending['emirates_id_file'] ?? null, ]); // Create active subscription in database diff --git a/app/Http/Controllers/Employer/MessageController.php b/app/Http/Controllers/Employer/MessageController.php index 12a0f35..87643ea 100644 --- a/app/Http/Controllers/Employer/MessageController.php +++ b/app/Http/Controllers/Employer/MessageController.php @@ -235,6 +235,21 @@ public function send(Request $request, $id) // Touch conversation updated_at for sorting $conv->touch(); + // Dispatch push notification to worker + $worker = $conv->worker; + if ($worker && $worker->fcm_token) { + \App\Services\FCMService::sendPushNotification( + $worker->fcm_token, + "New Message from " . ($user->name ?? "Employer"), + $request->text ?: "Sent an attachment", + [ + 'conversation_id' => $conv->id, + 'sender_type' => 'employer', + 'sender_id' => $user->id, + ] + ); + } + return back(); } diff --git a/app/Http/Controllers/Employer/WorkerController.php b/app/Http/Controllers/Employer/WorkerController.php index 9053c4d..14d03c6 100644 --- a/app/Http/Controllers/Employer/WorkerController.php +++ b/app/Http/Controllers/Employer/WorkerController.php @@ -123,12 +123,22 @@ public function index(Request $request) $locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc))); $locsArray = array_map('strtolower', $locsArray); - $workers = array_values(array_filter($workers, function ($c) use ($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'] + ]; + + $workers = array_values(array_filter($workers, function ($c) use ($locsArray, $locationMapping) { if (!isset($c['preferred_location'])) return false; $wLoc = strtolower($c['preferred_location']); foreach ($locsArray as $l) { - if (str_contains($wLoc, $l)) { - return true; + if ($l === 'all') return true; + $allowedKeywords = $locationMapping[$l] ?? [$l]; + foreach ($allowedKeywords as $keyword) { + if (str_contains($wLoc, $keyword)) { + return true; + } } } return false; @@ -187,7 +197,12 @@ public function index(Request $request) return false; })); } - + if ($request->filled('gender')) { + $gender = strtolower($request->gender); + $workers = array_values(array_filter($workers, 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 !== '') { @@ -253,7 +268,9 @@ public function index(Request $request) $shortlistedIds = Shortlist::where('employer_id', $employerId)->pluck('worker_id')->toArray(); $dbCategories = WorkerCategory::pluck('name')->toArray(); - $dbNationalities = Worker::distinct()->pluck('nationality')->toArray(); + $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 = [ 'categories' => array_merge(['All Categories'], $dbCategories), diff --git a/app/Models/Sponsor.php b/app/Models/Sponsor.php index 418d827..e878105 100644 --- a/app/Models/Sponsor.php +++ b/app/Models/Sponsor.php @@ -32,6 +32,8 @@ class Sponsor extends Authenticatable 'last_login_at', 'api_token', 'license_file', + 'emirates_id_file', + 'license_expiry', ]; protected $hidden = [ @@ -45,6 +47,7 @@ class Sponsor extends Authenticatable 'subscription_start_date' => 'datetime', 'subscription_end_date' => 'datetime', 'last_login_at' => 'datetime', + 'license_expiry' => 'datetime', ]; public function hasActiveSubscription(): bool diff --git a/app/Models/SupportTicket.php b/app/Models/SupportTicket.php index 691a866..6df61e9 100644 --- a/app/Models/SupportTicket.php +++ b/app/Models/SupportTicket.php @@ -16,6 +16,7 @@ class SupportTicket extends Model 'worker_id', 'subject', 'description', + 'voice_note_path', 'status', 'priority', ]; diff --git a/app/Models/Worker.php b/app/Models/Worker.php index acc8b7a..28cbb7d 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -36,6 +36,7 @@ class Worker extends Model 'in_country', 'visa_status', 'preferred_job_type', + 'fcm_token', ]; protected $hidden = [ diff --git a/app/Services/FCMService.php b/app/Services/FCMService.php new file mode 100644 index 0000000..29706e0 --- /dev/null +++ b/app/Services/FCMService.php @@ -0,0 +1,155 @@ + 'RS256', 'typ' => 'JWT']); + + $now = time(); + $payload = json_encode([ + 'iss' => $clientEmail, + 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', + 'aud' => 'https://oauth2.googleapis.com/token', + 'exp' => $now + 3600, + 'iat' => $now, + ]); + + $base64UrlHeader = self::base64UrlEncode($header); + $base64UrlPayload = self::base64UrlEncode($payload); + + $signatureInput = $base64UrlHeader . '.' . $base64UrlPayload; + $signature = ''; + + if (!openssl_sign($signatureInput, $signature, $privateKey, OPENSSL_ALGO_SHA256)) { + Log::error("Failed to sign JWT with private key using openssl_sign."); + return null; + } + + $base64UrlSignature = self::base64UrlEncode($signature); + $jwtToken = $signatureInput . '.' . $base64UrlSignature; + + $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwtToken, + ]); + + if ($response->failed()) { + Log::error("FCM OAuth2 request failed: " . $response->body()); + return null; + } + + $data = $response->json(); + return $data['access_token'] ?? null; + + } catch (\Exception $e) { + Log::error("FCM Access Token Generation Error: " . $e->getMessage()); + return null; + } + }); + } + + /** + * Send a Push Notification to a specific FCM token. + * + * @param string $token + * @param string $title + * @param string $body + * @param array $data + * @return bool + */ + public static function sendPushNotification($token, $title, $body, array $data = []) + { + if (empty($token)) { + return false; + } + + try { + $accessToken = self::getAccessToken(); + if (!$accessToken) { + Log::error("Cannot send push notification: FCM Access Token is null."); + return false; + } + + // Load credentials to fetch the correct project_id + $credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json'); + $credentials = json_decode(file_get_contents($credentialsPath), true); + $projectId = $credentials['project_id'] ?? 'migrant-fe5a7'; + + // Convert all data array values to strings as required by FCM v1 + $formattedData = []; + foreach ($data as $key => $value) { + $formattedData[(string)$key] = (string)$value; + } + + $payload = [ + 'message' => [ + 'token' => $token, + 'notification' => [ + 'title' => $title, + 'body' => $body, + ], + ] + ]; + + if (!empty($formattedData)) { + $payload['message']['data'] = $formattedData; + } + + $response = Http::withToken($accessToken) + ->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", $payload); + + if ($response->failed()) { + Log::error("FCM Push Notification failed: " . $response->body()); + return false; + } + + return true; + + } catch (\Exception $e) { + Log::error("FCM Notification Dispatch Failure: " . $e->getMessage()); + return false; + } + } + + /** + * Base64 Url Encode helper. + * + * @param string $data + * @return string + */ + private static function base64UrlEncode($data) + { + return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data)); + } +} diff --git a/database/migrations/2026_06_12_181000_add_emirates_id_file_to_sponsors_table.php b/database/migrations/2026_06_12_181000_add_emirates_id_file_to_sponsors_table.php new file mode 100644 index 0000000..96de29a --- /dev/null +++ b/database/migrations/2026_06_12_181000_add_emirates_id_file_to_sponsors_table.php @@ -0,0 +1,30 @@ +string('emirates_id_file')->nullable()->after('license_file'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sponsors', function (Blueprint $table) { + $table->dropColumn('emirates_id_file'); + }); + } +}; diff --git a/database/migrations/2026_06_12_185000_add_license_expiry_to_sponsors_table.php b/database/migrations/2026_06_12_185000_add_license_expiry_to_sponsors_table.php new file mode 100644 index 0000000..2759045 --- /dev/null +++ b/database/migrations/2026_06_12_185000_add_license_expiry_to_sponsors_table.php @@ -0,0 +1,30 @@ +date('license_expiry')->nullable()->after('license_file'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sponsors', function (Blueprint $table) { + $table->dropColumn('license_expiry'); + }); + } +}; diff --git a/database/migrations/2026_06_13_000000_add_fcm_token_to_workers_table.php b/database/migrations/2026_06_13_000000_add_fcm_token_to_workers_table.php new file mode 100644 index 0000000..2043ed9 --- /dev/null +++ b/database/migrations/2026_06_13_000000_add_fcm_token_to_workers_table.php @@ -0,0 +1,28 @@ +string('fcm_token')->nullable()->after('api_token'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workers', function (Blueprint $table) { + $table->dropColumn('fcm_token'); + }); + } +}; diff --git a/database/migrations/2026_06_13_000001_add_voice_note_path_to_support_tickets_table.php b/database/migrations/2026_06_13_000001_add_voice_note_path_to_support_tickets_table.php new file mode 100644 index 0000000..eb3105c --- /dev/null +++ b/database/migrations/2026_06_13_000001_add_voice_note_path_to_support_tickets_table.php @@ -0,0 +1,28 @@ +string('voice_note_path')->nullable()->after('description'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('support_tickets', function (Blueprint $table) { + $table->dropColumn('voice_note_path'); + }); + } +}; diff --git a/migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json b/migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json new file mode 100644 index 0000000..3a8d331 --- /dev/null +++ b/migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "migrant-fe5a7", + "private_key_id": "f8b365ecd04e9a5382841a837826ec6f3a4ddab0", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDoXYB0utGboyNW\nQJcvU3ARTwYGXD4qBNNgWY7z3mJkFdbiaeGp0D1esTuucYb8h2ldS3CURREAZXVG\nS55y26kXTPi4leBnEtEy7h2Mdp03EKQg3rJhwKL+u9KjO6s15Pa85umVNkIW1bNt\n/NgToQ8nx7rZpHJMsJM9jgVpFa/7++ugpcGwz41OeurrGtoeAlwOLa9aEFCNIJJ5\nESu6qra1tIr3LPOhIwj2xhQ2DSxSepEKrQmwZbBT1ak78NnHDqn3KSZKRvM7AgqD\nCtJ3rfATHhriJzEOLaUMAn6lhDwfOwi79lVIUSRq7S9jyI8qsyzh+xNLf2x3R/tu\n4bDrHUnlAgMBAAECggEAFaEyhmsnfNHcRbigeh7NtUbCXFA01yt406Syil/ej8t6\no7vQbkK8m7ZqxEu8EYC3XaBam+qr8PoAxyjd2Ho7DLi1BFWrPF0DjTaeGIeoDcqm\nRIsGVtQcOBMawegZgVxNXXsvLh0NXNvKpofdQ0KASycr7PuCSkI7ioTQWx5SMeXt\ncSDwD/H9gs2xg2uToG2ggKWMA1akAV4JibNvGGolzgJ/BHUQv2IbPKTtw0k2oRVr\nir7ztVlZh2QRpdxQSvb4VMz0jtYKUIF2vw9QcH+nzgBzjR//htvz7kYSBO6euSIm\ng4r/+Q01bcWx2FHrf7B1FIGrXDgxXptQF/Ou1nGJxwKBgQD93H0ZnLWRohd/8Koa\nM1ll1+CUgFNQn2RwthJi5yEgYXtAB6uWYph2RL6586b6JXl786Si5fZ6WzPU/pZP\n09IRaHnpiPT2uHkXTCT+KkhpJvvu8nH2CplIqXSnCiwDO7Loq6fshl4em9Zg6+Tu\nk7ofJaeKqW5zmlTyI0cJT+XjpwKBgQDqUqbgJaPgvcYVGDseNYNCnd2fFUyl9T6f\nSU0ubU2yGjec70rCwb9GRNT//H0R30kGJNbSwDwapIha/QD7UxeHjPkUGdwVNCKM\nQV19MYH3O1mv/j8N3fBasmk8zWbMUWCRdnIOiIkF5Md4FdRm7UtP9Lxy4SgXJfrK\njGJz/E4HkwKBgGBN6u9ycbcDxOJ2TDGQVVO7Z6kuXWzyasoPaD447Go6UOVjg4aT\ndL85KRmmAyxWVxXcwhJCJxUX0Dv3MNKryr9r4QPlPvjx1o4uBsKdC8dIUL6/Hth2\nANx93JEZ3MSFO0PlCtlByCbYe6VdGAYh6LO0NzD1Qb99Rshs9Z/kvZN1AoGBAJJC\nkuz4MalXayvBmy0JA+xx82KX/ebdBICSVX20NjoESVBIwOZ93vFyh21dYYflUoRm\nPD0CRsHujzoUECfPvrEaWmKknY4So7neFwfM/i2euyWyUhNKw/sov613HEJOTTOe\ntiTCLp0iJyuanKC+XzMCNRqT1d1VFIyXQeDZzK7HAoGAGC98Oz+z4z57IRhqdpTN\nGA2D+xitrQtEVKcq0RWuOM8aIxAGPfaAmJ+ec7vagy1xpohOtF8bAne9GvSH01nN\nbegp001RqmcHPCoqv9SzqJUZcEMG7M89NBvn1uL6hfmnMkZIljFoA8u0b5NzGy58\nvp3dka7/U12lo5n1guQF1Hk=\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-fbsvc@migrant-fe5a7.iam.gserviceaccount.com", + "client_id": "114003929019295227149", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40migrant-fe5a7.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/public/swagger.json b/public/swagger.json index 8ba9b42..b8fd2e1 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -52,6 +52,11 @@ "format": "binary", "description": "Organization or trade license document (jpg/png/pdf, max 10MB). (REQUIRED)" }, + "emirates_id_file": { + "type": "string", + "format": "binary", + "description": "Emirates ID document file (jpg/png/pdf, max 10MB). (OPTIONAL)" + }, "organization_name": { "type": "string", "example": "Al-Rashidi Charitable Foundation", @@ -787,9 +792,9 @@ "in": "query", "schema": { "type": "integer", - "default": 15 + "default": 500 }, - "description": "Number of nationalities to return per page" + "description": "Number of nationalities to return per page. Defaults to 500 on the backend to retrieve all nationalities by default." } ], "responses": { @@ -854,6 +859,386 @@ } } }, + "/preferred-locations": { + "get": { + "tags": [ + "General" + ], + "summary": "Get Localized Preferred Locations List", + "description": "Returns a list of preferred locations (Dubai, Abu Dhabi, Oman) translated to the requested language. Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).", + "security": [], + "parameters": [ + { + "name": "lang", + "in": "query", + "schema": { + "type": "string", + "default": "en" + }, + "description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')" + }, + { + "name": "Accept-Language", + "in": "header", + "schema": { + "type": "string" + }, + "description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')" + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Search keyword to filter locations by name or code" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + }, + "description": "Page number for pagination" + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": "integer", + "default": 15 + }, + "description": "Number of locations to return per page" + } + ], + "responses": { + "200": { + "description": "Preferred locations list retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "preferred_locations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "example": "dubai" + }, + "name": { + "type": "string", + "example": "Dubai" + } + } + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "example": 3 + }, + "per_page": { + "type": "integer", + "example": 15 + }, + "current_page": { + "type": "integer", + "example": 1 + }, + "last_page": { + "type": "integer", + "example": 1 + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/preferred-locations/{location}/areas": { + "get": { + "tags": [ + "General" + ], + "summary": "Get Localized Areas for a Preferred Location (Path Parameter)", + "description": "Returns a list of localized neighborhoods or sub-locations mapped under a specific preferred location (Dubai, Abu Dhabi, Oman) using path routing.", + "security": [], + "parameters": [ + { + "name": "location", + "in": "path", + "required": true, + "description": "The preferred location name (e.g. dubai, abu dhabi, oman).", + "schema": { + "type": "string" + } + }, + { + "name": "lang", + "in": "query", + "schema": { + "type": "string", + "default": "en" + }, + "description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')" + }, + { + "name": "Accept-Language", + "in": "header", + "schema": { + "type": "string" + }, + "description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')" + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Search keyword to filter areas by name or code" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + }, + "description": "Page number for pagination" + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": "integer", + "default": 15 + }, + "description": "Number of areas to return per page" + } + ], + "responses": { + "200": { + "description": "Areas retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "location": { + "type": "string", + "example": "dubai" + }, + "areas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "example": "marina" + }, + "name": { + "type": "string", + "example": "Dubai Marina" + } + } + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "example": 17 + }, + "per_page": { + "type": "integer", + "example": 15 + }, + "current_page": { + "type": "integer", + "example": 1 + }, + "last_page": { + "type": "integer", + "example": 2 + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/preferred-locations/areas": { + "get": { + "tags": [ + "General" + ], + "summary": "Get Localized Areas for a Preferred Location (Query Parameter)", + "description": "Returns a list of localized neighborhoods or sub-locations mapped under a specific preferred location (Dubai, Abu Dhabi, Oman) using query parameter routing.", + "security": [], + "parameters": [ + { + "name": "city", + "in": "query", + "required": false, + "description": "The preferred location name (e.g. dubai, abu dhabi, oman).", + "schema": { + "type": "string" + } + }, + { + "name": "location", + "in": "query", + "required": false, + "description": "Alternative key for location name (e.g. dubai, abu dhabi, oman).", + "schema": { + "type": "string" + } + }, + { + "name": "lang", + "in": "query", + "schema": { + "type": "string", + "default": "en" + }, + "description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')" + }, + { + "name": "Accept-Language", + "in": "header", + "schema": { + "type": "string" + }, + "description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')" + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Search keyword to filter areas by name or code" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + }, + "description": "Page number for pagination" + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": "integer", + "default": 15 + }, + "description": "Number of areas to return per page" + } + ], + "responses": { + "200": { + "description": "Areas retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "location": { + "type": "string", + "example": "dubai" + }, + "areas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "example": "marina" + }, + "name": { + "type": "string", + "example": "Dubai Marina" + } + } + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "example": 17 + }, + "per_page": { + "type": "integer", + "example": 15 + }, + "current_page": { + "type": "integer", + "example": 1 + }, + "last_page": { + "type": "integer", + "example": 2 + } + } + } + } + } + } + } + } + } + } + } + } + }, "/workers/send-otp": { "post": { "tags": [ @@ -1180,6 +1565,11 @@ 6, 8 ] + }, + "fcm_token": { + "type": "string", + "example": "fcm-device-token-xyz-123", + "description": "Firebase Cloud Messaging token for push notifications." } } } @@ -1777,7 +2167,7 @@ "requestBody": { "required": true, "content": { - "application/json": { + "multipart/form-data": { "schema": { "type": "object", "required": [ @@ -1801,6 +2191,16 @@ "high" ], "example": "medium" + }, + "reason_id": { + "type": "integer", + "example": 1, + "description": "Optional report/support reason ID." + }, + "voice_note": { + "type": "string", + "format": "binary", + "description": "Optional audio/voice note file attachment." } } } @@ -2778,7 +3178,16 @@ "name": "nationality", "in": "query", "required": false, - "description": "Filter by nationality name.", + "description": "Filter by nationality name (comma-separated list for multiple choice, e.g. India,Kenya,Ethiopia).", + "schema": { + "type": "string" + } + }, + { + "name": "gender", + "in": "query", + "required": false, + "description": "Filter by gender (e.g. male, female).", "schema": { "type": "string" } @@ -3078,7 +3487,16 @@ "name": "nationality", "in": "query", "required": false, - "description": "Filter candidates by nationality name.", + "description": "Filter candidates by nationality name (comma-separated list for multiple choice, e.g. India,Kenya,Ethiopia).", + "schema": { + "type": "string" + } + }, + { + "name": "gender", + "in": "query", + "required": false, + "description": "Filter candidates by gender (e.g. male, female).", "schema": { "type": "string" } @@ -3964,6 +4382,7 @@ "is_verified": {"type": "boolean", "example": false}, "status": {"type": "string", "example": "active"}, "license_file": {"type": "string", "example": "uploads/licenses/1234567890_license_trade.pdf"}, + "emirates_id_file": {"type": "string", "example": "uploads/licenses/1234567890_emirates_id.pdf"}, "created_at": {"type": "string", "format": "date-time"} } }, diff --git a/resources/js/Pages/Admin/Support/Show.jsx b/resources/js/Pages/Admin/Support/Show.jsx index 573a4af..ac287d9 100644 --- a/resources/js/Pages/Admin/Support/Show.jsx +++ b/resources/js/Pages/Admin/Support/Show.jsx @@ -115,12 +115,34 @@ export default function Show({ ticket, replies }) {
+ {ticket.reason_name && ( +
+ Support Reason + + {ticket.reason_name} + +
+ )} +
Original Ticket Message
{ticket.description}
+ + {ticket.voice_note_url && ( +
+ Voice Note Attachment +
+
+
+ )}
diff --git a/resources/js/Pages/Employer/Auth/Register.jsx b/resources/js/Pages/Employer/Auth/Register.jsx index d5e6bad..d1d32e9 100644 --- a/resources/js/Pages/Employer/Auth/Register.jsx +++ b/resources/js/Pages/Employer/Auth/Register.jsx @@ -14,6 +14,7 @@ export default function Register() { name: '', email: '', phone: '', + emirates_id_file: null, }); const [loading, setLoading] = useState(false); @@ -60,8 +61,20 @@ export default function Register() { setLoading(true); + const formData = new FormData(); + formData.append('name', data.name); + formData.append('email', data.email); + formData.append('phone', data.phone); + if (data.emirates_id_file) { + formData.append('emirates_id_file', data.emirates_id_file); + } + try { - await axios.post('/employer/register', data); + await axios.post('/employer/register', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); toast.success('Registration details saved! Verification code sent to your email.'); router.visit('/employer/verify-email'); } catch (err) { @@ -247,6 +260,26 @@ export default function Register() { )} + {/* Emirates ID File */} +
+ + handleInputChange('emirates_id_file', e.target.files[0])} + className={`w-full px-3.5 py-2.5 rounded-xl border text-sm focus:outline-none focus:ring-2 transition-all file:mr-4 file:py-1 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-[#185FA5]/10 file:text-[#185FA5] hover:file:bg-[#185FA5]/20 cursor-pointer ${ + errors.emirates_id_file + ? 'border-red-500 focus:ring-red-500/20 focus:border-red-500' + : 'border-slate-300 focus:ring-[#185FA5]/20 focus:border-[#185FA5]' + }`} + /> + {errors.emirates_id_file && ( +

+ {Array.isArray(errors.emirates_id_file) ? errors.emirates_id_file[0] : errors.emirates_id_file} +

+ )} +
+ - ); - })} +
+ {items.length > 5 && ( +
+ setSearch(e.target.value)} + placeholder={placeholder} + className="w-full px-3 py-1.5 bg-slate-50/50 border border-slate-200 rounded-lg text-xs font-semibold text-slate-700 focus:ring-2 focus:ring-blue-100 focus:border-[#185FA5] outline-none transition-all placeholder:text-slate-400 placeholder:font-normal" + /> + {search && ( + + )} +
+ )} +
+ {filteredItems.map(item => { + const isChecked = selected.includes(item); + return ( + + ); + })} + {filteredItems.length === 0 && ( +
No matches found
+ )} +
); } diff --git a/routes/api.php b/routes/api.php index 9ffce2c..e587d9c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -28,6 +28,9 @@ Route::get('/workers/config', [WorkerAuthController::class, 'config']); Route::get('/workers/skills', [WorkerAuthController::class, 'skills']); Route::get('/nationalities', [WorkerAuthController::class, 'nationalities']); +Route::get('/preferred-locations', [WorkerAuthController::class, 'preferredLocations']); +Route::get('/preferred-locations/areas', [WorkerAuthController::class, 'preferredLocationAreas']); +Route::get('/preferred-locations/{location}/areas', [WorkerAuthController::class, 'preferredLocationAreas']); Route::post('/workers/send-otp', [WorkerAuthController::class, 'sendOtp']); Route::post('/workers/verify-otp', [WorkerAuthController::class, 'verifyOtp']); Route::post('/workers/setup-profile', [WorkerAuthController::class, 'setupProfile']); diff --git a/tests/Feature/EmployerMessageApiTest.php b/tests/Feature/EmployerMessageApiTest.php index b78b522..4fd84e0 100644 --- a/tests/Feature/EmployerMessageApiTest.php +++ b/tests/Feature/EmployerMessageApiTest.php @@ -298,4 +298,40 @@ public function test_unauthenticated_requests_are_blocked() $response = $this->getJson('/api/employers/conversations'); $response->assertStatus(401); } + + /** + * Test employer message triggers push notification. + */ + public function test_employer_message_triggers_push_notification() + { + \Illuminate\Support\Facades\Http::fake([ + 'https://oauth2.googleapis.com/token' => \Illuminate\Support\Facades\Http::response(['access_token' => 'mocked-fcm-token'], 200), + 'https://fcm.googleapis.com/*' => \Illuminate\Support\Facades\Http::response(['name' => 'projects/migrant-fe5a7/messages/mock-id'], 200), + ]); + + // Set fcm_token on worker + $this->worker->update(['fcm_token' => 'test-device-fcm-token-xyz']); + + // Create conversation + $conversation = Conversation::create([ + 'employer_id' => $this->employer->id, + 'worker_id' => $this->worker->id, + ]); + + // Reply request + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + ])->postJson('/api/employers/conversations/' . $conversation->id . '/messages', [ + 'text' => 'Testing push notifications!' + ]); + + $response->assertStatus(201); + + // Assert Http call was made to FCM endpoint + \Illuminate\Support\Facades\Http::assertSent(function ($request) { + return str_contains($request->url(), 'fcm.googleapis.com') && + $request['message']['token'] === 'test-device-fcm-token-xyz' && + $request['message']['notification']['body'] === 'Testing push notifications!'; + }); + } } diff --git a/tests/Feature/EmployerWorkerFilterApiTest.php b/tests/Feature/EmployerWorkerFilterApiTest.php index f0616e1..150a3d4 100644 --- a/tests/Feature/EmployerWorkerFilterApiTest.php +++ b/tests/Feature/EmployerWorkerFilterApiTest.php @@ -40,7 +40,7 @@ protected function setUp(): void ]); // Seed 3 workers with specific filter attributes - // Worker 1: Dubai, full-time, live-in, India, in_country=true, Residence Visa + // Worker 1: Dubai, full-time, live-in, India, in_country=true, Residence Visa, female $w1 = Worker::create([ 'name' => 'Worker India In Dubai', 'email' => 'worker1@example.com', @@ -56,6 +56,7 @@ protected function setUp(): void 'in_country' => true, 'visa_status' => 'Residence Visa', 'age' => 25, + 'gender' => 'female', 'salary' => 1500, 'experience' => '3 Years', 'religion' => 'Christian', @@ -70,7 +71,7 @@ protected function setUp(): void 'file_path' => 'passports/test1.jpg', ]); - // Worker 2: Abu Dhabi, part-time, live-out, Philippines, in_country=true, Tourist Visa + // Worker 2: Abu Dhabi, part-time, live-out, Philippines, in_country=true, Tourist Visa, female $w2 = Worker::create([ 'name' => 'Worker Philippines In Abu Dhabi', 'email' => 'worker2@example.com', @@ -86,6 +87,7 @@ protected function setUp(): void 'in_country' => true, 'visa_status' => 'Tourist Visa', 'age' => 28, + 'gender' => 'female', 'salary' => 1800, 'experience' => '2 Years', 'religion' => 'Christian', @@ -100,7 +102,7 @@ protected function setUp(): void 'file_path' => 'passports/test2.jpg', ]); - // Worker 3: Dubai, full-time, live-out, Kenya, in_country=false, Cancelled Visa + // Worker 3: Dubai, full-time, live-out, Kenya, in_country=false, Cancelled Visa, male $w3 = Worker::create([ 'name' => 'Worker Kenya Out Country', 'email' => 'worker3@example.com', @@ -116,6 +118,7 @@ protected function setUp(): void 'in_country' => false, 'visa_status' => 'Cancelled Visa', 'age' => 30, + 'gender' => 'male', 'salary' => 1200, 'experience' => 'None', 'religion' => 'Christian', @@ -314,5 +317,35 @@ public function test_get_candidates_filters() $candidates = $response->json('data.candidates'); $this->assertCount(1, $candidates); $this->assertEquals('Worker Philippines In Abu Dhabi', $candidates[0]['name']); + + // Filter candidates by gender + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + ])->getJson('/api/employers/candidates?gender=male'); + + $response->assertStatus(200); + $candidates = $response->json('data.candidates'); + $this->assertCount(1, $candidates); + $this->assertEquals('Worker Kenya Out Country', $candidates[0]['name']); + } + + public function test_get_workers_gender_filter() + { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + ])->getJson('/api/employers/workers?gender=male'); + + $response->assertStatus(200); + $workers = $response->json('data.workers'); + $this->assertCount(1, $workers); + $this->assertEquals('Worker Kenya Out Country', $workers[0]['name']); + + $response2 = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + ])->getJson('/api/employers/workers?gender=female'); + + $response2->assertStatus(200); + $workers2 = $response2->json('data.workers'); + $this->assertCount(2, $workers2); } } diff --git a/tests/Feature/EmployerWorkerWebFilterTest.php b/tests/Feature/EmployerWorkerWebFilterTest.php index 1132313..4821330 100644 --- a/tests/Feature/EmployerWorkerWebFilterTest.php +++ b/tests/Feature/EmployerWorkerWebFilterTest.php @@ -54,6 +54,7 @@ protected function setUp(): void 'language' => 'EN', 'availability' => 'Immediate', 'bio' => 'Experienced helper', + 'gender' => 'female', ]); WorkerDocument::create([ 'worker_id' => $w1->id, @@ -83,6 +84,7 @@ protected function setUp(): void 'language' => 'TL', 'availability' => 'Immediate', 'bio' => 'Ready to work', + 'gender' => 'male', ]); WorkerDocument::create([ 'worker_id' => $w2->id, @@ -205,4 +207,32 @@ public function test_employer_can_view_candidates_index_and_apply_filters() $response2->assertStatus(200); } + + /** + * Test web workers index multiple nationality filtering. + */ + public function test_employer_workers_web_multiple_nationality_filtering() + { + $response = $this->actingAs($this->employer) + ->withSession(['user' => $this->employer]) + ->get(route('employer.workers', ['nationality' => 'India,Philippines'])); + + $response->assertStatus(200); + $response->assertSee('Worker India Dubai'); + $response->assertSee('Worker Philippines Abu Dhabi'); + } + + /** + * Test web workers index gender filtering. + */ + public function test_employer_workers_web_gender_filtering() + { + $response = $this->actingAs($this->employer) + ->withSession(['user' => $this->employer]) + ->get(route('employer.workers', ['gender' => 'female'])); + + $response->assertStatus(200); + $response->assertSee('Worker India Dubai'); + $response->assertDontSee('Worker Philippines Abu Dhabi'); + } } diff --git a/tests/Feature/SponsorAuthApiTest.php b/tests/Feature/SponsorAuthApiTest.php index 03e69cf..cd70b87 100644 --- a/tests/Feature/SponsorAuthApiTest.php +++ b/tests/Feature/SponsorAuthApiTest.php @@ -20,15 +20,18 @@ class SponsorAuthApiTest extends TestCase public function test_sponsor_can_register_with_license() { $licenseFile = UploadedFile::fake()->create('license.pdf', 500, 'application/pdf'); + $emiratesIdFile = UploadedFile::fake()->create('emirates_id.pdf', 500, 'application/pdf'); $response = $this->postJson('/api/sponsors/register', [ 'full_name' => 'Test Sponsor Organization', 'mobile' => '+971509990001', 'password' => 'securepassword', 'license_file' => $licenseFile, + 'emirates_id_file' => $emiratesIdFile, 'organization_name' => 'Charity Co', 'nationality' => 'Emirati', 'city' => 'Abu Dhabi', + 'license_expiry' => '2028-06-12', ]); $response->assertStatus(201) @@ -44,6 +47,8 @@ public function test_sponsor_can_register_with_license() 'nationality', 'city', 'license_file', + 'emirates_id_file', + 'license_expiry', ], 'token', ] @@ -52,7 +57,11 @@ public function test_sponsor_can_register_with_license() $this->assertDatabaseHas('sponsors', [ 'mobile' => '+971509990001', 'full_name' => 'Test Sponsor Organization', + 'license_expiry' => '2028-06-12 00:00:00', ]); + + $sponsor = Sponsor::where('mobile', '+971509990001')->first(); + $this->assertNotNull($sponsor->emirates_id_file); } /** @@ -325,6 +334,7 @@ public function test_sponsor_dashboard_returns_stats() 'password' => Hash::make('password123'), 'api_token' => 'sponsor_token_xyz', 'status' => 'active', + 'license_expiry' => now()->addYear()->toDateString(), ]); // Create some employers (Users with role = employer) @@ -391,6 +401,10 @@ public function test_sponsor_dashboard_returns_stats() ->assertJson([ 'success' => true, 'data' => [ + 'sponsor' => [ + 'license_expiry' => now()->addYear()->toDateString(), + 'validity' => 'Valid', + ], 'employer_stats' => [ 'total' => 2, 'active' => 1, @@ -401,5 +415,20 @@ public function test_sponsor_dashboard_returns_stats() ], ], ]); + + $profileResponse = $this->withHeaders([ + 'Authorization' => 'Bearer sponsor_token_xyz', + ])->getJson('/api/sponsors/profile'); + + $profileResponse->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'sponsor' => [ + 'license_expiry' => now()->addYear()->toDateString(), + 'validity' => 'Valid', + ] + ] + ]); } } diff --git a/tests/Feature/SponsorAuthWebTest.php b/tests/Feature/SponsorAuthWebTest.php new file mode 100644 index 0000000..7bf6181 --- /dev/null +++ b/tests/Feature/SponsorAuthWebTest.php @@ -0,0 +1,89 @@ +create('emirates_id.pdf', 500, 'application/pdf'); + + // Step 1: Registration Form Submission + $responseStep1 = $this->post('/employer/register', [ + 'name' => 'Abdullah Ahmed', + 'email' => 'abdullah@example.com', + 'phone' => '501234567', + 'emirates_id_file' => $emiratesIdFile, + ]); + + $responseStep1->assertRedirect(route('employer.verify-email')); + + // Verify session data and file presence + $pendingReg = session('pending_employer_registration'); + $this->assertNotNull($pendingReg); + $this->assertEquals('Abdullah Ahmed', $pendingReg['name']); + $this->assertNotNull($pendingReg['emirates_id_file']); + $this->assertStringContainsString('uploads/licenses/', $pendingReg['emirates_id_file']); + + // Step 2: OTP Verification (using local environment dummy code) + $responseStep2 = $this->post('/employer/verify-email', [ + 'otp' => '000000', + ]); + + $responseStep2->assertRedirect(route('employer.register-payment')); + $this->assertTrue(session('employer_email_verified')); + + // Step 3: Payment Choice + $responseStep3 = $this->post('/employer/register-payment', [ + 'plan_id' => 'premium', + 'amount_aed' => 199.00, + 'paytabs_transaction_id' => 'TRAN_ABC_123', + ]); + + $responseStep3->assertJson(['message' => 'Payment processed successfully.']); + + // Step 4: Create Password & Create Records + $responseStep4 = $this->post('/employer/create-password', [ + 'password' => 'SecurePass123!', + 'password_confirmation' => 'SecurePass123!', + ]); + + $responseStep4->assertRedirect(route('employer.dashboard')); + + // Verify Database Records + $this->assertDatabaseHas('users', [ + 'email' => 'abdullah@example.com', + 'role' => 'employer', + ]); + + $this->assertDatabaseHas('employer_profiles', [ + 'phone' => '501234567', + 'company_name' => 'Abdullah Ahmed Household', + ]); + + $this->assertDatabaseHas('sponsors', [ + 'email' => 'abdullah@example.com', + 'mobile' => '501234567', + ]); + + // Check Sponsor has the Emirates ID path matching the session stored file + $sponsor = Sponsor::where('email', 'abdullah@example.com')->first(); + $this->assertNotNull($sponsor->emirates_id_file); + $this->assertEquals($pendingReg['emirates_id_file'], $sponsor->emirates_id_file); + } +} diff --git a/tests/Feature/WorkerJourneyApiTest.php b/tests/Feature/WorkerJourneyApiTest.php index eed1f3c..620fabd 100644 --- a/tests/Feature/WorkerJourneyApiTest.php +++ b/tests/Feature/WorkerJourneyApiTest.php @@ -799,4 +799,109 @@ public function test_worker_new_announcements_flow() $response->assertStatus(200); $this->assertCount(0, $response->json('data.announcements')); } + + /** + * Test localized preferred locations endpoint. + */ + public function test_get_preferred_locations_localized() + { + // 1. Get English locations + $response = $this->getJson('/api/preferred-locations?lang=en'); + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'preferred_locations' => [ + ['code' => 'dubai', 'name' => 'Dubai'], + ['code' => 'abu dhabi', 'name' => 'Abu Dhabi'], + ['code' => 'oman', 'name' => 'Oman'], + ] + ] + ]); + + // 2. Get Hindi locations + $response = $this->getJson('/api/preferred-locations?lang=hi'); + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'preferred_locations' => [ + ['code' => 'dubai', 'name' => 'दुबई'], + ['code' => 'abu dhabi', 'name' => 'अबू धाबी'], + ['code' => 'oman', 'name' => 'ओमान'], + ] + ] + ]); + + // 3. Test pagination + $response = $this->getJson('/api/preferred-locations?per_page=1&page=2'); + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'preferred_locations' => [ + ['code' => 'abu dhabi', 'name' => 'Abu Dhabi'], + ], + 'pagination' => [ + 'total' => 3, + 'per_page' => 1, + 'current_page' => 2, + 'last_page' => 3 + ] + ] + ]); + } + + /** + * Test localized preferred location areas endpoint. + */ + public function test_get_preferred_location_areas_localized() + { + // 1. Get English areas for Dubai (using path parameter) + $response = $this->getJson('/api/preferred-locations/dubai/areas?lang=en'); + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'location' => 'dubai', + ] + ]); + $this->assertGreaterThan(5, count($response->json('data.areas'))); + $this->assertEquals('Dubai Marina', $response->json('data.areas.1.name')); + + // 2. Get Hindi areas for Dubai (using query parameter) + $response = $this->getJson('/api/preferred-locations/areas?city=dubai&lang=hi'); + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'location' => 'dubai', + ] + ]); + $this->assertEquals('दुबई मरीना', $response->json('data.areas.1.name')); + + // 3. Test pagination + $response = $this->getJson('/api/preferred-locations/oman/areas?per_page=2&page=2'); + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'location' => 'oman', + 'pagination' => [ + 'total' => 8, + 'per_page' => 2, + 'current_page' => 2, + 'last_page' => 4 + ] + ] + ]); + $this->assertCount(2, $response->json('data.areas')); + + // 4. Test not found location + $response = $this->getJson('/api/preferred-locations/sharjah/areas'); + $response->assertStatus(404) + ->assertJson([ + 'success' => false, + ]); + } } diff --git a/tests/Feature/WorkerSupportTicketApiTest.php b/tests/Feature/WorkerSupportTicketApiTest.php new file mode 100644 index 0000000..1a80c44 --- /dev/null +++ b/tests/Feature/WorkerSupportTicketApiTest.php @@ -0,0 +1,97 @@ +token = 'worker-test-token-789'; + + $category = \App\Models\WorkerCategory::create([ + 'name' => 'Housemaid', + ]); + + $this->worker = Worker::create([ + 'name' => 'John Worker', + 'email' => 'worker@example.com', + 'phone' => '+971500000009', + 'password' => bcrypt('password'), + 'api_token' => $this->token, + 'status' => 'Available', + 'category_id' => $category->id, + 'nationality' => 'Ethiopian', + 'age' => 28, + 'salary' => 1500.00, + 'availability' => 'Immediate', + 'experience' => '2 Years', + 'religion' => 'Christian', + 'bio' => 'Experienced helper.', + ]); + + $this->reason = ReportReason::create([ + 'reason' => 'App crashes when loading data', + 'status' => 'active', + 'type' => 'support', + ]); + } + + public function test_worker_can_create_ticket_with_reason_and_voice_note() + { + Storage::fake('public'); + + $voiceFile = UploadedFile::fake()->create('note.mp3', 500, 'audio/mpeg'); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + ])->postJson('/api/workers/tickets', [ + 'subject' => 'Issue with login', + 'description' => 'I cannot log in to the application.', + 'priority' => 'high', + 'reason_id' => $this->reason->id, + 'voice_note' => $voiceFile, + ]); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'success', + 'message', + 'ticket' => [ + 'id', + 'ticket_number', + 'subject', + 'description', + 'reason_id', + 'reason_name', + 'voice_note_url', + 'status', + 'priority', + 'created_at', + ] + ]); + + $ticket = SupportTicket::first(); + $this->assertNotNull($ticket); + $this->assertEquals('Issue with login', $ticket->subject); + $this->assertEquals($this->reason->id, $ticket->reason_id); + $this->assertNotNull($ticket->voice_note_path); + + Storage::disk('public')->assertExists($ticket->voice_note_path); + } +} diff --git a/vite.config.js b/vite.config.js index b36d5b9..fa54835 100644 --- a/vite.config.js +++ b/vite.config.js @@ -23,7 +23,7 @@ export default defineConfig({ port: 5173, cors: true, hmr: { - host: '10.150.39.221', + host: '192.168.29.131', }, watch: { ignored: ['**/storage/framework/views/**'],