diff --git a/app/Http/Controllers/Api/WorkerAuthController.php b/app/Http/Controllers/Api/WorkerAuthController.php new file mode 100644 index 0000000..130f547 --- /dev/null +++ b/app/Http/Controllers/Api/WorkerAuthController.php @@ -0,0 +1,244 @@ +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 new file mode 100644 index 0000000..cb5364d --- /dev/null +++ b/app/Http/Controllers/Api/WorkerProfileController.php @@ -0,0 +1,210 @@ +attributes->get('worker'); + + $worker->load(['category', 'skills', 'documents']); + + return response()->json([ + 'success' => true, + 'data' => [ + 'worker' => $worker + ] + ], 200); + } + + /** + * Update authorized worker's profile details. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function updateProfile(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $validator = Validator::make($request->all(), [ + 'name' => 'nullable|string|max:255', + 'age' => 'nullable|integer|min:18|max:70', + 'nationality' => 'nullable|string|max:100', + 'salary' => 'nullable|numeric|min:0', + 'availability' => 'nullable|string|max:100', + 'experience' => 'nullable|string|max:100', + 'religion' => 'nullable|string|max:100', + 'bio' => 'nullable|string|max:1000', + 'category_id' => 'nullable|exists:worker_categories,id', + 'skills' => 'nullable|array', + 'skills.*' => 'exists:skills,id', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + DB::transaction(function () use ($request, $worker) { + // Update worker attributes + $updateData = $request->only([ + 'name', 'age', 'nationality', 'salary', 'availability', + 'experience', 'religion', 'bio', 'category_id' + ]); + + // Filter out null inputs if we want to support partial updates + $updateData = array_filter($updateData, function ($value) { + return !is_null($value); + }); + + $worker->update($updateData); + + // Sync skills if provided + if ($request->has('skills')) { + $worker->skills()->sync($request->skills ?: []); + } + }); + + $worker->load(['category', 'skills', 'documents']); + + return response()->json([ + 'success' => true, + 'message' => 'Profile updated successfully.', + 'data' => [ + 'worker' => $worker + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile Worker Profile Update Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while updating your profile.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * Get all job offers received by the worker. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function getOffers(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $offers = JobOffer::where('worker_id', $worker->id) + ->with(['employer.employerProfile']) // Load employer and their profile details + ->latest() + ->get(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'offers' => $offers + ] + ], 200); + } + + /** + * Respond to a specific job offer (Accept or Reject). + * + * @param \Illuminate\Http\Request $request + * @param int $id + * @return \Illuminate\Http\JsonResponse + */ + public function respondToOffer(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $validator = Validator::make($request->all(), [ + 'status' => 'required|string|in:accepted,rejected', + ], [ + 'status.in' => 'Status must be either accepted or rejected.' + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + $offer = JobOffer::where('id', $id) + ->where('worker_id', $worker->id) + ->first(); + + if (!$offer) { + return response()->json([ + 'success' => false, + 'message' => 'Job offer not found.' + ], 404); + } + + if ($offer->status !== 'pending') { + return response()->json([ + 'success' => false, + 'message' => 'This job offer has already been responded to.' + ], 400); + } + + $responseStatus = $request->status; + + DB::transaction(function () use ($offer, $worker, $responseStatus) { + // Update offer status + $offer->update(['status' => $responseStatus]); + + // If accepted, change worker status to 'Hired' + if ($responseStatus === 'accepted') { + $worker->update(['status' => 'Hired']); + } + }); + + return response()->json([ + 'success' => true, + 'message' => "Job offer successfully " . $responseStatus . ".", + 'data' => [ + 'offer' => $offer, + 'worker_status' => $worker->fresh()->status + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile Respond to Offer Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while responding to the job offer.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } +} diff --git a/app/Http/Controllers/Employer/CandidateController.php b/app/Http/Controllers/Employer/CandidateController.php index a0d3627..2813633 100644 --- a/app/Http/Controllers/Employer/CandidateController.php +++ b/app/Http/Controllers/Employer/CandidateController.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Models\JobApplication; use App\Models\JobPost; +use App\Models\JobOffer; class CandidateController extends Controller { @@ -40,7 +41,7 @@ public function index(Request $request) $user = $this->resolveCurrentUser(); $employerId = $user ? $user->id : 2; - // Get job posts created by this employer + // 1. Get job posts created by this employer $jobIds = JobPost::where('employer_id', $employerId)->pluck('id'); // Fetch applications for those jobs @@ -61,7 +62,7 @@ public function index(Request $request) else $status = ucfirst($app->status); return [ - 'id' => $app->id, // application id + 'id' => $app->id, // standard application id 'worker_id' => $w->id, 'name' => $w->name, 'nationality' => $w->nationality, @@ -72,8 +73,38 @@ public function index(Request $request) ]; })->filter()->values()->toArray(); + // 2. Fetch direct hiring offers sent by this employer + $directOffers = JobOffer::where('employer_id', $employerId) + ->with('worker.category') + ->get(); + + $directWorkers = $directOffers->map(function ($offer) { + $w = $offer->worker; + if (!$w) return null; + + // Map DB offer status to UI friendly status + $status = 'Offer Sent'; + if ($offer->status === 'accepted') $status = 'Hired'; + elseif ($offer->status === 'rejected') $status = 'Rejected'; + elseif ($offer->status === 'pending') $status = 'Offer Sent'; + + return [ + 'id' => 'offer_' . $offer->id, // Unique string key prefix to prevent clashes + 'worker_id' => $w->id, + 'name' => $w->name, + 'nationality' => $w->nationality, + 'category' => $w->category ? $w->category->name : 'General Helper', + 'salary' => (int)$offer->salary, + 'status' => $status, + 'applied_at' => $offer->created_at->format('M d, Y'), + ]; + })->filter()->values()->toArray(); + + // Merge standard job applications with direct hiring offers for unified tracking + $mergedCandidates = array_merge($selectedWorkers, $directWorkers); + return Inertia::render('Employer/SelectedCandidates', [ - 'selectedWorkers' => $selectedWorkers, + 'selectedWorkers' => $mergedCandidates, ]); } @@ -83,19 +114,51 @@ public function updateStatus(Request $request, $id) 'status' => 'required|string|in:Reviewing,Offer Sent,Hired,Rejected', ]); + // If this is a direct hiring offer response + if (is_string($id) && str_starts_with($id, 'offer_')) { + $offerId = substr($id, 6); + $offer = JobOffer::findOrFail($offerId); + + $dbStatus = 'pending'; + if ($request->status === 'Hired') { + $dbStatus = 'accepted'; + $offer->worker->update(['status' => 'Hired']); + } elseif ($request->status === 'Rejected') { + $dbStatus = 'rejected'; + $offer->worker->update(['status' => 'active']); + } elseif ($request->status === 'Offer Sent') { + $dbStatus = 'pending'; + } + + $offer->update(['status' => $dbStatus]); + + return back()->with('success', 'Direct candidate hiring offer status updated successfully to ' . $request->status); + } + + // Standard job application status update $app = JobApplication::findOrFail($id); - // Map UI status back to DB status $dbStatus = 'applied'; - if ($request->status === 'Hired') $dbStatus = 'hired'; - elseif ($request->status === 'Rejected') $dbStatus = 'rejected'; - elseif ($request->status === 'Offer Sent') $dbStatus = 'shortlisted'; - elseif ($request->status === 'Reviewing') $dbStatus = 'applied'; + if ($request->status === 'Hired') { + $dbStatus = 'hired'; + if ($app->worker) { + $app->worker->update(['status' => 'Hired']); + } + } elseif ($request->status === 'Rejected') { + $dbStatus = 'rejected'; + if ($app->worker) { + $app->worker->update(['status' => 'active']); + } + } elseif ($request->status === 'Offer Sent') { + $dbStatus = 'shortlisted'; + } elseif ($request->status === 'Reviewing') { + $dbStatus = 'applied'; + } $app->update([ 'status' => $dbStatus, ]); - return back()->with('success', 'Candidate status updated successfully to ' . $request->status); + return back()->with('success', 'Candidate application status updated successfully to ' . $request->status); } } diff --git a/app/Http/Controllers/Employer/EmployerAuthController.php b/app/Http/Controllers/Employer/EmployerAuthController.php index 005ebe3..a701110 100644 --- a/app/Http/Controllers/Employer/EmployerAuthController.php +++ b/app/Http/Controllers/Employer/EmployerAuthController.php @@ -25,53 +25,37 @@ public function login(Request $request) 'password' => ['required'], ]); - if ($credentials['email'] === 'pending@example.com') { - return back()->with('status', 'pending_verification'); - } - - if ($credentials['email'] === 'rejected@example.com') { - return back()->with([ - 'status' => 'rejected', - 'reason' => 'Emirates ID scan was blurry or illegible. Please provide a high-resolution color scan.', - ]); - } - - if ($credentials['email'] === 'expired@example.com') { - return back()->with('status', 'subscription_expired'); - } - - // Attempt logging in via standard Auth or fallback credentials - if ($credentials['email'] === 'employer@example.com' && $credentials['password'] === 'password') { - session(['user' => (object)[ - 'id' => 2, - 'name' => 'John Doe (Employer)', - 'email' => 'employer@example.com', - 'role' => 'employer', - 'subscription_status' => 'active', - 'verification_status' => 'approved', - ]]); - - $request->session()->regenerate(); - - if ($request->source === 'mobile') { - return redirect('/mobile/employer/home'); - } - - return redirect()->intended('/employer/dashboard'); - } - // Attempt actual database login $user = User::where('email', $credentials['email'])->first(); if ($user && Hash::check($credentials['password'], $user->password) && $user->role === 'employer') { $profile = EmployerProfile::where('user_id', $user->id)->first(); - + $verification_status = $profile ? $profile->verification_status : 'approved'; + + if ($verification_status === 'pending') { + return back()->with('status', 'pending_verification'); + } + + if ($verification_status === 'rejected') { + return back()->with([ + 'status' => 'rejected', + 'reason' => ($profile && $profile->rejection_reason) ? $profile->rejection_reason : 'Emirates ID scan was blurry or illegible. Please provide a high-resolution color scan.', + ]); + } + + if ($user->subscription_status === 'expired') { + return back()->with('status', 'subscription_expired'); + } + + // Perform standard Laravel authentication + auth()->login($user); + session(['user' => (object)[ 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, 'role' => $user->role, 'subscription_status' => $user->subscription_status ?? 'active', - 'verification_status' => $profile ? $profile->verification_status : 'approved', + 'verification_status' => $verification_status, ]]); $request->session()->regenerate(); @@ -84,7 +68,7 @@ public function login(Request $request) } return back()->withErrors([ - 'email' => 'Invalid credentials. Use employer@example.com (or test emails: pending@, rejected@, expired@ / password)', + 'email' => 'These credentials do not match our records.', ]); } @@ -101,7 +85,6 @@ public function register(Request $request) 'name' => 'required|string|max:255', 'email' => 'required|string|email|max:255', 'phone' => 'required|string|regex:/^\+?[0-9\s\-()]{7,20}$/', - 'country' => 'required|string|max:100', ], [ 'phone.regex' => 'The phone number must be a valid mobile number format.', ]); @@ -125,7 +108,6 @@ public function register(Request $request) 'name' => $request->name, 'email' => $request->email, 'phone' => $request->phone, - 'country' => $request->country, ], 'employer_otp' => [ 'hash' => $hashedOtp, @@ -143,7 +125,7 @@ public function register(Request $request) $request->name )); } catch (\Exception $e) { - // Log error but proceed for demonstration/local testing if mail server is not fully configured + // Log error but proceed for local testing/robustness logger()->error('Failed to send registration OTP email: ' . $e->getMessage()); } @@ -310,7 +292,7 @@ public function createPassword(Request $request) 'user_id' => $user->id, 'company_name' => $pending['company_name'], 'phone' => $pending['phone'], - 'country' => $pending['country'], + 'country' => 'United Arab Emirates', // Default country since dropdown was removed 'verification_status' => 'approved', 'language' => 'English', 'notifications' => true, diff --git a/app/Http/Controllers/Employer/MessageController.php b/app/Http/Controllers/Employer/MessageController.php index 430b3cc..14e5ab5 100644 --- a/app/Http/Controllers/Employer/MessageController.php +++ b/app/Http/Controllers/Employer/MessageController.php @@ -152,4 +152,26 @@ public function send(Request $request, $id) return back(); } + + /** + * Start or load a conversation with a specific worker. + * + * @param int $workerId + * @return \Illuminate\Http\RedirectResponse + */ + public function startConversation($workerId) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + // Find or create conversation + $conv = Conversation::firstOrCreate([ + 'employer_id' => $user->id, + 'worker_id' => $workerId, + ]); + + return redirect()->route('employer.messages.show', ['id' => $conv->id]); + } } diff --git a/app/Http/Controllers/Employer/WorkerController.php b/app/Http/Controllers/Employer/WorkerController.php index b3b27f6..2b68ac5 100644 --- a/app/Http/Controllers/Employer/WorkerController.php +++ b/app/Http/Controllers/Employer/WorkerController.php @@ -9,6 +9,7 @@ use App\Models\Worker; use App\Models\WorkerCategory; use App\Models\Shortlist; +use App\Models\JobOffer; class WorkerController extends Controller { @@ -41,10 +42,8 @@ public function index(Request $request) $user = $this->resolveCurrentUser(); $employerId = $user ? $user->id : 2; - // Fetch workers from DB - $dbWorkers = Worker::with(['category', 'skills']) - ->where('status', 'active') - ->get(); + // Fetch all non-deleted workers to show their status changes (active vs Hired) + $dbWorkers = Worker::with(['category', 'skills'])->get(); $workers = $dbWorkers->map(function ($w) { return [ @@ -57,9 +56,10 @@ public function index(Request $request) 'experience' => $w->experience, 'salary' => (int)$w->salary, 'religion' => $w->religion, - 'languages' => ['English', 'Arabic'], // Custom language field or mock + 'languages' => ['English', 'Arabic'], 'age' => $w->age, 'verified' => (bool)$w->verified, + 'status' => $w->status, // Map worker status dynamically (e.g. active, Hired) 'bio' => $w->bio, ]; })->toArray(); @@ -69,7 +69,7 @@ public function index(Request $request) // Dynamically build filter metadata from DB values $dbCategories = WorkerCategory::pluck('name')->toArray(); - $dbNationalities = Worker::distinct()->where('status', 'active')->pluck('nationality')->toArray(); + $dbNationalities = Worker::distinct()->pluck('nationality')->toArray(); $filtersMetadata = [ 'categories' => array_merge(['All Categories'], $dbCategories), @@ -98,12 +98,13 @@ public function show($id) 'skills' => $w->skills->pluck('name')->toArray(), 'availability' => $w->availability, 'experience' => $w->experience, - 'experience_years' => 5, // Can parse or default + 'experience_years' => 5, 'salary' => (int)$w->salary, 'religion' => $w->religion, 'languages' => ['English', 'Arabic'], 'age' => $w->age, 'verified' => (bool)$w->verified, + 'status' => $w->status, 'bio' => $w->bio, 'passport_status' => 'OCR Verified', 'visa_status' => 'Transferable', @@ -113,4 +114,41 @@ public function show($id) 'worker' => $worker, ]); } + + /** + * Submit a secure hiring offer from the Employer Web Portal. + * + * @param \Illuminate\Http\Request $request + * @param int $id + * @return \Illuminate\Http\RedirectResponse + */ + public function sendOffer(Request $request, $id) + { + $request->validate([ + 'work_date' => 'required|date', + 'location' => 'required|string|max:255', + 'salary' => 'required|numeric|min:0', + 'notes' => 'nullable|string|max:1000', + ]); + + $user = $this->resolveCurrentUser(); + $employerId = $user ? $user->id : 2; + + // Verify worker exists + $worker = Worker::findOrFail($id); + + // Create job offer record + JobOffer::create([ + 'employer_id' => $employerId, + 'worker_id' => $worker->id, + 'work_date' => $request->work_date, + 'location' => $request->location, + 'salary' => $request->salary, + 'notes' => $request->notes, + 'status' => 'pending', + ]); + + return redirect()->route('employer.hiring.success', ['id' => $id]) + ->with('success', 'Hiring offer has been successfully dispatched to the worker!'); + } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 2a1408b..001280a 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -35,9 +35,16 @@ public function version(Request $request): ?string */ public function share(Request $request): array { - $sess = session('user'); - $userId = is_array($sess) ? ($sess['id'] ?? null) : ($sess->id ?? null); - $user = $userId ? \App\Models\User::find($userId) : \App\Models\User::where('role', 'employer')->first(); + $user = null; + if (auth()->check()) { + $user = auth()->user(); + } else { + $sess = session('user'); + $userId = is_array($sess) ? ($sess['id'] ?? null) : ($sess->id ?? null); + if ($userId) { + $user = \App\Models\User::find($userId); + } + } $userData = null; $unreadCount = 0; diff --git a/app/Http/Middleware/WorkerApiMiddleware.php b/app/Http/Middleware/WorkerApiMiddleware.php new file mode 100644 index 0000000..cea8269 --- /dev/null +++ b/app/Http/Middleware/WorkerApiMiddleware.php @@ -0,0 +1,44 @@ +header('Authorization'); + + if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) { + return response()->json([ + 'success' => false, + 'message' => 'Authentication token required.' + ], 401); + } + + $token = substr($authHeader, 7); + $worker = Worker::where('api_token', $token)->first(); + + if (!$worker) { + return response()->json([ + 'success' => false, + 'message' => 'Invalid or expired authentication token.' + ], 401); + } + + // Attach worker object to request attributes so controllers can read it using $request->attributes->get('worker') + $request->attributes->set('worker', $worker); + + return $next($request); + } +} diff --git a/app/Models/JobOffer.php b/app/Models/JobOffer.php new file mode 100644 index 0000000..c6e9cc7 --- /dev/null +++ b/app/Models/JobOffer.php @@ -0,0 +1,36 @@ + 'date', + 'salary' => 'decimal:2', + ]; + + public function employer() + { + return $this->belongsTo(User::class, 'employer_id'); + } + + public function worker() + { + return $this->belongsTo(Worker::class, 'worker_id'); + } +} diff --git a/app/Models/Worker.php b/app/Models/Worker.php index 6031cac..6a08bb3 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -14,6 +14,7 @@ class Worker extends Model 'name', 'email', 'phone', + 'password', 'nationality', 'age', 'salary', @@ -24,11 +25,18 @@ class Worker extends Model 'category_id', 'verified', 'status', + 'api_token', + ]; + + protected $hidden = [ + 'password', + 'api_token', // Hide from public serialization, returned only during auth endpoints specifically ]; protected $casts = [ 'verified' => 'boolean', 'salary' => 'decimal:2', + 'password' => 'hashed', ]; public function category() @@ -45,4 +53,10 @@ public function documents() { return $this->hasMany(WorkerDocument::class); } + + // Relationship for job offers received + public function offers() + { + return $this->hasMany(JobOffer::class, 'worker_id'); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index c481886..bbdee90 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) @@ -18,6 +19,7 @@ $middleware->alias([ 'admin' => \App\Http\Middleware\AdminMiddleware::class, 'employer' => \App\Http\Middleware\EmployerMiddleware::class, + 'auth.worker' => \App\Http\Middleware\WorkerApiMiddleware::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/database/migrations/2026_05_20_104323_add_auth_fields_to_workers_table.php b/database/migrations/2026_05_20_104323_add_auth_fields_to_workers_table.php new file mode 100644 index 0000000..8b79df2 --- /dev/null +++ b/database/migrations/2026_05_20_104323_add_auth_fields_to_workers_table.php @@ -0,0 +1,29 @@ +string('password')->nullable()->after('email'); + $table->string('api_token', 80)->nullable()->unique()->after('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workers', function (Blueprint $table) { + $table->dropColumn(['password', 'api_token']); + }); + } +}; diff --git a/database/migrations/2026_05_20_104357_create_job_offers_table.php b/database/migrations/2026_05_20_104357_create_job_offers_table.php new file mode 100644 index 0000000..65eb144 --- /dev/null +++ b/database/migrations/2026_05_20_104357_create_job_offers_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('employer_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('worker_id')->constrained('workers')->onDelete('cascade'); + $table->date('work_date'); + $table->string('location'); + $table->decimal('salary', 10, 2); + $table->text('notes')->nullable(); + $table->string('status')->default('pending'); // pending, accepted, rejected + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('job_offers'); + } +}; diff --git a/public/swagger.json b/public/swagger.json new file mode 100644 index 0000000..6e45edf --- /dev/null +++ b/public/swagger.json @@ -0,0 +1,680 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Migrant Mobile API", + "description": "Comprehensive API endpoints for the Migrant Mobile Application, covering worker registration, password login, token authentication, profile management, and interactive job offer responses.", + "version": "1.0.0" + }, + "servers": [ + { + "url": "/api", + "description": "Local development API prefix" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/workers/register": { + "post": { + "summary": "Register Worker & Upload Documents (Unified API)", + "description": "Performs worker registration and uploads their passport/visa documents in a single, robust multipart form request. Generates and returns a secure stateless bearer token upon success. Password is required (min 6 characters).", + "security": [], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "name", + "phone", + "salary", + "password", + "passport_file" + ], + "properties": { + "name": { + "type": "string", + "example": "Amina Al-Masry", + "description": "Full legal name of the worker. (REQUIRED)" + }, + "phone": { + "type": "string", + "example": "+971509998888", + "description": "Contact mobile phone number (must be unique). (REQUIRED)" + }, + "salary": { + "type": "number", + "format": "float", + "example": 1500.00, + "description": "Desired minimum monthly salary in AED. (REQUIRED)" + }, + "password": { + "type": "string", + "format": "password", + "example": "securepassword123", + "description": "Secret password for subsequent mobile logins (min 6 chars). (REQUIRED)" + }, + "passport_file": { + "type": "string", + "format": "binary", + "description": "Physical scan or image of the Passport (Max 10MB). (REQUIRED)" + }, + "skills": { + "type": "array", + "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)." + }, + "visa_file": { + "type": "string", + "format": "binary", + "description": "Optional physical scan or image of the Visa (Max 10MB)." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Worker registered and authenticated successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Worker registered and authenticated successfully." + }, + "data": { + "type": "object", + "properties": { + "worker": { + "$ref": "#/components/schemas/Worker" + }, + "token": { + "type": "string", + "example": "abc123xyz456...secureToken..." + } + } + } + } + } + } + } + }, + "422": { + "description": "Validation error details.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Validation error." + }, + "errors": { + "type": "object", + "properties": { + "phone": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "This mobile number is already registered." + ] + } + } + } + } + } + } + } + } + } + } + }, + "/workers/login": { + "post": { + "summary": "Authenticate Worker", + "description": "Validates worker credentials (mobile phone number and password) and generates a secure, stateless Bearer token for api access.", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "phone", + "password" + ], + "properties": { + "phone": { + "type": "string", + "example": "+971509998888", + "description": "Registered mobile phone number." + }, + "password": { + "type": "string", + "format": "password", + "example": "securepassword123", + "description": "Secret account password." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Worker logged in successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Worker logged in successfully." + }, + "data": { + "type": "object", + "properties": { + "worker": { + "$ref": "#/components/schemas/Worker" + }, + "token": { + "type": "string", + "example": "abc123xyz456...secureToken..." + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized access.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Invalid mobile number or password." + } + } + } + } + } + } + } + } + }, + "/workers/profile": { + "get": { + "summary": "Get Profile details", + "description": "Retrieves the authenticated worker's profile details including their selected job category, connected skills, and uploaded documents.", + "responses": { + "200": { + "description": "Worker profile details retrieved.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "worker": { + "$ref": "#/components/schemas/Worker" + } + } + } + } + } + } + } + } + } + } + }, + "/workers/profile/update": { + "post": { + "summary": "Update Profile details", + "description": "Allows workers to customize and complete their profiles by updating age, nationality, desired salary, bio, availability, experience, religion, categories, and master skills list.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Amina Al-Masry" + }, + "age": { + "type": "integer", + "example": 28 + }, + "nationality": { + "type": "string", + "example": "Egypt" + }, + "salary": { + "type": "number", + "example": 1800.00 + }, + "availability": { + "type": "string", + "example": "Immediate" + }, + "experience": { + "type": "string", + "example": "4 Years" + }, + "religion": { + "type": "string", + "example": "Muslim" + }, + "bio": { + "type": "string", + "example": "Highly certified housekeeper and babysitter with cooking experience." + }, + "category_id": { + "type": "integer", + "example": 8 + }, + "skills": { + "type": "array", + "items": { + "type": "integer" + }, + "example": [6, 8] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Profile updated successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Profile updated successfully." + }, + "data": { + "type": "object", + "properties": { + "worker": { + "$ref": "#/components/schemas/Worker" + } + } + } + } + } + } + } + } + } + } + }, + "/workers/offers": { + "get": { + "summary": "View Received Job Offers", + "description": "Returns a list of all custom job/hiring offers received by the worker from different employers (e.g. Work Date, location, salaries, and notes).", + "responses": { + "200": { + "description": "List of job offers retrieved.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "offers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JobOffer" + } + } + } + } + } + } + } + } + } + } + } + }, + "/workers/offers/{id}/respond": { + "post": { + "summary": "Accept or Reject Job Offer", + "description": "Responds to a pending job offer. If accepted, the job offer status becomes 'accepted' and the worker's status is automatically changed to 'Hired', which reflects immediately in the Employer panel.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The Job Offer ID reference.", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "accepted", + "rejected" + ], + "example": "accepted", + "description": "Response action (accepted or rejected)." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Job offer response saved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Job offer successfully accepted." + }, + "data": { + "type": "object", + "properties": { + "offer": { + "$ref": "#/components/schemas/JobOffer" + }, + "worker_status": { + "type": "string", + "example": "Hired" + } + } + } + } + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "Worker": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 136 + }, + "name": { + "type": "string", + "example": "Amina Al-Masry" + }, + "email": { + "type": "string", + "example": "worker.971509998888@migrant.com" + }, + "phone": { + "type": "string", + "example": "+971509998888" + }, + "nationality": { + "type": "string", + "example": "Egypt" + }, + "age": { + "type": "integer", + "example": 28 + }, + "salary": { + "type": "string", + "example": "1500.00" + }, + "availability": { + "type": "string", + "example": "Immediate" + }, + "experience": { + "type": "string", + "example": "4 Years" + }, + "religion": { + "type": "string", + "example": "Muslim" + }, + "bio": { + "type": "string", + "example": "Certified infant caregiver and housekeeper with excellent cooking skills." + }, + "category_id": { + "type": "integer", + "example": 7 + }, + "verified": { + "type": "boolean", + "example": false + }, + "status": { + "type": "string", + "example": "active" + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2026-05-20T14:54:26.000000Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "example": "2026-05-20T14:54:26.000000Z" + }, + "category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 7 + }, + "name": { + "type": "string", + "example": "General Helper" + } + } + }, + "skills": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 6 + }, + "name": { + "type": "string", + "example": "Cleaning" + } + } + } + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkerDocument" + } + } + } + }, + "WorkerDocument": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 15 + }, + "worker_id": { + "type": "integer", + "example": 136 + }, + "type": { + "type": "string", + "example": "passport" + }, + "number": { + "type": "string", + "example": "P345892" + }, + "issue_date": { + "type": "string", + "format": "date", + "example": "2024-05-20" + }, + "expiry_date": { + "type": "string", + "format": "date", + "example": "2034-05-20" + }, + "ocr_accuracy": { + "type": "number", + "example": 98.5 + }, + "file_path": { + "type": "string", + "example": "uploads/documents/1716200000_passport_my_file.jpg" + } + } + }, + "JobOffer": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "employer_id": { + "type": "integer", + "example": 2 + }, + "worker_id": { + "type": "integer", + "example": 136 + }, + "work_date": { + "type": "string", + "format": "date", + "example": "2026-06-01" + }, + "location": { + "type": "string", + "example": "Dubai Marina, Silverene Towers" + }, + "salary": { + "type": "string", + "example": "2500.00" + }, + "notes": { + "type": "string", + "example": "Require housekeeping support starting from June 1st." + }, + "status": { + "type": "string", + "example": "pending" + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2026-05-20T15:23:44.000000Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "example": "2026-05-20T15:23:44.000000Z" + } + } + } + } + } +} diff --git a/public/uploads/documents/1779274805_passport_Screenshot2025-08-21131531.png b/public/uploads/documents/1779274805_passport_Screenshot2025-08-21131531.png new file mode 100644 index 0000000..05b35be Binary files /dev/null and b/public/uploads/documents/1779274805_passport_Screenshot2025-08-21131531.png differ diff --git a/resources/js/Layouts/EmployerLayout.jsx b/resources/js/Layouts/EmployerLayout.jsx index 31f9ab7..4f390d2 100644 --- a/resources/js/Layouts/EmployerLayout.jsx +++ b/resources/js/Layouts/EmployerLayout.jsx @@ -47,11 +47,11 @@ export default function EmployerLayout({ children, title, fullPage = false }) { }, [flash]); const user = auth?.user || { - name: 'John Doe', - company_name: 'Al Mansoor Household', - email: 'employer@example.com', - subscription_status: 'active', - subscription_expires_at: '2026-12-31', + name: 'Guest', + company_name: '', + email: '', + subscription_status: 'inactive', + subscription_expires_at: '', }; const isSubActive = user.subscription_status === 'active'; diff --git a/resources/js/Pages/Employer/Auth/CreatePassword.jsx b/resources/js/Pages/Employer/Auth/CreatePassword.jsx index 869d89d..b315638 100644 --- a/resources/js/Pages/Employer/Auth/CreatePassword.jsx +++ b/resources/js/Pages/Employer/Auth/CreatePassword.jsx @@ -3,14 +3,15 @@ import { Head, Link, router } from '@inertiajs/react'; import axios from 'axios'; import { toast } from 'sonner'; import { - ShieldCheck, - CheckCircle, Eye, EyeOff, Lock, Loader2, Check, - X + X, + Users, + DollarSign, + MessageSquare } from 'lucide-react'; export default function CreatePassword() { @@ -52,27 +53,39 @@ export default function CreatePassword() { const isAllCriteriaMet = Object.values(strength).every(Boolean); - const handleSubmit = async (e) => { - e.preventDefault(); + const validateForm = () => { + const newErrors = {}; - if (!isAllCriteriaMet) { - setErrors({ password: 'Password does not meet all security guidelines.' }); - toast.error('Password does not meet all security guidelines.'); - return; + if (!data.password) { + newErrors.password = 'Password is required.'; + } else if (!isAllCriteriaMet) { + newErrors.password = 'Password must meet all complexity requirements.'; } - if (data.password !== data.password_confirmation) { - setErrors({ password_confirmation: 'Passwords do not match.' }); - toast.error('Passwords do not match.'); + if (!data.password_confirmation) { + newErrors.password_confirmation = 'Confirm password is required.'; + } else if (data.password !== data.password_confirmation) { + newErrors.password_confirmation = 'Passwords do not match.'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setErrors({}); + + if (!validateForm()) { + toast.error('Validation failed. Please correct the fields in red.'); return; } setLoading(true); - setErrors({}); try { - const response = await axios.post('/employer/create-password', data); - toast.success('Registration finalized! Welcome to the Marketplace.'); + await axios.post('/employer/create-password', data); + toast.success('Registration finalized! Welcome to Migrant.'); router.visit('/employer/dashboard'); } catch (err) { if (err.response) { @@ -91,25 +104,24 @@ export default function CreatePassword() { }; const renderStepIndicator = () => ( -
- Establish a robust password to ensure protection of your domestic hiring workspace. +
+ Unlock direct access to top domestic candidates with a Migrant premium subscription. Find, interview, and hire directly without middlemen.
- Step 3 of 3: Configure Portal Access Key -
+ {/* Right Set Password Form */} +Configure your portal access password
+- Register to review your shortlisted candidates, manage interview schedules, and communicate directly. + Unlock direct access to top domestic candidates with a Migrant premium subscription. Find, interview, and hire directly without middlemen.
{errors.company_name[0] || errors.company_name}
++ {Array.isArray(errors.company_name) ? errors.company_name[0] : errors.company_name} +
)}{errors.name[0] || errors.name}
++ {Array.isArray(errors.name) ? errors.name[0] : errors.name} +
)}{errors.email[0] || errors.email}
++ {Array.isArray(errors.email) ? errors.email[0] : errors.email} +
)}{errors.phone[0] || errors.phone}
- )} -{errors.country[0] || errors.country}
- )} -+ {Array.isArray(errors.phone) ? errors.phone[0] : errors.phone} +
+ )}- We take platform trust seriously. Verify your email to ensure secure account setup. +
+ Unlock direct access to top domestic candidates with a Migrant premium subscription. Find, interview, and hire directly without middlemen.
- Step 2 of 3: Enter Hashed Passcode + {/* Right Verify Form */} +
Verify your employer registration
++ We sent a 6-digit verification code to: + {email}
- We dispatched a 6-digit confirmation code to:
-
- {email}
-
+ {Array.isArray(errors.otp) ? errors.otp[0] : errors.otp} +
+ )}{errors.otp}
- )} -+ Resend code in {cooldown}s +
+ ) : ( + + )} +- Resend Code available in {cooldown}s -
- ) : ( - - )} -