changes api and push notification updated

This commit is contained in:
mohanmd 2026-06-13 02:20:04 +05:30
parent f7569cd35a
commit 4bdaa2811b
36 changed files with 1843 additions and 120 deletions

View File

@ -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',

View File

@ -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.',

View File

@ -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,14 +177,24 @@ 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)) {
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,

View File

@ -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',

View File

@ -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(),
]
]

View File

@ -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(),

View File

@ -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);
}
}

View File

@ -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

View File

@ -124,14 +124,24 @@ 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)) {
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,
]);
}

View File

@ -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

View File

@ -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();
}

View File

@ -123,14 +123,24 @@ 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)) {
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),

View File

@ -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

View File

@ -16,6 +16,7 @@ class SupportTicket extends Model
'worker_id',
'subject',
'description',
'voice_note_path',
'status',
'priority',
];

View File

@ -36,6 +36,7 @@ class Worker extends Model
'in_country',
'visa_status',
'preferred_job_type',
'fcm_token',
];
protected $hidden = [

155
app/Services/FCMService.php Normal file
View File

@ -0,0 +1,155 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FCMService
{
/**
* Generate OAuth2 Access Token for Firebase Messaging.
* Caches the token for 50 minutes to avoid overhead.
*
* @return string|null
*/
public static function getAccessToken()
{
return Cache::remember('fcm_access_token', 3000, function () {
try {
$credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json');
if (!file_exists($credentialsPath)) {
Log::error("FCM Service Account JSON not found at: {$credentialsPath}");
return null;
}
$credentials = json_decode(file_get_contents($credentialsPath), true);
if (!$credentials) {
Log::error("FCM Service Account JSON is invalid or empty.");
return null;
}
$privateKey = $credentials['private_key'];
$clientEmail = $credentials['client_email'];
$header = json_encode(['alg' => '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));
}
}

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('sponsors', function (Blueprint $table) {
if (!Schema::hasColumn('sponsors', 'emirates_id_file')) {
$table->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');
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('sponsors', function (Blueprint $table) {
if (!Schema::hasColumn('sponsors', 'license_expiry')) {
$table->date('license_expiry')->nullable()->after('license_file');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('sponsors', function (Blueprint $table) {
$table->dropColumn('license_expiry');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->string('fcm_token')->nullable()->after('api_token');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->dropColumn('fcm_token');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('support_tickets', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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"
}

View File

@ -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"}
}
},

View File

@ -115,12 +115,34 @@ export default function Show({ ticket, replies }) {
</div>
<div className="p-6 space-y-4">
{ticket.reason_name && (
<div>
<span className="text-[9px] text-slate-400 uppercase tracking-widest font-black block mb-1.5">Support Reason</span>
<span className="inline-block px-3 py-1 bg-slate-100 border border-slate-200 text-slate-700 text-xs font-black rounded-lg">
{ticket.reason_name}
</span>
</div>
)}
<div>
<span className="text-[9px] text-slate-400 uppercase tracking-widest font-black block mb-2">Original Ticket Message</span>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-150 text-xs text-slate-600 font-medium whitespace-pre-wrap leading-relaxed">
{ticket.description}
</div>
</div>
{ticket.voice_note_url && (
<div className="pt-2">
<span className="text-[9px] text-slate-400 uppercase tracking-widest font-black block mb-2">Voice Note Attachment</span>
<div className="flex items-center space-x-3 bg-slate-50 border border-slate-150 p-3 rounded-xl max-w-md">
<audio
src={ticket.voice_note_url}
controls
className="w-full h-8 outline-none"
/>
</div>
</div>
)}
</div>
</div>

View File

@ -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() {
)}
</div>
{/* Emirates ID File */}
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wider font-bold">Emirates ID Document (Optional)</label>
<input
type="file"
accept="image/jpeg,image/png,image/jpg,application/pdf"
onChange={(e) => 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 && (
<p className="mt-1.5 text-xs text-red-500 font-medium">
{Array.isArray(errors.emirates_id_file) ? errors.emirates_id_file[0] : errors.emirates_id_file}
</p>
)}
</div>
<button
type="submit"
disabled={loading}

View File

@ -22,9 +22,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import FilterDrawer, { FilterSection, FilterSelect, FilterInput, FilterChips } from '../../Components/Employer/FilterDrawer';
import FilterDrawer, { FilterSection, FilterSelect, FilterInput, FilterChips, FilterCheckboxList } from '../../Components/Employer/FilterDrawer';
export default function SelectedCandidates({ selectedWorkers }) {
export default function SelectedCandidates({
selectedWorkers,
allNationalities,
skills = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'],
languages = ['English', 'Arabic', 'Hindi', 'Tagalog']
}) {
const { t } = useTranslation();
const [workers, setWorkers] = useState((selectedWorkers || []).filter(w => w.status !== 'Searching'));
const [searchTerm, setSearchTerm] = useState('');
@ -33,12 +38,22 @@ export default function SelectedCandidates({ selectedWorkers }) {
const [drawerOpen, setDrawerOpen] = useState(false);
// Filter States
const [filterLocation, setFilterLocation] = useState('');
const [filterLocation, setFilterLocation] = useState('All');
const [filterJobType, setFilterJobType] = useState('All');
const [filterAccommodation, setFilterAccommodation] = useState('All');
const [filterNationality, setFilterNationality] = useState('All');
const [filterNationalities, setFilterNationalities] = useState([]);
const [filterGender, setFilterGender] = useState('All');
const [filterInCountry, setFilterInCountry] = useState('All');
const [filterVisaType, setFilterVisaType] = useState('All');
const [filterSkills, setFilterSkills] = useState([]);
const [filterLanguages, setFilterLanguages] = useState([]);
const [maxSalary, setMaxSalary] = useState(5000);
const toggleMultiSelect = (item, setSelectedList) => {
setSelectedList(prev =>
prev.includes(item) ? prev.filter(x => x !== item) : [...prev, item]
);
};
useEffect(() => {
setWorkers((selectedWorkers || []).filter(w => w.status !== 'Searching'));
@ -53,9 +68,12 @@ export default function SelectedCandidates({ selectedWorkers }) {
// Extract unique nationalities for filter dropdown
const nationalities = useMemo(() => {
if (allNationalities && allNationalities.length > 0) {
return allNationalities;
}
const nats = (selectedWorkers || []).map(w => w.nationality).filter(Boolean);
return [...new Set(nats)];
}, [selectedWorkers]);
}, [selectedWorkers, allNationalities]);
// Apply Client-Side Filters
const filteredWorkers = useMemo(() => {
@ -65,9 +83,19 @@ export default function SelectedCandidates({ selectedWorkers }) {
const query = searchTerm.toLowerCase();
list = list.filter(w => w.name.toLowerCase().includes(query));
}
if (filterLocation.trim() !== '') {
const query = filterLocation.toLowerCase();
list = list.filter(w => w.preferred_location && w.preferred_location.toLowerCase().includes(query));
if (filterLocation !== 'All' && filterLocation.trim() !== '') {
const locKey = filterLocation.toLowerCase();
const 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']
};
const allowedKeywords = locationMapping[locKey] || [locKey];
list = list.filter(w => {
if (!w.preferred_location) return false;
const wLoc = w.preferred_location.toLowerCase();
return allowedKeywords.some(keyword => wLoc.includes(keyword));
});
}
if (filterJobType !== 'All') {
list = list.filter(w => w.preferred_job_type && w.preferred_job_type.toLowerCase() === filterJobType.toLowerCase());
@ -75,8 +103,11 @@ export default function SelectedCandidates({ selectedWorkers }) {
if (filterAccommodation !== 'All') {
list = list.filter(w => w.live_in_out && w.live_in_out.toLowerCase() === filterAccommodation.toLowerCase());
}
if (filterNationality !== 'All') {
list = list.filter(w => w.nationality && w.nationality.toLowerCase() === filterNationality.toLowerCase());
if (filterNationalities.length > 0) {
list = list.filter(w => w.nationality && filterNationalities.map(n => n.toLowerCase()).includes(w.nationality.toLowerCase()));
}
if (filterGender !== 'All') {
list = list.filter(w => w.gender && w.gender.toLowerCase() === filterGender.toLowerCase());
}
if (filterInCountry !== 'All') {
const wantInCountry = filterInCountry === 'In Country';
@ -88,41 +119,62 @@ export default function SelectedCandidates({ selectedWorkers }) {
if (filterInCountry === 'In Country' && filterVisaType !== 'All') {
list = list.filter(w => w.visa_status && w.visa_status.toLowerCase().includes(filterVisaType.toLowerCase()));
}
if (filterSkills.length > 0) {
list = list.filter(w => w.skills && w.skills.some(s => filterSkills.includes(s.toLowerCase())));
}
if (filterLanguages.length > 0) {
list = list.filter(w => w.languages && w.languages.some(l => filterLanguages.includes(l)));
}
if (maxSalary < 5000) {
list = list.filter(w => w.salary && Number(w.salary) <= maxSalary);
}
return list;
}, [selectedWorkers, searchTerm, filterLocation, filterJobType, filterAccommodation, filterNationality, filterInCountry, filterVisaType]);
}, [selectedWorkers, searchTerm, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
const resetFilters = () => {
setFilterLocation('');
setFilterLocation('All');
setFilterJobType('All');
setFilterAccommodation('All');
setFilterNationality('All');
setFilterNationalities([]);
setFilterGender('All');
setFilterInCountry('All');
setFilterVisaType('All');
setFilterSkills([]);
setFilterLanguages([]);
setMaxSalary(5000);
};
const activeFilterCount = useMemo(() => {
let count = 0;
if (filterLocation.trim() !== '') count++;
if (filterLocation !== 'All' && filterLocation.trim() !== '') count++;
if (filterJobType !== 'All') count++;
if (filterAccommodation !== 'All') count++;
if (filterNationality !== 'All') count++;
if (filterNationalities.length > 0) count++;
if (filterGender !== 'All') count++;
if (filterInCountry !== 'All') count++;
if (filterInCountry === 'In Country' && filterVisaType !== 'All') count++;
if (filterSkills.length > 0) count++;
if (filterLanguages.length > 0) count++;
if (maxSalary < 5000) count++;
return count;
}, [filterLocation, filterJobType, filterAccommodation, filterNationality, filterInCountry, filterVisaType]);
}, [filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
// Active filter tags for display
const activeFilterTags = useMemo(() => {
const tags = [];
if (filterLocation.trim()) tags.push({ key: 'location', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('') });
if (filterLocation !== 'All' && filterLocation.trim()) tags.push({ key: 'location', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('All') });
if (filterJobType !== 'All') tags.push({ key: 'job', label: `💼 ${filterJobType}`, clear: () => setFilterJobType('All') });
if (filterAccommodation !== 'All') tags.push({ key: 'acc', label: `🏠 ${filterAccommodation.replace('_', '-')}`, clear: () => setFilterAccommodation('All') });
if (filterNationality !== 'All') tags.push({ key: 'nat', label: `🌍 ${filterNationality}`, clear: () => setFilterNationality('All') });
if (filterNationalities.length > 0) tags.push({ key: 'nat', label: `🌍 ${filterNationalities.length} Nat`, clear: () => setFilterNationalities([]) });
if (filterGender !== 'All') tags.push({ key: 'gender', label: `🚻 ${filterGender}`, clear: () => setFilterGender('All') });
if (filterInCountry !== 'All') tags.push({ key: 'country', label: `✈️ ${filterInCountry}`, clear: () => { setFilterInCountry('All'); setFilterVisaType('All'); } });
if (filterInCountry === 'In Country' && filterVisaType !== 'All') tags.push({ key: 'visa', label: `🪪 ${filterVisaType}`, clear: () => setFilterVisaType('All') });
if (filterSkills.length > 0) tags.push({ key: 'skills', label: `🛠 ${filterSkills.length} skill${filterSkills.length > 1 ? 's' : ''}`, clear: () => setFilterSkills([]) });
if (filterLanguages.length > 0) tags.push({ key: 'lang', label: `🗣 ${filterLanguages.length} lang`, clear: () => setFilterLanguages([]) });
if (maxSalary < 5000) tags.push({ key: 'salary', label: `💰 ≤${maxSalary} AED`, clear: () => setMaxSalary(5000) });
return tags;
}, [filterLocation, filterJobType, filterAccommodation, filterNationality, filterInCountry, filterVisaType]);
}, [filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
return (
<EmployerLayout title={t('candidates_pipeline', 'Hired Workers')}>
@ -138,12 +190,16 @@ export default function SelectedCandidates({ selectedWorkers }) {
>
{/* Preferred Location */}
<FilterSection label="Preferred Location">
<FilterInput
<FilterSelect
id="filter_preferred_location"
value={filterLocation}
onChange={e => setFilterLocation(e.target.value)}
placeholder="e.g. Dubai, Abu Dhabi…"
/>
>
<option value="All">All Locations</option>
<option value="Dubai">Dubai</option>
<option value="Abu Dhabi">Abu Dhabi</option>
<option value="Oman">Oman</option>
</FilterSelect>
</FilterSection>
{/* Job Type */}
@ -173,17 +229,27 @@ export default function SelectedCandidates({ selectedWorkers }) {
</FilterSection>
{/* Nationality */}
{nationalities.length > 0 && (
<FilterSection label="Nationality">
<FilterSelect
id="filter_nationality"
value={filterNationality}
onChange={e => setFilterNationality(e.target.value)}
>
<option value="All">All Nationalities</option>
{nationalities.map(nat => (
<option key={nat} value={nat}>{nat}</option>
))}
</FilterSelect>
<FilterCheckboxList
items={nationalities}
selected={filterNationalities}
onToggle={nat => toggleMultiSelect(nat, setFilterNationalities)}
/>
</FilterSection>
)}
{/* Gender */}
<FilterSection label="Gender">
<FilterChips
options={[
{ value: 'All', label: 'All' },
{ value: 'male', label: 'Male' },
{ value: 'female', label: 'Female' },
]}
selected={filterGender}
onChange={setFilterGender}
/>
</FilterSection>
{/* Country Status */}
@ -225,6 +291,44 @@ export default function SelectedCandidates({ selectedWorkers }) {
)}
</div>
</FilterSection>
{/* Skills */}
{skills && skills.length > 0 && (
<FilterSection label="Skills">
<FilterCheckboxList
items={skills}
selected={filterSkills}
onToggle={skill => toggleMultiSelect(skill, setFilterSkills)}
/>
</FilterSection>
)}
{/* Languages */}
{languages && languages.length > 0 && (
<FilterSection label="Languages">
<FilterCheckboxList
items={languages}
selected={filterLanguages}
onToggle={lang => toggleMultiSelect(lang, setFilterLanguages)}
/>
</FilterSection>
)}
{/* Salary Range */}
<FilterSection label={maxSalary === 5000 ? "Max Salary — Any" : `Max Salary — ${maxSalary} AED`}>
<input
type="range"
min="500"
max="5000"
step="100"
value={maxSalary}
onChange={e => setMaxSalary(Number(e.target.value))}
className="w-full accent-[#185FA5]"
/>
<div className="flex justify-between text-[10px] font-bold text-slate-400 mt-1">
<span>500 AED</span>
<span>5,000 AED</span>
</div>
</FilterSection>
</FilterDrawer>
<div className="space-y-6 select-none">

View File

@ -48,7 +48,8 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
// Basic Filters
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState('All Categories');
const [selectedNationality, setSelectedNationality] = useState('All Nationalities');
const [selectedNationalities, setSelectedNationalities] = useState([]);
const [selectedGender, setSelectedGender] = useState('All Genders');
const [selectedExperience, setSelectedExperience] = useState('All Experience');
const [selectedReligion, setSelectedReligion] = useState('All Religions');
const [maxSalary, setMaxSalary] = useState(5000);
@ -62,7 +63,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
// Advanced Filters Toggle & Details
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [filterLocation, setFilterLocation] = useState('');
const [filterLocation, setFilterLocation] = useState('All');
const [filterJobType, setFilterJobType] = useState('All');
const [filterAccommodation, setFilterAccommodation] = useState('All');
const [filterInCountry, setFilterInCountry] = useState('All');
@ -107,11 +108,17 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
const inCountryVal = params.get('in_country');
const outCountryVal = params.get('out_country');
const visa = params.get('visa_status') || params.get('next_visa_type') || params.get('visa_type');
const gender = params.get('gender');
let hasAdvanced = false;
if (cat) setSelectedCategory(cat);
if (nat) setSelectedNationality(nat);
if (nat) {
setSelectedNationalities(nat.split(',').map(x => x.trim()).filter(Boolean));
}
if (gender) {
setSelectedGender(gender.toLowerCase() === 'male' ? 'Male' : (gender.toLowerCase() === 'female' ? 'Female' : 'All Genders'));
}
if (sal) setMaxSalary(Number(sal));
if (loc) {
setFilterLocation(loc);
@ -194,7 +201,8 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
const resetFilters = () => {
setSearchQuery('');
setSelectedCategory('All Categories');
setSelectedNationality('All Nationalities');
setSelectedNationalities([]);
setSelectedGender('All Genders');
setSelectedExperience('All Experience');
setSelectedReligion('All Religions');
setMaxSalary(5000);
@ -204,7 +212,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
setSelectedWorkTypes([]);
setSortBy('default');
setVisibleCount(6);
setFilterLocation('');
setFilterLocation('All');
setFilterJobType('All');
setFilterAccommodation('All');
setFilterInCountry('All');
@ -213,31 +221,33 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
const activeFilterCount = useMemo(() => {
let count = 0;
if (filterLocation.trim() !== '') count++;
if (filterLocation !== 'All' && filterLocation.trim() !== '') count++;
if (filterJobType !== 'All') count++;
if (filterAccommodation !== 'All') count++;
if (selectedNationality !== 'All Nationalities') count++;
if (selectedNationalities.length > 0) count++;
if (selectedGender !== 'All Genders') count++;
if (filterInCountry !== 'All') count++;
if (filterInCountry === 'In Country' && filterVisaType !== 'All') count++;
if (selectedSkills.length > 0) count++;
if (selectedLanguages.length > 0) count++;
if (maxSalary < 5000) count++;
return count;
}, [filterLocation, filterJobType, filterAccommodation, selectedNationality, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
const activeFilterTags = useMemo(() => {
const tags = [];
if (filterLocation.trim()) tags.push({ key: 'loc', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('') });
if (filterLocation !== 'All' && filterLocation.trim()) tags.push({ key: 'loc', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('All') });
if (filterJobType !== 'All') tags.push({ key: 'job', label: `💼 ${filterJobType}`, clear: () => setFilterJobType('All') });
if (filterAccommodation !== 'All') tags.push({ key: 'acc', label: `🏠 ${filterAccommodation.replace('_', '-')}`, clear: () => setFilterAccommodation('All') });
if (selectedNationality !== 'All Nationalities') tags.push({ key: 'nat', label: `🌍 ${selectedNationality}`, clear: () => setSelectedNationality('All Nationalities') });
if (selectedNationalities.length > 0) tags.push({ key: 'nat', label: `🌍 ${selectedNationalities.length} Nat`, clear: () => setSelectedNationalities([]) });
if (selectedGender !== 'All Genders') tags.push({ key: 'gender', label: `🚻 ${selectedGender}`, clear: () => setSelectedGender('All Genders') });
if (filterInCountry !== 'All') tags.push({ key: 'country', label: `✈️ ${filterInCountry}`, clear: () => { setFilterInCountry('All'); setFilterVisaType('All'); } });
if (filterInCountry === 'In Country' && filterVisaType !== 'All') tags.push({ key: 'visa', label: `🪪 ${filterVisaType}`, clear: () => setFilterVisaType('All') });
if (selectedSkills.length > 0) tags.push({ key: 'skills', label: `🛠 ${selectedSkills.length} skill${selectedSkills.length > 1 ? 's' : ''}`, clear: () => setSelectedSkills([]) });
if (selectedLanguages.length > 0) tags.push({ key: 'lang', label: `🗣 ${selectedLanguages.length} lang`, clear: () => setSelectedLanguages([]) });
if (maxSalary < 5000) tags.push({ key: 'salary', label: `💰 ≤${maxSalary} AED`, clear: () => setMaxSalary(5000) });
return tags;
}, [filterLocation, filterJobType, filterAccommodation, selectedNationality, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
// Filter and Sort Worker List
const filteredWorkers = useMemo(() => {
@ -253,7 +263,8 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
// Dropdown filters
if (selectedCategory !== 'All Categories' && worker.category !== selectedCategory) return false;
if (selectedNationality !== 'All Nationalities' && worker.nationality !== selectedNationality) return false;
if (selectedNationalities.length > 0 && (!worker.nationality || !selectedNationalities.map(n => n.toLowerCase()).includes(worker.nationality.toLowerCase()))) return false;
if (selectedGender !== 'All Genders' && worker.gender && worker.gender.toLowerCase() !== selectedGender.toLowerCase()) return false;
if (selectedExperience !== 'All Experience' && worker.experience !== selectedExperience) return false;
if (selectedReligion !== 'All Religions' && worker.religion !== selectedReligion) return false;
if (maxSalary < 5000 && worker.salary > maxSalary) return false;
@ -273,9 +284,18 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
if (selectedWorkTypes.length > 0 && !selectedWorkTypes.includes(worker.preferred_job_type)) return false;
// Preferred Location
if (filterLocation.trim() !== '') {
const query = filterLocation.toLowerCase();
if (!worker.preferred_location || !worker.preferred_location.toLowerCase().includes(query)) return false;
if (filterLocation !== 'All' && filterLocation.trim() !== '') {
const locKey = filterLocation.toLowerCase();
const 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']
};
const allowedKeywords = locationMapping[locKey] || [locKey];
if (!worker.preferred_location) return false;
const wLoc = worker.preferred_location.toLowerCase();
const matched = allowedKeywords.some(keyword => wLoc.includes(keyword));
if (!matched) return false;
}
// Job Type
@ -319,7 +339,8 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
initialWorkers,
searchQuery,
selectedCategory,
selectedNationality,
selectedNationalities,
selectedGender,
selectedExperience,
selectedReligion,
maxSalary,
@ -388,12 +409,16 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
>
{/* Preferred Location */}
<FilterSection label="Preferred Location">
<FilterInput
<FilterSelect
id="filter_preferred_location"
value={filterLocation}
onChange={e => setFilterLocation(e.target.value)}
placeholder="e.g. Dubai, Abu Dhabi…"
/>
>
<option value="All">All Locations</option>
<option value="Dubai">Dubai</option>
<option value="Abu Dhabi">Abu Dhabi</option>
<option value="Oman">Oman</option>
</FilterSelect>
</FilterSection>
{/* Job Type */}
@ -423,16 +448,27 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
</FilterSection>
{/* Nationality */}
{filtersMetadata.nationalities?.length > 1 && (
<FilterSection label="Nationality">
<FilterSelect
id="filter_nationality"
value={selectedNationality}
onChange={e => setSelectedNationality(e.target.value)}
>
{filtersMetadata.nationalities?.map(nat => (
<option key={nat} value={nat}>{nat}</option>
))}
</FilterSelect>
<FilterCheckboxList
items={filtersMetadata.nationalities.filter(n => n !== 'All Nationalities')}
selected={selectedNationalities}
onToggle={nat => toggleMultiSelect(nat, setSelectedNationalities)}
/>
</FilterSection>
)}
{/* Gender */}
<FilterSection label="Gender">
<FilterChips
options={[
{ value: 'All Genders', label: 'All' },
{ value: 'male', label: 'Male' },
{ value: 'female', label: 'Female' },
]}
selected={selectedGender}
onChange={setSelectedGender}
/>
</FilterSection>
{/* Country Status */}

View File

@ -185,10 +185,37 @@ export function FilterChips({ options, selected, onChange }) {
);
}
export function FilterCheckboxList({ items, selected, onToggle }) {
export function FilterCheckboxList({ items, selected, onToggle, placeholder = 'Search...' }) {
const [search, setSearch] = React.useState('');
const filteredItems = items.filter(item =>
item.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-2">
{items.length > 5 && (
<div className="relative">
<input
type="text"
value={search}
onChange={e => 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 && (
<button
type="button"
onClick={() => setSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[10px] font-bold text-slate-400 hover:text-slate-600"
>
Clear
</button>
)}
</div>
)}
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
{items.map(item => {
{filteredItems.map(item => {
const isChecked = selected.includes(item);
return (
<button
@ -212,6 +239,10 @@ export function FilterCheckboxList({ items, selected, onToggle }) {
</button>
);
})}
{filteredItems.length === 0 && (
<div className="text-[11px] text-slate-400 italic py-1">No matches found</div>
)}
</div>
</div>
);
}

View File

@ -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']);

View File

@ -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!';
});
}
}

View File

@ -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);
}
}

View File

@ -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');
}
}

View File

@ -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',
]
]
]);
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace Tests\Feature;
use App\Models\Sponsor;
use App\Models\User;
use App\Models\EmployerProfile;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class SponsorAuthWebTest extends TestCase
{
use RefreshDatabase;
/**
* Test successful multi-step web employer registration with Emirates ID upload.
*/
public function test_employer_web_registration_with_emirates_id()
{
Storage::fake('public');
$emiratesIdFile = UploadedFile::fake()->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);
}
}

View File

@ -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,
]);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace Tests\Feature;
use App\Models\Worker;
use App\Models\ReportReason;
use App\Models\SupportTicket;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class WorkerSupportTicketApiTest extends TestCase
{
use RefreshDatabase;
protected $worker;
protected $token;
protected $reason;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@ -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/**'],