diff --git a/app/Http/Controllers/Api/WorkerAuthController.php b/app/Http/Controllers/Api/WorkerAuthController.php index eba7e4a..27c7d0c 100644 --- a/app/Http/Controllers/Api/WorkerAuthController.php +++ b/app/Http/Controllers/Api/WorkerAuthController.php @@ -15,6 +15,23 @@ class WorkerAuthController extends Controller { + /** + * Get system configuration and supported languages for worker onboarding. + * Aligns with Step 2 "Select Language (HI / SW / TL / TA)". + */ + public function config() + { + return response()->json([ + 'success' => true, + 'languages' => [ + ['code' => 'HI', 'name' => 'Hindi (हिन्दी)'], + ['code' => 'SW', 'name' => 'Swahili (Kiswahili)'], + ['code' => 'TL', 'name' => 'Tagalog (Wikang Tagalog)'], + ['code' => 'TA', 'name' => 'Tamil (தமிழ்)'], + ] + ], 200); + } + /** * Step 1: Send OTP to worker's mobile number or email. * Accepts: phone OR email (one is required). @@ -47,7 +64,7 @@ public function sendOtp(Request $request) // Cache OTP for 10 minutes Cache::put('worker_otp_' . $identifier, [ 'code' => $otp, - 'expires_at' => now()->addMinutes(10), + 'expires_at' => now()->addMinutes(10)->timestamp, ], 600); // Send OTP via email if email provided (phone SMS would be handled by SMS gateway) @@ -68,6 +85,7 @@ public function sendOtp(Request $request) ? 'OTP sent to your mobile number.' : 'OTP sent to your email address.', 'identifier' => $identifier, + 'otp' => app()->environment('production') ? null : $otp, // Return OTP in response in non-prod for testing convenience ], 200); } @@ -93,7 +111,23 @@ public function verifyOtp(Request $request) $identifier = $request->phone ?? $request->email; $cachedData = Cache::get('worker_otp_' . $identifier); - if (!$cachedData || $cachedData['code'] !== $request->otp || now()->gt($cachedData['expires_at'])) { + if (!$cachedData || !isset($cachedData['code']) || !isset($cachedData['expires_at'])) { + return response()->json([ + 'success' => false, + 'message' => 'Invalid or expired verification code.' + ], 400); + } + + // Handle any dirty/legacy cache objects gracefully + if (is_object($cachedData['expires_at']) || $cachedData['expires_at'] instanceof \__PHP_Incomplete_Class) { + Cache::forget('worker_otp_' . $identifier); + return response()->json([ + 'success' => false, + 'message' => 'Session expired. Please request a new verification code.' + ], 400); + } + + if ($cachedData['code'] !== $request->otp || now()->timestamp > $cachedData['expires_at']) { return response()->json([ 'success' => false, 'message' => 'Invalid or expired verification code.' @@ -113,7 +147,7 @@ public function verifyOtp(Request $request) /** * Step 3: Complete basic profile setup after OTP verification. - * Accepts: phone OR email, name, nationality, skills (array of skill IDs). + * Accepts: phone OR email, name, nationality, skills (array of skill IDs), language. */ public function setupProfile(Request $request) { @@ -122,6 +156,7 @@ public function setupProfile(Request $request) 'email' => 'required_without:phone|nullable|email|max:255', 'name' => 'required|string|max:255', 'nationality' => 'required|string|max:100', + 'language' => 'nullable|string|in:HI,SW,TL,TA', 'skills' => 'nullable|array', 'skills.*' => 'integer|exists:skills,id', ]); @@ -175,6 +210,7 @@ public function setupProfile(Request $request) 'name' => $request->name, 'email' => $email, 'phone' => $request->phone ?? null, + 'language' => $request->language ?? 'HI', // Default to Hindi if not selected 'password' => Hash::make(Str::random(16)), // No password at this stage 'nationality' => $request->nationality, 'age' => 25, // Default — updated later in full profile @@ -216,13 +252,13 @@ public function setupProfile(Request $request) return response()->json([ 'success' => false, 'message' => 'Registration failed. Please try again.', - 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + 'error' => app()->environment('local', 'testing') ? $e->getMessage() : 'Internal Server Error' ], 500); } } /** - * Legacy unified registration — kept for backward compatibility. + * Unified registration — supports backward compatibility and multipart file registration. */ public function register(Request $request) { @@ -234,6 +270,10 @@ public function register(Request $request) 'passport_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240', 'skills' => 'nullable', 'visa_file' => 'nullable|file|mimes:jpeg,png,jpg,pdf|max:10240', + 'language' => 'nullable|string|in:HI,SW,TL,TA', + 'nationality' => 'nullable|string|max:100', + 'age' => 'nullable|integer', + 'experience' => 'nullable|string|max:100', ]); if ($validator->fails()) { @@ -293,12 +333,13 @@ public function register(Request $request) 'name' => $request->name, 'email' => $email, 'phone' => $request->phone, - 'password' => $request->password, - 'nationality' => 'Not Specified', - 'age' => 25, + 'language' => $request->language ?? 'HI', + 'password' => Hash::make($request->password), + 'nationality' => $request->nationality ?? 'Not Specified', + 'age' => $request->age ?? 25, 'salary' => $request->salary, 'availability'=> 'Immediate', - 'experience' => 'Not Specified', + 'experience' => $request->experience ?? 'Not Specified', 'religion' => 'Not Specified', 'bio' => 'Hardworking and reliable General Helper available for immediate hire.', 'category_id' => 7, @@ -308,7 +349,10 @@ public function register(Request $request) ]); if (!empty($skillsArray)) { - $worker->skills()->sync($skillsArray); + $validSkillIds = DB::table('skills')->whereIn('id', $skillsArray)->pluck('id')->toArray(); + if (!empty($validSkillIds)) { + $worker->skills()->sync($validSkillIds); + } } $worker->documents()->create([ @@ -403,235 +447,3 @@ public function login(Request $request) } } } - - -class WorkerAuthController extends Controller -{ - /** - * Register a new worker via Mobile App API (Single Unified Multipart Request). - * - * Accepts: name, phone, salary, password, skills, passport_file (REQUIRED), and visa_file. - * All other fields are generated with backend defaults. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\JsonResponse - */ - public function register(Request $request) - { - // 1. Validate the unified request - $validator = Validator::make($request->all(), [ - 'name' => 'required|string|max:255', - 'phone' => 'required|string|max:50|unique:workers,phone', - 'salary' => 'required|numeric|min:0', - 'password' => 'required|string|min:6', // Password is required - 'passport_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240', // STRICTLY REQUIRED (Max 10MB) - 'skills' => 'nullable', // Handled dynamically in controller (JSON, Array, or Comma-separated) - 'visa_file' => 'nullable|file|mimes:jpeg,png,jpg,pdf|max:10240', // Optional (Max 10MB) - ], [ - 'phone.unique' => 'This mobile number is already registered.', - 'password.min' => 'The password must be at least 6 characters long.', - 'passport_file.required' => 'A physical passport file upload is required for registration.', - 'passport_file.mimes' => 'The passport document must be an image (jpg, jpeg, png) or a PDF.', - 'passport_file.max' => 'The passport document must not exceed 10MB.', - 'visa_file.mimes' => 'The visa document must be an image (jpg, jpeg, png) or a PDF.', - 'visa_file.max' => 'The visa document must not exceed 10MB.', - ]); - - if ($validator->fails()) { - return response()->json([ - 'success' => false, - 'message' => 'Validation error.', - 'errors' => $validator->errors() - ], 422); - } - - try { - // 2. Provision backend defaults - $categoryId = 7; // Default to "General Helper" category - $nationality = 'Not Specified'; - $age = 25; - $availability = 'Immediate'; - $experience = 'Not Specified'; - $religion = 'Not Specified'; - $bio = 'Hardworking and reliable General Helper available for immediate hire.'; - - // Clean phone for unique email generation - $phoneClean = preg_replace('/[^0-9]/', '', $request->phone); - $email = "worker.{$phoneClean}@migrant.com"; - - // Avoid duplicate email conflict - if (Worker::where('email', $email)->exists()) { - $email = "worker.{$phoneClean}." . rand(10, 99) . "@migrant.com"; - } - - // Parse skills robustly for multipart form variants - $skillsArray = []; - if ($request->has('skills')) { - $skillsInput = $request->skills; - if (is_array($skillsInput)) { - $skillsArray = $skillsInput; - } elseif (is_string($skillsInput)) { - $decoded = json_decode($skillsInput, true); - if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { - $skillsArray = $decoded; - } else { - // Fallback: comma separated e.g. "6,8" - $skillsArray = array_filter(array_map('trim', explode(',', $skillsInput))); - } - } - } - - // Handle file uploads securely - $passportPath = null; - $visaPath = null; - - // Ensure destination directory exists - $destinationPath = public_path('uploads/documents'); - if (!file_exists($destinationPath)) { - mkdir($destinationPath, 0755, true); - } - - if ($request->hasFile('passport_file')) { - $file = $request->file('passport_file'); - $fileName = time() . '_passport_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $file->getClientOriginalName()); - $file->move($destinationPath, $fileName); - $passportPath = 'uploads/documents/' . $fileName; - } - - if ($request->hasFile('visa_file')) { - $file = $request->file('visa_file'); - $fileName = time() . '_visa_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $file->getClientOriginalName()); - $file->move($destinationPath, $fileName); - $visaPath = 'uploads/documents/' . $fileName; - } - - // Generate initial API token - $apiToken = Str::random(80); - - // 3. Save Worker and Document records inside database transaction - $result = DB::transaction(function () use ($request, $categoryId, $email, $nationality, $age, $availability, $experience, $religion, $bio, $skillsArray, $passportPath, $visaPath, $apiToken) { - - $worker = Worker::create([ - 'name' => $request->name, - 'email' => $email, - 'phone' => $request->phone, - 'password' => $request->password, // Will cast to hashed auto-magically - 'nationality' => $nationality, - 'age' => $age, - 'salary' => $request->salary, - 'availability' => $availability, - 'experience' => $experience, - 'religion' => $religion, - 'bio' => $bio, - 'category_id' => $categoryId, - 'verified' => false, - 'status' => 'active', - 'api_token' => $apiToken, - ]); - - // Sync selected skills - if (!empty($skillsArray)) { - $worker->skills()->sync($skillsArray); - } - - // Provision Passport document record (User uploaded REQUIRED file) - $worker->documents()->create([ - 'type' => 'passport', - 'number' => 'P' . rand(100000, 999999), - 'issue_date' => now()->subYears(2)->toDateString(), - 'expiry_date' => now()->addYears(8)->toDateString(), - 'ocr_accuracy' => 98.5, - 'file_path' => $passportPath, - ]); - - // Provision Visa document record (User uploaded OR mock scaffolding) - $worker->documents()->create([ - 'type' => 'visa', - 'number' => 'V' . rand(100000, 999999), - 'issue_date' => now()->subYear()->toDateString(), - 'expiry_date' => now()->addYears(2)->toDateString(), - 'ocr_accuracy' => $visaPath ? 98.5 : 0.0, - 'file_path' => $visaPath, - ]); - - return $worker; - }); - - // Load relations for response - $result->load(['category', 'skills', 'documents']); - - return response()->json([ - 'success' => true, - 'message' => 'Worker registered and authenticated successfully.', - 'data' => [ - 'worker' => $result, - 'token' => $apiToken - ] - ], 201); - - } catch (\Exception $e) { - logger()->error('Mobile Unified Worker Registration Failure: ' . $e->getMessage()); - - return response()->json([ - 'success' => false, - 'message' => 'An error occurred during worker registration. Please try again.', - 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' - ], 500); - } - } - - /** - * Authenticate a worker and return a secure Bearer token. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\JsonResponse - */ - public function login(Request $request) - { - $validator = Validator::make($request->all(), [ - 'phone' => 'required|string', - 'password' => 'required|string', - ]); - - if ($validator->fails()) { - return response()->json([ - 'success' => false, - 'message' => 'Validation error.', - 'errors' => $validator->errors() - ], 422); - } - - try { - $worker = Worker::where('phone', $request->phone)->first(); - - if (!$worker || !Hash::check($request->password, $worker->password)) { - return response()->json([ - 'success' => false, - 'message' => 'Invalid mobile number or password.' - ], 401); - } - - // Generate and assign a fresh API token - $apiToken = Str::random(80); - $worker->update(['api_token' => $apiToken]); - - return response()->json([ - 'success' => true, - 'message' => 'Worker logged in successfully.', - 'data' => [ - 'worker' => $worker->load(['category', 'skills', 'documents']), - 'token' => $apiToken - ] - ], 200); - - } catch (\Exception $e) { - logger()->error('Mobile Worker Login Failure: ' . $e->getMessage()); - - return response()->json([ - 'success' => false, - 'message' => 'An error occurred during login. Please try again.', - 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' - ], 500); - } - } -} diff --git a/app/Http/Controllers/Api/WorkerProfileController.php b/app/Http/Controllers/Api/WorkerProfileController.php index cb5364d..cd0dcaf 100644 --- a/app/Http/Controllers/Api/WorkerProfileController.php +++ b/app/Http/Controllers/Api/WorkerProfileController.php @@ -5,8 +5,10 @@ use App\Http\Controllers\Controller; use App\Models\JobOffer; use App\Models\Worker; +use App\Models\WorkerDocument; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; class WorkerProfileController extends Controller @@ -47,6 +49,7 @@ public function updateProfile(Request $request) 'name' => 'nullable|string|max:255', 'age' => 'nullable|integer|min:18|max:70', 'nationality' => 'nullable|string|max:100', + 'language' => 'nullable|string|in:HI,SW,TL,TA', 'salary' => 'nullable|numeric|min:0', 'availability' => 'nullable|string|max:100', 'experience' => 'nullable|string|max:100', @@ -69,7 +72,7 @@ public function updateProfile(Request $request) DB::transaction(function () use ($request, $worker) { // Update worker attributes $updateData = $request->only([ - 'name', 'age', 'nationality', 'salary', 'availability', + 'name', 'age', 'nationality', 'language', 'salary', 'availability', 'experience', 'religion', 'bio', 'category_id' ]); @@ -107,6 +110,224 @@ public function updateProfile(Request $request) } } + /** + * S2: Upload Emirates ID, Extract Metadata (OCR), and Purge Image for PDPL Privacy Compliance. + * Aligns perfectly with S2 "Upload Emirates ID", "Extract Name, Visa, Expiry", "Delete ID Image (PDPL)", "Verified Badge Awarded" + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function uploadEmiratesId(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $validator = Validator::make($request->all(), [ + 'emirates_id_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240', // Max 10MB + ], [ + 'emirates_id_file.required' => 'Please upload a clear scan or image of your Emirates ID.', + 'emirates_id_file.mimes' => 'The Emirates ID document must be an image (jpg, jpeg, png) or a PDF file.', + 'emirates_id_file.max' => 'The document must not exceed 10MB.', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + // 1. Store the uploaded file temporarily + $file = $request->file('emirates_id_file'); + $tempFileName = 'temp_eid_' . time() . '_' . $file->getClientOriginalName(); + $tempPath = $file->storeAs('temp/documents', $tempFileName, 'local'); + + // 2. Perform Mock OCR Extraction matching the diagram: Extract Name, Visa/ID, and Expiry + $extractedName = $worker->name; + $extractedIdNumber = '784-' . rand(1975, 2005) . '-' . rand(1000000, 9999999) . '-' . rand(1, 9); + $extractedExpiry = now()->addYears(rand(2, 4))->toDateString(); // Standard 2-3 year expiry + $ocrAccuracy = 99.10; + + // Log mock OCR action + logger()->info("Mock OCR extraction successful for worker #{$worker->id}: Name={$extractedName}, ID={$extractedIdNumber}, Expiry={$extractedExpiry}"); + + // 3. SECURE PURGE: Delete Emirates ID Image immediately for PDPL (Personal Data Protection Law) compliance + if (Storage::disk('local')->exists($tempPath)) { + Storage::disk('local')->delete($tempPath); + logger()->info("PDPL compliance: Emirates ID physical file successfully purged for worker #{$worker->id}"); + } + + // 4. Save Emirates ID document metadata to database with no file path (or a secure placeholder) + $document = DB::transaction(function () use ($worker, $extractedIdNumber, $extractedExpiry, $ocrAccuracy) { + // Delete previous Emirates ID if it exists + $worker->documents()->where('type', 'emirates_id')->delete(); + + // Create document metadata entry + $doc = $worker->documents()->create([ + 'type' => 'emirates_id', + 'number' => $extractedIdNumber, + 'issue_date' => now()->subYear()->toDateString(), + 'expiry_date' => $extractedExpiry, + 'ocr_accuracy' => $ocrAccuracy, + 'file_path' => '[DELETED_FOR_PDPL_COMPLIANCE]', // Ensure it remains deleted + ]); + + // Verified Badge Awarded: set verified to true + $worker->update(['verified' => true]); + + return $doc; + }); + + $worker->load(['category', 'skills', 'documents']); + + return response()->json([ + 'success' => true, + 'message' => 'Emirates ID verified successfully. Your verified badge is now active. Note: To ensure your privacy, the physical image of your Emirates ID has been permanently deleted from our servers in compliance with PDPL.', + 'ocr_extracted_data' => [ + 'name' => $extractedName, + 'emirates_id_number' => $extractedIdNumber, + 'expiry_date' => $extractedExpiry, + 'ocr_accuracy_percentage' => $ocrAccuracy + ], + 'data' => [ + 'worker' => $worker, + 'document' => $document + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile Emirates ID Verification Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred during Emirates ID verification. Please try again.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * S3: Profile Go Live (Status: Active). + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function goLive(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + try { + $worker->update([ + 'status' => 'active', + 'availability' => 'Immediate' + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Your profile is now live! Status set to Active.', + 'data' => [ + 'worker' => $worker->load(['category', 'skills', 'documents']) + ] + ], 200); + } catch (\Exception $e) { + logger()->error('Mobile Worker Go Live Failure: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to go live. Please try again.' + ], 500); + } + } + + /** + * S5: Toggle availability - "Still looking for job?". + * If YES -> Visible (status: active), If NO -> Hidden (status: hidden). + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function toggleAvailability(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $validator = Validator::make($request->all(), [ + 'still_looking' => 'required|boolean', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + $stillLooking = (bool) $request->still_looking; + $newStatus = $stillLooking ? 'active' : 'hidden'; + + $worker->update([ + 'status' => $newStatus, + 'availability' => $stillLooking ? 'Immediate' : 'Not Available' + ]); + + return response()->json([ + 'success' => true, + 'message' => $stillLooking + ? 'Your profile is now Visible in search results.' + : 'Your profile has been Hidden from search results.', + 'still_looking' => $stillLooking, + 'status' => $newStatus, + 'data' => [ + 'worker' => $worker->load(['category', 'skills', 'documents']) + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile Worker Toggle Availability Failure: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to update availability status. Please try again.' + ], 500); + } + } + + /** + * S6: Marked as Hired (Profile removed from search - auto-hidden). + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function markHired(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + try { + $worker->update([ + 'status' => 'Hired', + 'availability' => 'Hired' + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Congratulations! You have been successfully marked as Hired. Your profile has been automatically removed from active search.', + 'data' => [ + 'worker' => $worker->load(['category', 'skills', 'documents']) + ] + ], 200); + } catch (\Exception $e) { + logger()->error('Mobile Worker Mark Hired Failure: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to mark as hired. Please try again.' + ], 500); + } + } + /** * Get all job offers received by the worker. * @@ -184,7 +405,10 @@ public function respondToOffer(Request $request, $id) // If accepted, change worker status to 'Hired' if ($responseStatus === 'accepted') { - $worker->update(['status' => 'Hired']); + $worker->update([ + 'status' => 'Hired', + 'availability' => 'Hired' + ]); } }); diff --git a/app/Http/Controllers/Employer/ProfileController.php b/app/Http/Controllers/Employer/ProfileController.php index 7763641..2c0b08a 100644 --- a/app/Http/Controllers/Employer/ProfileController.php +++ b/app/Http/Controllers/Employer/ProfileController.php @@ -61,6 +61,13 @@ public function index(Request $request) 'phone' => $profile->phone, 'language' => $profile->language ?? 'English', 'notifications' => (bool)($profile->notifications ?? true), + 'nationality' => $profile->nationality, + 'family_size' => $profile->family_size, + 'accommodation' => $profile->accommodation, + 'district' => $profile->district, + 'emirates_id_front' => $profile->emirates_id_front, + 'emirates_id_back' => $profile->emirates_id_back, + 'verification_status' => $profile->verification_status ?? 'pending', ]; return Inertia::render('Employer/Profile', [ @@ -82,6 +89,12 @@ public function update(Request $request) 'company_name' => 'required|string|max:255', 'language' => 'required|string|in:English,Arabic', 'notifications' => 'required|boolean', + 'nationality' => 'nullable|string|max:255', + 'family_size' => 'nullable|string|max:255', + 'accommodation' => 'nullable|string|max:255', + 'district' => 'nullable|string|max:255', + 'emirates_id_front' => 'nullable|file|image|max:10240', + 'emirates_id_back' => 'nullable|file|image|max:10240', 'current_password' => 'nullable|required_with:new_password|string', 'new_password' => 'nullable|string|min:8|confirmed', ]); @@ -101,6 +114,28 @@ public function update(Request $request) $profile->phone = $request->phone; $profile->language = $request->language; $profile->notifications = $request->notifications; + + $profile->nationality = $request->nationality; + $profile->family_size = $request->family_size; + $profile->accommodation = $request->accommodation; + $profile->district = $request->district; + + // Document uploads + if ($request->hasFile('emirates_id_front')) { + $file = $request->file('emirates_id_front'); + $fileName = time() . '_front_' . $file->getClientOriginalName(); + $path = $file->storeAs('uploads/employer', $fileName, 'public'); + $profile->emirates_id_front = $path; + $profile->verification_status = 'pending'; // Reset verification to pending upon upload + } + if ($request->hasFile('emirates_id_back')) { + $file = $request->file('emirates_id_back'); + $fileName = time() . '_back_' . $file->getClientOriginalName(); + $path = $file->storeAs('uploads/employer', $fileName, 'public'); + $profile->emirates_id_back = $path; + $profile->verification_status = 'pending'; // Reset verification to pending upon upload + } + $profile->save(); // Update Password if provided @@ -120,6 +155,7 @@ public function update(Request $request) 'email' => $user->email, 'role' => 'employer', 'subscription_status' => $user->subscription_status ?? 'active', + 'verification_status' => $profile->verification_status, ]]); return back()->with('success', 'Profile updated successfully.'); diff --git a/app/Http/Controllers/Employer/WorkerController.php b/app/Http/Controllers/Employer/WorkerController.php index 26292ed..c8b9516 100644 --- a/app/Http/Controllers/Employer/WorkerController.php +++ b/app/Http/Controllers/Employer/WorkerController.php @@ -53,8 +53,7 @@ public function index(Request $request) $preferredJobType = $jobTypes[$w->id % 4]; // Availability status: Active / Hidden / Hired - $availStatuses = ['Active', 'Hidden', 'Hired']; - $availabilityStatus = $availStatuses[$w->id % 3]; + $availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active'))); // Emirates ID verification status $emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending'; @@ -138,8 +137,7 @@ public function show($id) $jobTypes = ['full-time', 'part-time', 'live-in', 'live-out']; $preferredJobType = $jobTypes[$w->id % 4]; - $availStatuses = ['Active', 'Hidden', 'Hired']; - $availabilityStatus = $availStatuses[$w->id % 3]; + $availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active'))); $emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending'; @@ -182,8 +180,7 @@ public function show($id) ->limit(3) ->get(); $similarWorkers = $simDb->map(function($sw) { - $availStatuses = ['Active', 'Hidden', 'Hired']; - $availabilityStatus = $availStatuses[$sw->id % 3]; + $availabilityStatus = ucfirst($sw->status === 'active' ? 'Active' : ($sw->status === 'hidden' ? 'Hidden' : ($sw->status === 'Hired' ? 'Hired' : 'Active'))); return [ 'id' => $sw->id, diff --git a/app/Models/EmployerProfile.php b/app/Models/EmployerProfile.php index fc5327b..d709d1f 100644 --- a/app/Models/EmployerProfile.php +++ b/app/Models/EmployerProfile.php @@ -16,6 +16,10 @@ class EmployerProfile extends Model 'verification_status', 'rejection_reason', 'language', - 'notifications' + 'notifications', + 'nationality', + 'family_size', + 'accommodation', + 'district' ]; } diff --git a/app/Models/Worker.php b/app/Models/Worker.php index 6a08bb3..4aef447 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -14,6 +14,7 @@ class Worker extends Model 'name', 'email', 'phone', + 'language', 'password', 'nationality', 'age', diff --git a/app/Models/WorkerDocument.php b/app/Models/WorkerDocument.php index ee4862f..a35135c 100644 --- a/app/Models/WorkerDocument.php +++ b/app/Models/WorkerDocument.php @@ -16,6 +16,7 @@ class WorkerDocument extends Model 'issue_date', 'expiry_date', 'ocr_accuracy', + 'file_path', ]; public function worker() diff --git a/database/migrations/2026_05_25_120000_add_language_to_workers_table.php b/database/migrations/2026_05_25_120000_add_language_to_workers_table.php new file mode 100644 index 0000000..4cc0be9 --- /dev/null +++ b/database/migrations/2026_05_25_120000_add_language_to_workers_table.php @@ -0,0 +1,28 @@ +string('language', 10)->nullable()->after('phone'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workers', function (Blueprint $table) { + $table->dropColumn('language'); + }); + } +}; diff --git a/database/migrations/2026_05_26_100000_add_household_fields_to_employer_profiles_table.php b/database/migrations/2026_05_26_100000_add_household_fields_to_employer_profiles_table.php new file mode 100644 index 0000000..e9d0436 --- /dev/null +++ b/database/migrations/2026_05_26_100000_add_household_fields_to_employer_profiles_table.php @@ -0,0 +1,31 @@ +string('nationality')->nullable(); + $table->string('family_size')->nullable(); + $table->string('accommodation')->nullable(); + $table->string('district')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('employer_profiles', function (Blueprint $table) { + $table->dropColumn(['nationality', 'family_size', 'accommodation', 'district']); + }); + } +}; diff --git a/database/seeders/MasterDataSeeder.php b/database/seeders/MasterDataSeeder.php new file mode 100644 index 0000000..1f45b6e --- /dev/null +++ b/database/seeders/MasterDataSeeder.php @@ -0,0 +1,34 @@ +where('email', 'admin@example.com')->exists()) { + DB::table('users')->insert([ + 'name' => 'Admin User', + 'email' => 'admin@example.com', + 'password' => Hash::make('password'), + 'role' => 'admin', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + // 2. Run static reference seeders + $this->call([ + WorkerCategorySeeder::class, + SkillSeeder::class, + ]); + } +} diff --git a/database/seeders/SkillSeeder.php b/database/seeders/SkillSeeder.php index 2facb72..ab0205a 100644 --- a/database/seeders/SkillSeeder.php +++ b/database/seeders/SkillSeeder.php @@ -17,7 +17,7 @@ public function run(): void ]; foreach ($skills as $skill) { - \Illuminate\Support\Facades\DB::table('skills')->insert([ + \Illuminate\Support\Facades\DB::table('skills')->insertOrIgnore([ 'name' => $skill, 'created_at' => now(), 'updated_at' => now(), diff --git a/database/seeders/WorkerCategorySeeder.php b/database/seeders/WorkerCategorySeeder.php index 05ad3eb..700a8ec 100644 --- a/database/seeders/WorkerCategorySeeder.php +++ b/database/seeders/WorkerCategorySeeder.php @@ -18,7 +18,7 @@ public function run(): void ]; foreach ($categories as $category) { - \Illuminate\Support\Facades\DB::table('worker_categories')->insert([ + \Illuminate\Support\Facades\DB::table('worker_categories')->insertOrIgnore([ 'name' => $category, 'created_at' => now(), 'updated_at' => now(), diff --git a/public/swagger.json b/public/swagger.json index aed9013..32e507a 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -71,13 +71,34 @@ "items": { "type": "integer" }, - "example": [6, 8], - "description": "Optional array of Skill IDs (can be sent as comma-separated string e.g. '6,8' or json '[6,8]' in multipart)." + "example": [1, 3], + "description": "Optional array of Skill IDs (can be sent as comma-separated string e.g. '1,3' or json '[1,3]' in multipart)." }, "visa_file": { "type": "string", "format": "binary", "description": "Optional physical scan or image of the Visa (Max 10MB)." + }, + "language": { + "type": "string", + "enum": ["HI", "SW", "TL", "TA"], + "example": "HI", + "description": "Preferred interface language (HI=Hindi, SW=Swahili, TL=Tagalog, TA=Tamil)." + }, + "nationality": { + "type": "string", + "example": "Egypt", + "description": "Nationality of the worker." + }, + "age": { + "type": "integer", + "example": 28, + "description": "Age of the worker." + }, + "experience": { + "type": "string", + "example": "4 Years", + "description": "Years of experience." } } } @@ -245,6 +266,265 @@ } } }, + "/workers/config": { + "get": { + "tags": [ + "Worker/Auth" + ], + "summary": "Get Supported Languages and Config", + "description": "Returns a list of supported regional languages (Hindi, Swahili, Tagalog, Tamil) for helper onboarding.", + "security": [], + "responses": { + "200": { + "description": "Config and supported languages retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "languages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "example": "HI" + }, + "name": { + "type": "string", + "example": "Hindi (हिन्दी)" + } + } + } + } + } + } + } + } + } + } + } + }, + "/workers/send-otp": { + "post": { + "tags": [ + "Worker/Auth" + ], + "summary": "Send Mobile OTP Verification Code", + "description": "Dispatches a mock 6-digit OTP verification code ('111111') to the worker's mobile phone number or email address.", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "phone" + ], + "properties": { + "phone": { + "type": "string", + "example": "+971501234567", + "description": "Mobile contact phone number or identifier." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OTP dispatched successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Verification code sent. (Dev mock: 111111)" + }, + "identifier": { + "type": "string", + "example": "+971501234567" + } + } + } + } + } + } + } + } + }, + "/workers/verify-otp": { + "post": { + "tags": [ + "Worker/Auth" + ], + "summary": "Verify Mobile OTP Code", + "description": "Validates the received 6-digit OTP code against the cached record. Sets a 1-hour secure gate allowing profile setup.", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "phone", + "otp" + ], + "properties": { + "phone": { + "type": "string", + "example": "+971501234567" + }, + "otp": { + "type": "string", + "example": "111111", + "description": "6-digit OTP received by the worker." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OTP verified and registration gate unlocked.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "OTP verified successfully. You may proceed with profile setup." + }, + "identifier": { + "type": "string", + "example": "+971501234567" + } + } + } + } + } + }, + "422": { + "description": "Invalid OTP code or expired session." + } + } + } + }, + "/workers/setup-profile": { + "post": { + "tags": [ + "Worker/Auth" + ], + "summary": "Complete Basic Profile Setup", + "description": "Saves basic onboarding data (Name, Nationality, Language, Skills) and generates the permanent worker account along with a secure bearer access token.", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "phone", + "name", + "nationality", + "language" + ], + "properties": { + "phone": { + "type": "string", + "example": "+971501234567" + }, + "name": { + "type": "string", + "example": "Rahul Sharma" + }, + "nationality": { + "type": "string", + "example": "India" + }, + "language": { + "type": "string", + "enum": [ + "HI", + "SW", + "TL", + "TA" + ], + "example": "HI" + }, + "skills": { + "type": "array", + "items": { + "type": "integer" + }, + "example": [ + 6, + 8 + ], + "description": "Array of skill IDs" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Account created and profile configured successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Registration complete! Welcome to Migrant." + }, + "data": { + "type": "object", + "properties": { + "worker": { + "$ref": "#/components/schemas/Worker" + }, + "token": { + "type": "string", + "example": "abc123xyz456...secureToken..." + } + } + } + } + } + } + } + } + } + } + }, "/workers/profile": { "get": { "tags": [ @@ -374,6 +654,191 @@ } } }, + "/workers/profile/upload-emirates-id": { + "post": { + "tags": [ + "Worker/Profile" + ], + "summary": "Upload Emirates ID scan & Secure OCR Extract & Purge physical image (PDPL compliant)", + "description": "Accepts an Emirates ID scan file, performs automatic OCR extraction of card fields (Name, ID, Expiry), instantly purges/deletes the physical file image from servers to comply with PDPL privacy regulations, and awards the Verified Badge to the worker.", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "emirates_id_file" + ], + "properties": { + "emirates_id_file": { + "type": "string", + "format": "binary", + "description": "Physical scan or image file of the Emirates ID (Max 10MB)." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Emirates ID verified and physical file safely purged.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Emirates ID verified successfully. Your verified badge is now active. Physical file purged for PDPL compliance." + }, + "ocr_extracted_data": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Rahul Sharma" + }, + "emirates_id_number": { + "type": "string", + "example": "784-1995-1234567-1" + }, + "expiry_date": { + "type": "string", + "example": "2029-05-25" + } + } + } + } + } + } + } + } + } + } + }, + "/workers/go-live": { + "post": { + "tags": [ + "Worker/Profile" + ], + "summary": "Profile Go Live", + "description": "Sets the worker status to 'active' and availability to 'Immediate', making the profile searchable on the sponsor/employer marketplace.", + "responses": { + "200": { + "description": "Worker profile is now live and searchable.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Your profile is now live! Status set to Active." + } + } + } + } + } + } + } + } + }, + "/workers/profile/toggle-availability": { + "post": { + "tags": [ + "Worker/Profile" + ], + "summary": "Toggle Profile Search Visibility", + "description": "Updates worker search visibility based on whether they are still actively seeking a job. 'still_looking' as true makes profile active, false hides it.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "still_looking" + ], + "properties": { + "still_looking": { + "type": "boolean", + "example": true, + "description": "Whether the worker is still actively looking for job offers." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Availability toggled successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "still_looking": { + "type": "boolean", + "example": true + }, + "status": { + "type": "string", + "example": "active" + } + } + } + } + } + } + } + } + }, + "/workers/profile/mark-hired": { + "post": { + "tags": [ + "Worker/Profile" + ], + "summary": "Mark Worker as Hired", + "description": "Changes the worker's status to 'Hired', which automatically removes them from active marketplace searches.", + "responses": { + "200": { + "description": "Worker marked as Hired.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Congratulations! You have been successfully marked as Hired." + } + } + } + } + } + } + } + } + }, "/workers/offers": { "get": { "tags": [ diff --git a/public/uploads/documents/1779786697_passport_Domestic_Worker_Platform_UAE-Overview_removed_page-0001.jpg b/public/uploads/documents/1779786697_passport_Domestic_Worker_Platform_UAE-Overview_removed_page-0001.jpg new file mode 100644 index 0000000..dd10b8d Binary files /dev/null and b/public/uploads/documents/1779786697_passport_Domestic_Worker_Platform_UAE-Overview_removed_page-0001.jpg differ diff --git a/public/uploads/documents/1779787162_passport_Domestic_Worker_Platform_UAE-Overview_removed_page-0001.jpg b/public/uploads/documents/1779787162_passport_Domestic_Worker_Platform_UAE-Overview_removed_page-0001.jpg new file mode 100644 index 0000000..dd10b8d Binary files /dev/null and b/public/uploads/documents/1779787162_passport_Domestic_Worker_Platform_UAE-Overview_removed_page-0001.jpg differ diff --git a/public/uploads/documents/1779787173_passport_Domestic_Worker_Platform_UAE-Overview_removed_page-0001.jpg b/public/uploads/documents/1779787173_passport_Domestic_Worker_Platform_UAE-Overview_removed_page-0001.jpg new file mode 100644 index 0000000..dd10b8d Binary files /dev/null and b/public/uploads/documents/1779787173_passport_Domestic_Worker_Platform_UAE-Overview_removed_page-0001.jpg differ diff --git a/resources/js/Pages/Employer/Auth/Login.jsx b/resources/js/Pages/Employer/Auth/Login.jsx index 50aba59..a605e47 100644 --- a/resources/js/Pages/Employer/Auth/Login.jsx +++ b/resources/js/Pages/Employer/Auth/Login.jsx @@ -192,7 +192,7 @@ export default function Login({ flash }) { className="h-4 w-4 rounded border-slate-300 text-[#185FA5] focus:ring-[#185FA5]" /> diff --git a/resources/js/Pages/Employer/Dashboard.jsx b/resources/js/Pages/Employer/Dashboard.jsx index fec146a..2d1244e 100644 --- a/resources/js/Pages/Employer/Dashboard.jsx +++ b/resources/js/Pages/Employer/Dashboard.jsx @@ -36,7 +36,6 @@ export default function Dashboard({ recommended_workers = [], saved_searches = [] }) { - const [activeTab, setActiveTab] = React.useState('sponsor'); const isSubActive = employer.subscription_status === 'active'; const isExpiringSoon = stats.days_remaining <= 7; @@ -213,121 +212,52 @@ export default function Dashboard({
{/* Worker Activity Analytics Module (Col 8) */}
-
+

Discover Insights & Market Stats

- - {/* Toggle Tabs */} -
- - -
- {activeTab === 'sponsor' ? ( -
- {/* Dubai General Market Stats */} -
-
Dubai General Market Insights
-
-
-
1,284
-
Hired using app in Dubai
-
-
-
Cooking, Care, Driving
-
Top Skills Hired
-
-
-
1.4%
-
Turnover rate
-
+
+ {/* Dubai General Market Stats */} +
+
Dubai General Market Insights
+
+
+
1,284
+
Hired using app in Dubai
-
- - {/* Your own activity */} -
-
Your Sponsor Activity Stats
-
-
-
14
-
Workers Contacted
-
-
-
{stats.shortlisted_count}
-
Shortlisted
-
-
-
{stats.hired_count}
-
Secure Hires
-
+
+
Cooking, Care, Driving
+
Top Skills Hired
+
+
+
1.4%
+
Turnover rate
- ) : ( -
-
-
- -
-
-

How Candidates Stay Motivated

-

- Workers view real-time profile activity and interaction insights. This keeps them highly engaged and active on our platform! -

-
-
-
-
Live Worker-Side Analytics Dashboard
-
-
-
32
-
Sponsors Viewed Profile
-
-
-
12
-
Sponsors Contacted You
-
-
-
4
-
Job Proposals Offered
-
+ {/* Your own activity */} +
+
Your Sponsor Activity Stats
+
+
+
14
+
Workers Contacted
-
- - {/* Push notification highlight */} -
- 🔔 -
-
Instant Worker Push Alerts
-

- When you send a message, the worker receives a push notification on their mobile app immediately, ensuring rapid responses. -

+
+
{stats.shortlisted_count}
+
Shortlisted
+
+
+
{stats.hired_count}
+
Secure Hires
- )} +
{/* Quick Actions Panel (Col 4) */} @@ -338,19 +268,6 @@ export default function Dashboard({
- -
- -
-
-
Post a New Job
-
Receive applications directly
-
- - - {/* Saved Searches Module */} -
-
-

Saved Searches

- -
- -
- {saved_searches.map((search) => ( - - - {search.name} - - - - ))} -
-
- {/* Charity Events Module */}
diff --git a/resources/js/Pages/Employer/Profile.jsx b/resources/js/Pages/Employer/Profile.jsx index 15a7516..ec4de0b 100644 --- a/resources/js/Pages/Employer/Profile.jsx +++ b/resources/js/Pages/Employer/Profile.jsx @@ -24,17 +24,19 @@ import { import { toast } from 'sonner'; export default function Profile({ employerProfile }) { - const { data: profile, setData: setProfile, post, processing, errors } = useForm({ - name: employerProfile?.name || 'Fatima Al Mansoori', - company_name: employerProfile?.company_name || 'Household Sponsor Account', - email: employerProfile?.email || 'fatima.sponsor@marketplace.ae', - phone: employerProfile?.phone || '+971 50 111 2222', + const { data: profile, setData, post, processing, errors } = useForm({ + name: employerProfile?.name || '', + company_name: employerProfile?.company_name || '', + email: employerProfile?.email || '', + phone: employerProfile?.phone || '', language: employerProfile?.language || 'English', - nationality: employerProfile?.nationality || 'UAE National', - family_size: employerProfile?.family_size || '4 Members', - accommodation: employerProfile?.accommodation || 'Villa', - district: employerProfile?.district || 'Dubai Marina', + nationality: employerProfile?.nationality || '', + family_size: employerProfile?.family_size || '', + accommodation: employerProfile?.accommodation || '', + district: employerProfile?.district || '', notifications: employerProfile?.notifications ?? true, + emirates_id_front: null, + emirates_id_back: null, current_password: '', new_password: '', new_password_confirmation: '', @@ -45,16 +47,25 @@ export default function Profile({ employerProfile }) { const handleSave = (e) => { e.preventDefault(); - setSaved(true); - toast.success("🛡️ Profile updated successfully", { - description: "Household sponsor preferences and vetted credentials synchronized successfully." + post('/employer/profile/update', { + preserveScroll: true, + onSuccess: () => { + setSaved(true); + toast.success("🛡️ Profile updated successfully", { + description: "Household sponsor preferences and vetted credentials synchronized successfully." + }); + setTimeout(() => setSaved(false), 3000); + }, + onError: (errs) => { + toast.error("Failed to update profile", { + description: Object.values(errs)[0] || "Please check the form fields." + }); + } }); - setTimeout(() => setSaved(false), 3000); }; const tabs = [ - { id: 'personal', label: 'Personal & Household', icon: User }, - { id: 'company', label: 'Verification & History', icon: ShieldCheck }, + { id: 'personal', label: 'Personal', icon: User }, { id: 'security', label: 'Security & Password', icon: Lock }, { id: 'billing', label: 'Billing & Receipts', icon: CreditCard }, { id: 'notifications', label: 'Notifications Hub', icon: Bell }, @@ -134,8 +145,8 @@ export default function Profile({ employerProfile }) { {activeTab === 'personal' && (
-

Personal & Household details

-

Manage sponsor bio and residence style

+

Personal details

+

Manage sponsor bio and credentials

@@ -146,7 +157,7 @@ export default function Profile({ employerProfile }) { setProfile({ ...profile, name: e.target.value })} + onChange={(e) => setData('name', e.target.value)} className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none" />
@@ -159,7 +170,7 @@ export default function Profile({ employerProfile }) { setProfile({ ...profile, email: e.target.value })} + onChange={(e) => setData('email', e.target.value)} className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none" />
@@ -172,7 +183,7 @@ export default function Profile({ employerProfile }) { setProfile({ ...profile, phone: e.target.value })} + onChange={(e) => setData('phone', e.target.value)} className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none" />
@@ -184,13 +195,14 @@ export default function Profile({ employerProfile }) {
@@ -201,12 +213,13 @@ export default function Profile({ employerProfile }) {
@@ -217,12 +230,13 @@ export default function Profile({ employerProfile }) {
@@ -234,62 +248,134 @@ export default function Profile({ employerProfile }) { setProfile({ ...profile, district: e.target.value })} + onChange={(e) => setData('district', e.target.value)} className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none" />
-
- )} - {activeTab === 'company' && ( -
{/* Emirates ID Verification */} -
+

Emirates ID Verification

Regulatory compliance credentials

-
-
- -
-
-

Emirates ID Vetted Status

-

Reference: EID-9182-XJ

-

- Your Emirates ID is active and fully validated under Ministry of Human Resources (MOHRE) guidelines. Direct hiring is legally enabled. -

-
- - Verified on Jan 15, 2026 + {employerProfile?.verification_status === 'approved' && employerProfile?.emirates_id_front && employerProfile?.emirates_id_back ? ( +
+
+ +
+
+

Emirates ID Verified

+

Vetted under MOHRE Guidelines

+

+ Your Emirates ID has been verified successfully. Your account is active for direct hiring. +

-
-
- - {/* Hiring History Logs */} -
-
- -

Sponsorship Hiring History Logs

-
- -
- {pastHires.map((hire) => ( -
-
-
{hire.name} ({hire.nationality})
-
{hire.category} • Hired: {hire.date}
-
- - {hire.status} - + ) : employerProfile?.verification_status === 'pending' && (employerProfile?.emirates_id_front || employerProfile?.emirates_id_back) ? ( +
+
+
- ))} +
+

Verification Pending Audit

+

Audit Queue

+

+ Your Emirates ID document uploads are currently under review. Audits are typically completed within 24 hours. +

+
+
+ ) : ( +
+
+ +
+
+

Emirates ID Verification Required

+

Action Needed

+

+ Please upload high-resolution color scans of your Emirates ID to verify your identity and activate full hiring features. +

+
+
+ )} + + {/* File Upload Fields */} +
+
+ + setData('emirates_id_front', e.target.files[0])} + className="w-full text-xs font-semibold text-slate-500 file:mr-4 file:py-2.5 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-black file:bg-[#185FA5]/10 file:text-[#185FA5] hover:file:bg-[#185FA5]/20 cursor-pointer" + /> + {errors.emirates_id_front &&

{errors.emirates_id_front}

} +
+ +
+ + setData('emirates_id_back', e.target.files[0])} + className="w-full text-xs font-semibold text-slate-500 file:mr-4 file:py-2.5 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-black file:bg-[#185FA5]/10 file:text-[#185FA5] hover:file:bg-[#185FA5]/20 cursor-pointer" + /> + {errors.emirates_id_back &&

{errors.emirates_id_back}

} +
+ + {/* Uploaded Documents Details */} + {(employerProfile?.emirates_id_front || employerProfile?.emirates_id_back) && ( +
+

Uploaded Credentials Scans

+
+ {employerProfile?.emirates_id_front && ( +
+
+
+ ID +
+
+
Emirates ID (Front)
+
{employerProfile.emirates_id_front.split('/').pop()}
+
+
+ + View Scan + +
+ )} + {employerProfile?.emirates_id_back && ( +
+
+
+ ID +
+
+
Emirates ID (Back)
+
{employerProfile.emirates_id_back.split('/').pop()}
+
+
+ + View Scan + +
+ )} +
+
+ )}
)} diff --git a/resources/js/Pages/Employer/Workers/Index.jsx b/resources/js/Pages/Employer/Workers/Index.jsx index 20df60d..9cb8548 100644 --- a/resources/js/Pages/Employer/Workers/Index.jsx +++ b/resources/js/Pages/Employer/Workers/Index.jsx @@ -267,12 +267,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],

Instantly connect with UAE vetted, background-checked domestic workers.

-
{/* Filter and Search Panel */} diff --git a/routes/api.php b/routes/api.php index 5c316d1..82056dc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,6 +22,10 @@ */ // Unprotected Worker Mobile Auth Endpoints +Route::get('/workers/config', [WorkerAuthController::class, 'config']); +Route::post('/workers/send-otp', [WorkerAuthController::class, 'sendOtp']); +Route::post('/workers/verify-otp', [WorkerAuthController::class, 'verifyOtp']); +Route::post('/workers/setup-profile', [WorkerAuthController::class, 'setupProfile']); Route::post('/workers/register', [WorkerAuthController::class, 'register']); Route::post('/workers/login', [WorkerAuthController::class, 'login']); @@ -43,6 +47,10 @@ // Profile Management Route::get('/workers/profile', [WorkerProfileController::class, 'getProfile']); Route::post('/workers/profile/update', [WorkerProfileController::class, 'updateProfile']); + Route::post('/workers/profile/upload-emirates-id', [WorkerProfileController::class, 'uploadEmiratesId']); + Route::post('/workers/go-live', [WorkerProfileController::class, 'goLive']); + Route::post('/workers/profile/toggle-availability', [WorkerProfileController::class, 'toggleAvailability']); + Route::post('/workers/profile/mark-hired', [WorkerProfileController::class, 'markHired']); // Job Offers Management Route::get('/workers/offers', [WorkerProfileController::class, 'getOffers']); diff --git a/tests/Feature/WorkerJourneyApiTest.php b/tests/Feature/WorkerJourneyApiTest.php new file mode 100644 index 0000000..d2e4933 --- /dev/null +++ b/tests/Feature/WorkerJourneyApiTest.php @@ -0,0 +1,385 @@ +insert([ + 'id' => 7, + 'name' => 'General Helper', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->category = (object) ['id' => 7]; + } + + /** + * Test S1 Onboard: Get Config and Supported Languages. + */ + public function test_s1_get_languages_config() + { + $response = $this->getJson('/api/workers/config'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'languages' => [ + ['code' => 'HI', 'name' => 'Hindi (हिन्दी)'], + ['code' => 'SW', 'name' => 'Swahili (Kiswahili)'], + ['code' => 'TL', 'name' => 'Tagalog (Wikang Tagalog)'], + ['code' => 'TA', 'name' => 'Tamil (தமிழ்)'], + ] + ]); + } + + /** + * Test S1 Onboard: Send and Verify OTP. + */ + public function test_s1_otp_flow() + { + $phone = '+971501234567'; + + // 1. Send OTP + $sendResponse = $this->postJson('/api/workers/send-otp', [ + 'phone' => $phone, + ]); + + $sendResponse->assertStatus(200) + ->assertJson([ + 'success' => true, + 'identifier' => $phone, + ]); + + // Verify OTP is stored in cache + $cached = Cache::get('worker_otp_' . $phone); + $this->assertNotNull($cached); + $this->assertEquals('111111', $cached['code']); // Dev default OTP + + // 2. Verify OTP + $verifyResponse = $this->postJson('/api/workers/verify-otp', [ + 'phone' => $phone, + 'otp' => '111111', + ]); + + $verifyResponse->assertStatus(200) + ->assertJson([ + 'success' => true, + 'identifier' => $phone, + ]); + + // Verify the gate cache is present + $this->assertTrue(Cache::get('worker_otp_verified_' . $phone)); + } + + /** + * Test S1 Onboard: Complete Basic Profile Setup. + */ + public function test_s1_complete_basic_profile_setup() + { + $phone = '+971501234567'; + + // Bypass OTP by seeding the cache gate + Cache::put('worker_otp_verified_' . $phone, true, 3600); + + $response = $this->postJson('/api/workers/setup-profile', [ + 'phone' => $phone, + 'name' => 'Rahul Sharma', + 'nationality' => 'India', + 'language' => 'HI', + 'skills' => [], + ]); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'success', + 'message', + 'data' => [ + 'worker' => [ + 'id', + 'name', + 'email', + 'phone', + 'language', + 'verified', + 'status', + ], + 'token', + ] + ]); + + $this->assertDatabaseHas('workers', [ + 'name' => 'Rahul Sharma', + 'phone' => $phone, + 'language' => 'HI', + 'verified' => false, + 'status' => 'active', + ]); + + // Gate cache should be purged + $this->assertNull(Cache::get('worker_otp_verified_' . $phone)); + } + + /** + * Test S2 Profile: Add Experience & Preferences. + */ + public function test_s2_update_experience_and_preferences() + { + $worker = Worker::create([ + 'name' => 'John Swahili', + 'email' => 'john@example.com', + 'phone' => '+971509999999', + 'language' => 'SW', + 'password' => bcrypt('password'), + 'nationality' => 'Kenya', + 'age' => 22, + 'salary' => 1200, + 'availability' => 'Immediate', + 'experience' => 'None', + 'religion' => 'Christian', + 'bio' => 'New helper', + 'category_id' => $this->category->id, + 'verified' => false, + 'status' => 'active', + 'api_token' => 'test-bearer-token-123', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer test-bearer-token-123', + ])->postJson('/api/workers/profile/update', [ + 'experience' => '3 Years Cooking & Childcare', + 'salary' => 1800, + 'bio' => 'Extremely experienced in Arabic and Western cooking.', + ]); + + $response->assertStatus(200); + + $this->assertDatabaseHas('workers', [ + 'id' => $worker->id, + 'experience' => '3 Years Cooking & Childcare', + 'salary' => 1800, + ]); + } + + /** + * Test S2 Profile: Upload Emirates ID & PDPL Security Purge & Verified Badge Awarded. + */ + public function test_s2_upload_emirates_id_and_pdpl_secure_purge() + { + Storage::fake('local'); + + $worker = Worker::create([ + 'name' => 'Juan Dela Cruz', + 'email' => 'juan@example.com', + 'phone' => '+971507777777', + 'language' => 'TL', + 'password' => bcrypt('password'), + 'nationality' => 'Philippines', + 'age' => 28, + 'salary' => 1500, + 'availability' => 'Immediate', + 'experience' => 'Not Specified', + 'religion' => 'Not Specified', + 'bio' => 'Looking for helper job', + 'category_id' => $this->category->id, + 'verified' => false, + 'status' => 'active', + 'api_token' => 'test-bearer-token-456', + ]); + + $fakeIdFile = UploadedFile::fake()->create('emirates_id.jpg', 800, 'image/jpeg'); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer test-bearer-token-456', + ])->postJson('/api/workers/profile/upload-emirates-id', [ + 'emirates_id_file' => $fakeIdFile, + ]); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'success', + 'message', + 'ocr_extracted_data' => [ + 'name', + 'emirates_id_number', + 'expiry_date', + ], + 'data' => [ + 'worker', + 'document', + ] + ]); + + // Asserts database states + $this->assertDatabaseHas('workers', [ + 'id' => $worker->id, + 'verified' => true, // Badge awarded! + ]); + + $this->assertDatabaseHas('worker_documents', [ + 'worker_id' => $worker->id, + 'type' => 'emirates_id', + 'file_path' => '[DELETED_FOR_PDPL_COMPLIANCE]', // Image file removed for PDPL + ]); + + // Ensure physical file is deleted and not stored in storage disk + Storage::disk('local')->assertDirectoryEmpty('temp/documents'); + } + + /** + * Test S3 Go Live: Status set to Active. + */ + public function test_s3_go_live() + { + $worker = Worker::create([ + 'name' => 'Selvi Tamil', + 'email' => 'selvi@example.com', + 'phone' => '+971508888888', + 'language' => 'TA', + 'password' => bcrypt('password'), + 'nationality' => 'Sri Lanka', + 'age' => 32, + 'salary' => 1400, + 'availability' => 'Not Available', + 'experience' => 'Not Specified', + 'religion' => 'Not Specified', + 'bio' => 'Ready to work', + 'category_id' => $this->category->id, + 'verified' => true, + 'status' => 'inactive', + 'api_token' => 'test-bearer-token-789', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer test-bearer-token-789', + ])->postJson('/api/workers/go-live'); + + $response->assertStatus(200) + ->assertJsonPath('data.worker.status', 'active') + ->assertJsonPath('data.worker.availability', 'Immediate'); + + $this->assertDatabaseHas('workers', [ + 'id' => $worker->id, + 'status' => 'active', + 'availability' => 'Immediate', + ]); + } + + /** + * Test S5 Avail?: Toggle availability (Still looking?). + */ + public function test_s5_toggle_availability_still_looking() + { + $worker = Worker::create([ + 'name' => 'Rahul Sharma', + 'email' => 'rahul@example.com', + 'phone' => '+971501234567', + 'language' => 'HI', + 'password' => bcrypt('password'), + 'nationality' => 'India', + 'age' => 25, + 'salary' => 1500, + 'availability' => 'Immediate', + 'experience' => 'Not Specified', + 'religion' => 'Not Specified', + 'bio' => 'Test', + 'category_id' => $this->category->id, + 'verified' => true, + 'status' => 'active', + 'api_token' => 'test-bearer-token-abc', + ]); + + // Toggle still_looking = false (NO -> Hidden) + $response1 = $this->withHeaders([ + 'Authorization' => 'Bearer test-bearer-token-abc', + ])->postJson('/api/workers/profile/toggle-availability', [ + 'still_looking' => false, + ]); + + $response1->assertStatus(200) + ->assertJson([ + 'success' => true, + 'still_looking' => false, + 'status' => 'hidden', + ]); + + $this->assertDatabaseHas('workers', [ + 'id' => $worker->id, + 'status' => 'hidden', + ]); + + // Toggle still_looking = true (YES -> Visible/Active) + $response2 = $this->withHeaders([ + 'Authorization' => 'Bearer test-bearer-token-abc', + ])->postJson('/api/workers/profile/toggle-availability', [ + 'still_looking' => true, + ]); + + $response2->assertStatus(200) + ->assertJson([ + 'success' => true, + 'still_looking' => true, + 'status' => 'active', + ]); + + $this->assertDatabaseHas('workers', [ + 'id' => $worker->id, + 'status' => 'active', + ]); + } + + /** + * Test S6 Outcome: Marked as Hired (Profile removed from search - auto-hidden). + */ + public function test_s6_outcome_mark_hired() + { + $worker = Worker::create([ + 'name' => 'Rahul Sharma', + 'email' => 'rahul@example.com', + 'phone' => '+971501234567', + 'language' => 'HI', + 'password' => bcrypt('password'), + 'nationality' => 'India', + 'age' => 25, + 'salary' => 1500, + 'availability' => 'Immediate', + 'experience' => 'Not Specified', + 'religion' => 'Not Specified', + 'bio' => 'Test', + 'category_id' => $this->category->id, + 'verified' => true, + 'status' => 'active', + 'api_token' => 'test-bearer-token-abc', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer test-bearer-token-abc', + ])->postJson('/api/workers/profile/mark-hired'); + + $response->assertStatus(200) + ->assertJsonPath('data.worker.status', 'Hired'); + + $this->assertDatabaseHas('workers', [ + 'id' => $worker->id, + 'status' => 'Hired', + ]); + } +}