all(), [ 'full_name' => 'required|string|max:255', 'mobile' => 'required|string|max:50|unique:sponsors,mobile|unique:employer_profiles,phone', 'password' => 'required|string|min:6', 'license' => 'nullable|array', 'emirates_id' => 'nullable|array', 'organization_name' => 'required|string|max:255', 'email' => 'required|email|max:255|unique:sponsors,email|unique:users,email', 'nationality' => 'required|string|max:100', 'city' => 'required|string|max:100', 'address' => 'required|string|max:255', 'country_code' => 'required|string|max:10', 'license_expiry' => 'nullable|date_format:Y-m-d', 'fcm_token' => 'nullable|string|max:255', ], [ 'mobile.unique' => 'This mobile number is already registered.', 'email.unique' => 'This email address is already registered.', ]); if ($validator->fails()) { return response()->json([ 'success' => false, 'message' => 'Validation error.', 'errors' => $validator->errors() ], 422); } try { // Auto-generate email if not provided $mobileClean = preg_replace('/[^0-9]/', '', $request->mobile); $email = $request->email ?? "sponsor.{$mobileClean}@migrant.ae"; if (!$request->email && Sponsor::where('email', $email)->exists()) { $email = "sponsor.{$mobileClean}." . rand(10, 99) . "@migrant.ae"; } $licenseExpiry = $request->license_expiry; $licenseInput = $request->input('license'); if (is_array($licenseInput)) { $licenseExpiry = $licenseExpiry ?? ($licenseInput['expiry_date'] ?? $licenseInput['license_expiry'] ?? null); } $emiratesIdInput = $request->input('emirates_id'); $emiratesId = null; $emiratesIdExpiry = null; $emiratesIdName = null; $emiratesIdDob = null; $emiratesIdNationality = null; $emiratesIdIssueDate = null; $emiratesIdEmployer = null; $emiratesIdIssuePlace = null; $emiratesIdOccupation = null; if (is_array($emiratesIdInput)) { $emiratesId = $emiratesIdInput['emirates_id_number'] ?? $emiratesIdInput['id_number'] ?? $emiratesIdInput['id number'] ?? $emiratesIdInput['card_number'] ?? null; $emiratesIdExpiry = $emiratesIdInput['expiry_date'] ?? $emiratesIdInput['expiry date'] ?? $emiratesIdInput['emirates_id_expiry'] ?? null; $emiratesIdName = $emiratesIdInput['name'] ?? null; $emiratesIdDob = $emiratesIdInput['date_of_birth'] ?? $emiratesIdInput['date of birth'] ?? $emiratesIdInput['dob'] ?? null; $emiratesIdNationality = $emiratesIdInput['nationality'] ?? null; $emiratesIdIssueDate = $emiratesIdInput['issue_date'] ?? $emiratesIdInput['issue date'] ?? null; $emiratesIdEmployer = $emiratesIdInput['employer'] ?? null; $emiratesIdIssuePlace = $emiratesIdInput['issue_place'] ?? $emiratesIdInput['issue place'] ?? null; $emiratesIdOccupation = $emiratesIdInput['occupation'] ?? $emiratesIdInput['occuption'] ?? null; } $emiratesIdPath = null; $licensePath = null; $apiToken = Str::random(80); $sponsor = DB::transaction(function () use ( $request, $email, $licensePath, $emiratesIdPath, $emiratesId, $apiToken, $licenseExpiry, $emiratesIdExpiry, $emiratesIdName, $emiratesIdDob, $emiratesIdIssueDate, $emiratesIdEmployer, $emiratesIdIssuePlace, $emiratesIdOccupation ) { return Sponsor::create([ 'full_name' => $request->full_name, 'organization_name' => $request->organization_name, 'email' => $email, 'mobile' => $request->mobile, 'password' => Hash::make($request->password), 'country_code' => $request->country_code, 'nationality' => $request->nationality, 'city' => $request->city, 'address' => $request->address, 'license_file' => $licensePath, 'emirates_id_file' => $emiratesIdPath, 'emirates_id' => $emiratesId, 'license_expiry' => $licenseExpiry, 'status' => 'active', 'is_verified' => false, 'subscription_status' => 'none', 'api_token' => $apiToken, 'fcm_token' => $request->fcm_token, 'emirates_id_name' => $emiratesIdName, 'emirates_id_dob' => $emiratesIdDob, 'emirates_id_issue_date' => $emiratesIdIssueDate, 'emirates_id_expiry' => $emiratesIdExpiry, 'emirates_id_employer' => $emiratesIdEmployer, 'emirates_id_issue_place' => $emiratesIdIssuePlace, 'emirates_id_occupation' => $emiratesIdOccupation, ]); }); if ($request->filled('fcm_token')) { \App\Services\FCMService::sendPushNotification( $request->fcm_token, 'Welcome to Migrant', 'Sponsor account registered successfully. Your license is pending admin review.' ); } return response()->json([ 'success' => true, 'message' => 'Sponsor account registered successfully. Your license is pending admin review.', 'data' => [ 'sponsor' => $sponsor->makeHidden(['password', 'api_token']), 'token' => $apiToken, ] ], 201); } catch (\Exception $e) { logger()->error('Sponsor Registration Failure: ' . $e->getMessage()); return response()->json([ 'success' => false, 'message' => 'An error occurred during registration. Please try again.', 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' ], 500); } } /** * Authenticate a Sponsor and return their bearer token. * * POST /api/sponsors/login */ public function login(Request $request) { $validator = Validator::make($request->all(), [ 'mobile' => 'required|string', 'password' => 'required|string', 'fcm_token' => 'nullable|string|max:255', ]); if ($validator->fails()) { return response()->json([ 'success' => false, 'message' => 'Validation error.', 'errors' => $validator->errors() ], 422); } $sponsor = Sponsor::where('mobile', $request->mobile)->first(); if (!$sponsor || !Hash::check($request->password, $sponsor->password)) { return response()->json([ 'success' => false, 'message' => 'Invalid mobile number or password.' ], 401); } if ($sponsor->status === 'suspended') { return response()->json([ 'success' => false, 'message' => 'Your account has been suspended. Please contact support.' ], 403); } // Rotate token on each login for security $apiToken = Str::random(80); $updateData = [ 'api_token' => $apiToken, 'last_login_at' => now(), ]; if ($request->has('fcm_token')) { $updateData['fcm_token'] = $request->fcm_token; } $sponsor->update($updateData); if ($request->filled('fcm_token')) { \App\Services\FCMService::sendPushNotification( $request->fcm_token, 'Successful Login', 'Welcome back to your Migrant sponsor account.' ); } return response()->json([ 'success' => true, 'message' => 'Login successful.', 'data' => [ 'sponsor' => $sponsor->makeHidden(['password', 'api_token']), 'token' => $apiToken, ] ], 200); } /** * Upload Sponsor's License document. * POST /api/sponsors/register/license */ public function uploadLicense(Request $request) { $validator = Validator::make($request->all(), [ 'email' => 'required|string|email|max:255', 'license_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240', ], [ 'email.required' => 'The email address is required.', 'license_file.required' => 'Please upload the organization or trade license.', 'license_file.mimes' => 'The license file must be an image or a PDF.', ]); if ($validator->fails()) { return response()->json([ 'success' => false, 'message' => 'Validation error.', 'errors' => $validator->errors() ], 422); } try { $sponsor = Sponsor::where('email', $request->email)->first(); if (!$sponsor) { return response()->json([ 'success' => false, 'message' => 'Sponsor account not found.' ], 404); } $licenseFile = $request->file('license_file'); $ocrLicense = \App\Services\OcrDocumentService::extractSponsorLicenseData($licenseFile); $extractedExpiry = $ocrLicense['expiry_date']; $sponsor->update([ 'license_file' => null, 'license_expiry' => $extractedExpiry, ]); return response()->json([ 'success' => true, 'message' => 'License uploaded and processed successfully.', 'ocr_extracted_data' => $ocrLicense, 'email' => $request->email, ], 200); } catch (\Exception $e) { logger()->error('API Sponsor License Upload Failure: ' . $e->getMessage()); return response()->json([ 'success' => false, 'message' => 'An error occurred while uploading the license.', 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' ], 500); } } // ------------------------------------------------------------------------- // Forgot / Reset Password // ------------------------------------------------------------------------- /** * POST /api/sponsors/forgot-password * Send a 6-digit OTP to the sponsor's registered email. * Accepts: email OR mobile */ public function forgotPassword(Request $request) { $validator = Validator::make($request->all(), [ 'email' => 'nullable|email', 'mobile' => 'nullable|string', ]); $validator->after(function ($v) use ($request) { if (!$request->filled('email') && !$request->filled('mobile')) { $v->errors()->add('email', 'Either email or mobile number is required.'); } }); if ($validator->fails()) { return response()->json([ 'success' => false, 'message' => 'Validation error.', 'errors' => $validator->errors(), ], 422); } $sponsor = null; if ($request->filled('email')) { $sponsor = Sponsor::where('email', $request->email)->first(); } elseif ($request->filled('mobile')) { $sponsor = Sponsor::where('mobile', $request->mobile)->first(); } // Prevent email enumeration — always return success if (!$sponsor || !$sponsor->email) { return response()->json([ 'success' => true, 'message' => 'If an account with that contact exists, a reset OTP has been sent.', ]); } $otp = (string) mt_rand(100000, 999999); $cacheKey = 'sponsor_pwd_reset_' . md5($sponsor->email); \Illuminate\Support\Facades\Cache::put($cacheKey, [ 'otp' => Hash::make($otp), 'expires_at' => now()->addMinutes(10)->timestamp, ], now()->addMinutes(10)); try { \Illuminate\Support\Facades\Mail::to($sponsor->email)->send( new \App\Mail\ForgotPasswordOtpMail($otp, $sponsor->full_name, 'Sponsor') ); } catch (\Exception $e) { logger()->error('Sponsor forgot-password mail failed: ' . $e->getMessage()); } return response()->json([ 'success' => true, 'message' => 'If an account with that contact exists, a reset OTP has been sent.', ]); } /** * POST /api/sponsors/reset-password * Verify OTP and set new password. */ public function resetPassword(Request $request) { $validator = Validator::make($request->all(), [ 'email' => 'required|email', 'otp' => 'required|string|size:6', 'password' => 'required|string|min:8|confirmed', 'password_confirmation' => 'required|string', ]); if ($validator->fails()) { return response()->json([ 'success' => false, 'message' => 'Validation error.', 'errors' => $validator->errors(), ], 422); } $cacheKey = 'sponsor_pwd_reset_' . md5($request->email); $cached = \Illuminate\Support\Facades\Cache::get($cacheKey); if (!$cached || $cached['expires_at'] < now()->timestamp) { \Illuminate\Support\Facades\Cache::forget($cacheKey); return response()->json([ 'success' => false, 'message' => 'OTP has expired or is invalid. Please request a new one.', ], 422); } if (!Hash::check($request->otp, $cached['otp'])) { return response()->json([ 'success' => false, 'message' => 'Invalid OTP. Please check and try again.', ], 422); } $sponsor = Sponsor::where('email', $request->email)->first(); if (!$sponsor) { return response()->json([ 'success' => false, 'message' => 'Account not found.', ], 404); } $sponsor->update(['password' => Hash::make($request->password)]); \Illuminate\Support\Facades\Cache::forget($cacheKey); return response()->json([ 'success' => true, 'message' => 'Password reset successfully. You can now log in with your new password.', ]); } }