Compare commits

..

No commits in common. "9310263a6e1f32ad35647c9f08c014662fd65e92" and "aa5f409a4e5d51c9076f4d30e3a762c617ce86e7" have entirely different histories.

22 changed files with 470 additions and 1470 deletions

View File

@ -15,23 +15,6 @@
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).
@ -64,7 +47,7 @@ public function sendOtp(Request $request)
// Cache OTP for 10 minutes
Cache::put('worker_otp_' . $identifier, [
'code' => $otp,
'expires_at' => now()->addMinutes(10)->timestamp,
'expires_at' => now()->addMinutes(10),
], 600);
// Send OTP via email if email provided (phone SMS would be handled by SMS gateway)
@ -85,7 +68,6 @@ 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);
}
@ -111,23 +93,7 @@ public function verifyOtp(Request $request)
$identifier = $request->phone ?? $request->email;
$cachedData = Cache::get('worker_otp_' . $identifier);
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']) {
if (!$cachedData || $cachedData['code'] !== $request->otp || now()->gt($cachedData['expires_at'])) {
return response()->json([
'success' => false,
'message' => 'Invalid or expired verification code.'
@ -147,7 +113,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), language.
* Accepts: phone OR email, name, nationality, skills (array of skill IDs).
*/
public function setupProfile(Request $request)
{
@ -156,7 +122,6 @@ 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',
]);
@ -210,7 +175,6 @@ 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
@ -252,13 +216,13 @@ public function setupProfile(Request $request)
return response()->json([
'success' => false,
'message' => 'Registration failed. Please try again.',
'error' => app()->environment('local', 'testing') ? $e->getMessage() : 'Internal Server Error'
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* Unified registration supports backward compatibility and multipart file registration.
* Legacy unified registration kept for backward compatibility.
*/
public function register(Request $request)
{
@ -270,10 +234,6 @@ 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()) {
@ -333,13 +293,12 @@ public function register(Request $request)
'name' => $request->name,
'email' => $email,
'phone' => $request->phone,
'language' => $request->language ?? 'HI',
'password' => Hash::make($request->password),
'nationality' => $request->nationality ?? 'Not Specified',
'age' => $request->age ?? 25,
'password' => $request->password,
'nationality' => 'Not Specified',
'age' => 25,
'salary' => $request->salary,
'availability'=> 'Immediate',
'experience' => $request->experience ?? 'Not Specified',
'experience' => 'Not Specified',
'religion' => 'Not Specified',
'bio' => 'Hardworking and reliable General Helper available for immediate hire.',
'category_id' => 7,
@ -349,10 +308,7 @@ public function register(Request $request)
]);
if (!empty($skillsArray)) {
$validSkillIds = DB::table('skills')->whereIn('id', $skillsArray)->pluck('id')->toArray();
if (!empty($validSkillIds)) {
$worker->skills()->sync($validSkillIds);
}
$worker->skills()->sync($skillsArray);
}
$worker->documents()->create([
@ -447,3 +403,235 @@ 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);
}
}
}

View File

@ -5,10 +5,8 @@
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
@ -49,7 +47,6 @@ 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',
@ -72,7 +69,7 @@ public function updateProfile(Request $request)
DB::transaction(function () use ($request, $worker) {
// Update worker attributes
$updateData = $request->only([
'name', 'age', 'nationality', 'language', 'salary', 'availability',
'name', 'age', 'nationality', 'salary', 'availability',
'experience', 'religion', 'bio', 'category_id'
]);
@ -110,224 +107,6 @@ 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.
*
@ -405,10 +184,7 @@ public function respondToOffer(Request $request, $id)
// If accepted, change worker status to 'Hired'
if ($responseStatus === 'accepted') {
$worker->update([
'status' => 'Hired',
'availability' => 'Hired'
]);
$worker->update(['status' => 'Hired']);
}
});

View File

@ -61,13 +61,6 @@ 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', [
@ -89,12 +82,6 @@ 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',
]);
@ -114,28 +101,6 @@ 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
@ -155,7 +120,6 @@ 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.');

View File

@ -53,7 +53,8 @@ public function index(Request $request)
$preferredJobType = $jobTypes[$w->id % 4];
// Availability status: Active / Hidden / Hired
$availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active')));
$availStatuses = ['Active', 'Hidden', 'Hired'];
$availabilityStatus = $availStatuses[$w->id % 3];
// Emirates ID verification status
$emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending';
@ -137,7 +138,8 @@ public function show($id)
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
$preferredJobType = $jobTypes[$w->id % 4];
$availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active')));
$availStatuses = ['Active', 'Hidden', 'Hired'];
$availabilityStatus = $availStatuses[$w->id % 3];
$emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending';
@ -180,7 +182,8 @@ public function show($id)
->limit(3)
->get();
$similarWorkers = $simDb->map(function($sw) {
$availabilityStatus = ucfirst($sw->status === 'active' ? 'Active' : ($sw->status === 'hidden' ? 'Hidden' : ($sw->status === 'Hired' ? 'Hired' : 'Active')));
$availStatuses = ['Active', 'Hidden', 'Hired'];
$availabilityStatus = $availStatuses[$sw->id % 3];
return [
'id' => $sw->id,

View File

@ -16,10 +16,6 @@ class EmployerProfile extends Model
'verification_status',
'rejection_reason',
'language',
'notifications',
'nationality',
'family_size',
'accommodation',
'district'
'notifications'
];
}

View File

@ -14,7 +14,6 @@ class Worker extends Model
'name',
'email',
'phone',
'language',
'password',
'nationality',
'age',

View File

@ -16,7 +16,6 @@ class WorkerDocument extends Model
'issue_date',
'expiry_date',
'ocr_accuracy',
'file_path',
];
public function worker()

View File

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

View File

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

View File

@ -1,34 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class MasterDataSeeder extends Seeder
{
/**
* Run the database seeds for master/static data only.
*/
public function run(): void
{
// 1. Create Admin User (if not already exists)
if (!DB::table('users')->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,
]);
}
}

View File

@ -17,7 +17,7 @@ public function run(): void
];
foreach ($skills as $skill) {
\Illuminate\Support\Facades\DB::table('skills')->insertOrIgnore([
\Illuminate\Support\Facades\DB::table('skills')->insert([
'name' => $skill,
'created_at' => now(),
'updated_at' => now(),

View File

@ -18,7 +18,7 @@ public function run(): void
];
foreach ($categories as $category) {
\Illuminate\Support\Facades\DB::table('worker_categories')->insertOrIgnore([
\Illuminate\Support\Facades\DB::table('worker_categories')->insert([
'name' => $category,
'created_at' => now(),
'updated_at' => now(),

View File

@ -71,34 +71,13 @@
"items": {
"type": "integer"
},
"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)."
"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)."
},
"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."
}
}
}
@ -266,265 +245,6 @@
}
}
},
"/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": [
@ -654,191 +374,6 @@
}
}
},
"/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": [

View File

@ -192,7 +192,7 @@ export default function Login({ flash }) {
className="h-4 w-4 rounded border-slate-300 text-[#185FA5] focus:ring-[#185FA5]"
/>
<label htmlFor="remember" className="ml-2 block text-xs text-gray-700 select-none">
Remember me
Remember me for 30 days
</label>
</div>

View File

@ -36,6 +36,7 @@ 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;
@ -212,14 +213,39 @@ export default function Dashboard({
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Worker Activity Analytics Module (Col 8) */}
<div className="lg:col-span-8 bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-6">
<div className="pb-3 border-b border-slate-100">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-3 border-b border-slate-100">
<div className="flex items-center space-x-2">
<Activity className="w-5 h-5 text-[#185FA5]" />
<h3 className="font-extrabold text-base text-slate-900">Discover Insights & Market Stats</h3>
</div>
{/* Toggle Tabs */}
<div className="flex items-center bg-slate-50 border border-slate-200 rounded-xl p-1 shrink-0">
<button
onClick={() => setActiveTab('sponsor')}
className={`px-3 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
activeTab === 'sponsor'
? 'bg-white text-[#185FA5] shadow-xs'
: 'text-slate-500 hover:text-slate-800'
}`}
>
Sponsor Hub
</button>
<button
onClick={() => setActiveTab('worker')}
className={`px-3 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
activeTab === 'worker'
? 'bg-white text-[#185FA5] shadow-xs'
: 'text-slate-500 hover:text-slate-800'
}`}
>
Worker Tracker
</button>
</div>
</div>
<div className="space-y-6">
{activeTab === 'sponsor' ? (
<div className="space-y-6 animate-in fade-in duration-250">
{/* Dubai General Market Stats */}
<div className="space-y-3">
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">Dubai General Market Insights</div>
@ -258,6 +284,50 @@ export default function Dashboard({
</div>
</div>
</div>
) : (
<div className="space-y-6 animate-in fade-in duration-250">
<div className="p-4 bg-emerald-50/30 border border-emerald-100 rounded-2xl flex items-center space-x-3.5">
<div className="w-10 h-10 rounded-xl bg-emerald-100/60 text-emerald-700 flex items-center justify-center shrink-0">
<Sparkles className="w-5 h-5 text-emerald-600 animate-pulse" />
</div>
<div>
<h4 className="font-extrabold text-xs text-slate-900">How Candidates Stay Motivated</h4>
<p className="text-[11px] text-slate-500 font-medium mt-0.5 leading-relaxed">
Workers view real-time profile activity and interaction insights. This keeps them highly engaged and active on our platform!
</p>
</div>
</div>
<div className="space-y-3">
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">Live Worker-Side Analytics Dashboard</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100">
<div className="text-2xl font-black text-slate-800">32</div>
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">Sponsors Viewed Profile</div>
</div>
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100">
<div className="text-2xl font-black text-slate-800">12</div>
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">Sponsors Contacted You</div>
</div>
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100">
<div className="text-2xl font-black text-[#185FA5]">4</div>
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">Job Proposals Offered</div>
</div>
</div>
</div>
{/* Push notification highlight */}
<div className="p-4 bg-blue-50/50 border border-blue-100 rounded-2xl flex items-start space-x-3 text-xs font-bold text-slate-600">
<span className="text-base shrink-0 mt-0.5">🔔</span>
<div>
<div className="text-slate-800 text-[11px] font-extrabold">Instant Worker Push Alerts</div>
<p className="text-[10px] text-slate-500 font-medium leading-relaxed mt-0.5">
When you send a message, the worker receives a push notification on their mobile app immediately, ensuring rapid responses.
</p>
</div>
</div>
</div>
)}
</div>
{/* Quick Actions Panel (Col 4) */}
@ -268,6 +338,19 @@ export default function Dashboard({
</div>
<div className="space-y-2.5 flex-1 py-2">
<Link
href="/employer/jobs/create"
className="w-full p-3.5 bg-slate-50 hover:bg-blue-50/50 border border-slate-200 hover:border-[#185FA5]/40 rounded-2xl transition-all flex items-center space-x-3 text-left group"
>
<div className="w-9 h-9 rounded-xl bg-blue-100/50 text-[#185FA5] flex items-center justify-center flex-shrink-0 group-hover:scale-105 transition-transform">
<Plus className="w-4 h-4" />
</div>
<div className="truncate">
<div className="text-xs font-bold text-slate-800 group-hover:text-[#185FA5] transition-colors">Post a New Job</div>
<div className="text-[10px] text-slate-500 truncate">Receive applications directly</div>
</div>
</Link>
<Link
href="/employer/workers?category=Childcare"
className="w-full p-3.5 bg-slate-50 hover:bg-blue-50/50 border border-slate-200 hover:border-[#185FA5]/40 rounded-2xl transition-all flex items-center space-x-3 text-left group"
@ -491,6 +574,29 @@ export default function Dashboard({
{/* Right Column: Saved Searches, Announcements, Regulatory details */}
<div className="lg:col-span-4 space-y-8">
{/* Saved Searches Module */}
<div className="bg-white rounded-3xl border border-slate-200 shadow-xs p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm text-slate-900 uppercase tracking-widest">Saved Searches</h3>
<Bookmark className="w-4 h-4 text-[#185FA5]" />
</div>
<div className="space-y-2">
{saved_searches.map((search) => (
<Link
key={search.id}
href={`/employer/workers?${search.query}`}
className="w-full p-3 bg-slate-50 hover:bg-blue-50/30 border border-slate-200 rounded-xl transition-all flex items-center justify-between text-left group"
>
<span className="text-xs font-bold text-slate-700 group-hover:text-[#185FA5] transition-colors truncate">
{search.name}
</span>
<ChevronRight className="w-4 h-4 text-slate-400 group-hover:translate-x-0.5 transition-transform" />
</Link>
))}
</div>
</div>
{/* Charity Events Module */}
<div className="bg-white rounded-3xl border border-slate-200 shadow-xs p-6 space-y-6">
<div className="flex items-center justify-between">

View File

@ -24,19 +24,17 @@ import {
import { toast } from 'sonner';
export default function Profile({ employerProfile }) {
const { data: profile, setData, post, processing, errors } = useForm({
name: employerProfile?.name || '',
company_name: employerProfile?.company_name || '',
email: employerProfile?.email || '',
phone: employerProfile?.phone || '',
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',
language: employerProfile?.language || 'English',
nationality: employerProfile?.nationality || '',
family_size: employerProfile?.family_size || '',
accommodation: employerProfile?.accommodation || '',
district: employerProfile?.district || '',
nationality: employerProfile?.nationality || 'UAE National',
family_size: employerProfile?.family_size || '4 Members',
accommodation: employerProfile?.accommodation || 'Villa',
district: employerProfile?.district || 'Dubai Marina',
notifications: employerProfile?.notifications ?? true,
emirates_id_front: null,
emirates_id_back: null,
current_password: '',
new_password: '',
new_password_confirmation: '',
@ -47,25 +45,16 @@ export default function Profile({ employerProfile }) {
const handleSave = (e) => {
e.preventDefault();
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."
});
}
});
};
const tabs = [
{ id: 'personal', label: 'Personal', icon: User },
{ id: 'personal', label: 'Personal & Household', icon: User },
{ id: 'company', label: 'Verification & History', icon: ShieldCheck },
{ id: 'security', label: 'Security & Password', icon: Lock },
{ id: 'billing', label: 'Billing & Receipts', icon: CreditCard },
{ id: 'notifications', label: 'Notifications Hub', icon: Bell },
@ -145,8 +134,8 @@ export default function Profile({ employerProfile }) {
{activeTab === 'personal' && (
<div className="space-y-6">
<div className="border-b border-slate-100 pb-4">
<h3 className="text-lg font-black text-slate-900">Personal details</h3>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">Manage sponsor bio and credentials</p>
<h3 className="text-lg font-black text-slate-900">Personal & Household details</h3>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">Manage sponsor bio and residence style</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -157,7 +146,7 @@ export default function Profile({ employerProfile }) {
<input
type="text"
value={profile.name}
onChange={(e) => setData('name', e.target.value)}
onChange={(e) => setProfile({ ...profile, 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"
/>
</div>
@ -170,7 +159,7 @@ export default function Profile({ employerProfile }) {
<input
type="email"
value={profile.email}
onChange={(e) => setData('email', e.target.value)}
onChange={(e) => setProfile({ ...profile, 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"
/>
</div>
@ -183,7 +172,7 @@ export default function Profile({ employerProfile }) {
<input
type="text"
value={profile.phone}
onChange={(e) => setData('phone', e.target.value)}
onChange={(e) => setProfile({ ...profile, 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"
/>
</div>
@ -195,14 +184,13 @@ export default function Profile({ employerProfile }) {
<Globe className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-300" />
<select
value={profile.nationality}
onChange={(e) => setData('nationality', e.target.value)}
onChange={(e) => setProfile({ ...profile, nationality: 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 cursor-pointer"
>
<option value="">Select Nationality</option>
<option value="UAE National">UAE National</option>
<option value="Expat (UK / Europe)">Expat (UK / Europe)</option>
<option value="Expat (USA / Canada)">Expat (USA / Canada)</option>
<option value="Expat (Asia / GCC)">Expat (Asia / GCC)</option>
<option>UAE National</option>
<option>Expat (UK / Europe)</option>
<option>Expat (USA / Canada)</option>
<option>Expat (Asia / GCC)</option>
</select>
</div>
</div>
@ -213,13 +201,12 @@ export default function Profile({ employerProfile }) {
<Users className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-300" />
<select
value={profile.family_size}
onChange={(e) => setData('family_size', e.target.value)}
onChange={(e) => setProfile({ ...profile, family_size: 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 cursor-pointer"
>
<option value="">Select Family Size</option>
<option value="1-2 Members">1-2 Members</option>
<option value="3-4 Members">3-4 Members</option>
<option value="5+ Members">5+ Members</option>
<option>1-2 Members</option>
<option>3-4 Members</option>
<option>5+ Members</option>
</select>
</div>
</div>
@ -230,13 +217,12 @@ export default function Profile({ employerProfile }) {
<Home className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-300" />
<select
value={profile.accommodation}
onChange={(e) => setData('accommodation', e.target.value)}
onChange={(e) => setProfile({ ...profile, accommodation: 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 cursor-pointer"
>
<option value="">Select Accommodation</option>
<option value="Villa">Villa</option>
<option value="Apartment (Penthouse)">Apartment (Penthouse)</option>
<option value="Apartment (1-3 Bed)">Apartment (1-3 Bed)</option>
<option>Villa</option>
<option>Apartment (Penthouse)</option>
<option>Apartment (1-3 Bed)</option>
</select>
</div>
</div>
@ -248,134 +234,62 @@ export default function Profile({ employerProfile }) {
<input
type="text"
value={profile.district}
onChange={(e) => setData('district', e.target.value)}
onChange={(e) => setProfile({ ...profile, 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"
/>
</div>
</div>
</div>
</div>
)}
{activeTab === 'company' && (
<div className="space-y-8">
{/* Emirates ID Verification */}
<div className="space-y-6 pt-6 border-t border-slate-100">
<div className="space-y-6">
<div className="border-b border-slate-100 pb-4">
<h3 className="text-lg font-black text-slate-900">Emirates ID Verification</h3>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">Regulatory compliance credentials</p>
</div>
{employerProfile?.verification_status === 'approved' && employerProfile?.emirates_id_front && employerProfile?.emirates_id_back ? (
<div className="p-6 bg-emerald-50 rounded-3xl border border-emerald-200 flex items-start space-x-4">
<div className="p-6 bg-slate-50 rounded-3xl border border-slate-150 flex items-start space-x-4">
<div className="p-3 bg-emerald-100 text-emerald-600 rounded-2xl">
<ShieldCheck className="w-6 h-6" />
</div>
<div>
<h4 className="font-extrabold text-slate-900">Emirates ID Verified</h4>
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest mt-0.5">Vetted under MOHRE Guidelines</p>
<h4 className="font-extrabold text-slate-900">Emirates ID Vetted Status</h4>
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest mt-0.5">Reference: EID-9182-XJ</p>
<p className="text-xs text-slate-600 mt-2 font-medium leading-relaxed">
Your Emirates ID has been verified successfully. Your account is active for direct hiring.
Your Emirates ID is active and fully validated under Ministry of Human Resources (MOHRE) guidelines. Direct hiring is legally enabled.
</p>
<div className="flex items-center space-x-2 mt-4 text-emerald-600 font-bold text-[10px] uppercase">
<CheckCircle2 className="w-4 h-4" />
<span>Verified on Jan 15, 2026</span>
</div>
</div>
) : employerProfile?.verification_status === 'pending' && (employerProfile?.emirates_id_front || employerProfile?.emirates_id_back) ? (
<div className="p-6 bg-amber-50 rounded-3xl border border-amber-200 flex items-start space-x-4">
<div className="p-3 bg-amber-100 text-amber-600 rounded-2xl animate-pulse">
<ShieldCheck className="w-6 h-6" />
</div>
<div>
<h4 className="font-extrabold text-slate-900">Verification Pending Audit</h4>
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest mt-0.5">Audit Queue</p>
<p className="text-xs text-slate-600 mt-2 font-medium leading-relaxed">
Your Emirates ID document uploads are currently under review. Audits are typically completed within 24 hours.
</p>
</div>
</div>
) : (
<div className="p-6 bg-rose-50 rounded-3xl border border-rose-200 flex items-start space-x-4">
<div className="p-3 bg-rose-100 text-rose-600 rounded-2xl">
<ShieldCheck className="w-6 h-6" />
</div>
<div>
<h4 className="font-extrabold text-slate-900">Emirates ID Verification Required</h4>
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest mt-0.5">Action Needed</p>
<p className="text-xs text-slate-600 mt-2 font-medium leading-relaxed">
Please upload high-resolution color scans of your Emirates ID to verify your identity and activate full hiring features.
</p>
</div>
</div>
)}
{/* File Upload Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 bg-slate-50 p-6 rounded-3xl border border-slate-200">
<div className="space-y-2">
<label className="text-xs font-black text-slate-600 uppercase tracking-tight ml-1">Emirates ID (Front Side)</label>
<input
type="file"
onChange={(e) => 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 && <p className="text-xs text-red-500 mt-1">{errors.emirates_id_front}</p>}
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-600 uppercase tracking-tight ml-1">Emirates ID (Back Side)</label>
<input
type="file"
onChange={(e) => 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 && <p className="text-xs text-red-500 mt-1">{errors.emirates_id_back}</p>}
</div>
</div>
{/* Uploaded Documents Details */}
{(employerProfile?.emirates_id_front || employerProfile?.emirates_id_back) && (
<div className="space-y-4 pt-4 border-t border-slate-100">
<h4 className="text-xs font-black text-slate-600 uppercase tracking-wider ml-1">Uploaded Credentials Scans</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{employerProfile?.emirates_id_front && (
<div className="p-4 bg-slate-50 border border-slate-200 rounded-2xl flex items-center justify-between">
<div className="flex items-center space-x-3 min-w-0">
<div className="w-10 h-10 bg-blue-100 text-[#185FA5] rounded-xl flex items-center justify-center font-bold text-xs flex-shrink-0">
ID
{/* Hiring History Logs */}
<div className="space-y-4">
<div className="flex items-center space-x-2 text-slate-800 font-bold">
<History className="w-5 h-5 text-[#185FA5]" />
<h3 className="font-extrabold text-sm uppercase">Sponsorship Hiring History Logs</h3>
</div>
<div className="min-w-0">
<div className="text-xs font-black text-slate-800">Emirates ID (Front)</div>
<div className="text-[10px] text-slate-400 font-bold truncate">{employerProfile.emirates_id_front.split('/').pop()}</div>
<div className="bg-slate-50 rounded-2xl border border-slate-150 overflow-hidden divide-y divide-slate-150">
{pastHires.map((hire) => (
<div key={hire.id} className="p-4 flex items-center justify-between text-xs font-semibold text-slate-700">
<div className="space-y-0.5">
<div className="font-bold text-slate-800">{hire.name} ({hire.nationality})</div>
<div className="text-[9px] text-slate-400 uppercase">{hire.category} Hired: {hire.date}</div>
</div>
<span className="px-2.5 py-0.5 bg-blue-50 border border-blue-100 text-[#185FA5] text-[9px] font-black rounded uppercase">
{hire.status}
</span>
</div>
<a
href={`/storage/${employerProfile.emirates_id_front}`}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-white border border-slate-200 text-xs font-bold text-slate-600 rounded-lg hover:text-[#185FA5] transition-colors shadow-sm flex-shrink-0"
>
View Scan
</a>
))}
</div>
)}
{employerProfile?.emirates_id_back && (
<div className="p-4 bg-slate-50 border border-slate-200 rounded-2xl flex items-center justify-between">
<div className="flex items-center space-x-3 min-w-0">
<div className="w-10 h-10 bg-blue-100 text-[#185FA5] rounded-xl flex items-center justify-center font-bold text-xs flex-shrink-0">
ID
</div>
<div className="min-w-0">
<div className="text-xs font-black text-slate-800">Emirates ID (Back)</div>
<div className="text-[10px] text-slate-400 font-bold truncate">{employerProfile.emirates_id_back.split('/').pop()}</div>
</div>
</div>
<a
href={`/storage/${employerProfile.emirates_id_back}`}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-white border border-slate-200 text-xs font-bold text-slate-600 rounded-lg hover:text-[#185FA5] transition-colors shadow-sm flex-shrink-0"
>
View Scan
</a>
</div>
)}
</div>
</div>
)}
</div>
</div>
)}

View File

@ -267,6 +267,12 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
<p className="text-slate-500 font-bold text-xs">Instantly connect with UAE vetted, background-checked domestic workers.</p>
</div>
<button
onClick={() => setShowPresetModal(true)}
className="bg-[#185FA5] hover:bg-[#144f8a] text-white px-6 py-3 rounded-2xl text-xs font-black uppercase tracking-widest transition-all shadow-md shadow-blue-200 relative z-10"
>
+ Save Current Filter
</button>
</div>
{/* Filter and Search Panel */}

View File

@ -22,10 +22,6 @@
*/
// 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']);
@ -47,10 +43,6 @@
// 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']);

View File

@ -1,385 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\Worker;
use App\Models\WorkerCategory;
use App\Models\WorkerDocument;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class WorkerJourneyApiTest extends TestCase
{
use RefreshDatabase;
protected $category;
protected function setUp(): void
{
parent::setUp();
// Create standard General Helper category
\Illuminate\Support\Facades\DB::table('worker_categories')->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',
]);
}
}