Compare commits

..

1 Commits

Author SHA1 Message Date
pavithrak27
b96246bbdb refactor: remove unused diagnostic provider implementation 2026-06-09 11:00:30 +05:30
226 changed files with 7838 additions and 43102 deletions

View File

@ -62,19 +62,4 @@ AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
PASSPORT_API_URL=
VISA_API_URL=
EMIRATES_FRONT_API_URL=
EMIRATES_BACK_API_URL=
SPONSOR_LICENSE_API_URL=
VITE_APP_NAME="${APP_NAME}"
# Notification Reminder Settings
WORKER_NO_RESPONSE_REMINDER_DAYS=14
REVIEW_ELIGIBILITY_MONTHS=3
REVIEW_ELIGIBILITY_MINUTES=10
REVIEW_EDIT_PERIOD_DAYS=7
EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS=3
WORKER_JOIN_CONFIRM_REMINDER_DAYS=3
DOCUMENT_EXPIRY_REMINDER_DAYS=30,7,1

1
.gitignore vendored
View File

@ -18,7 +18,6 @@
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/public/uploads
/storage/*.key
/storage/pail
/vendor

View File

@ -1,474 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Carbon\Carbon;
class SendReminders extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:send-reminders';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Send automated reminder notifications for document expiry and pending confirmations';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Starting automated reminder notification system...');
$this->info('1. Checking Worker Documents...');
$this->checkWorkerDocuments();
$this->info('2. Checking Employer Profiles (Emirates ID Expiry)...');
$this->checkEmployerProfiles();
$this->info('3. Checking Sponsors (Emirates ID and License Expiry)...');
$this->checkSponsors();
$this->info('4. Checking Pending Hire Confirmations (Employer)...');
$this->checkHireConfirmations();
$this->info('5. Checking Pending Joining Confirmations (Worker)...');
$this->checkJoiningConfirmations();
$this->info('6. Checking Pending Direct Job Offers...');
$this->checkJobOffers();
$this->info('7. Checking Worker No Response...');
$this->checkWorkerNoResponse();
$this->info('8. Checking Review Reminders...');
$this->checkReviewReminders();
$this->info('Automated reminder system run complete!');
}
private function getReminderLevel(int $days, string $configKey, string $default): ?string
{
$value = config($configKey, $default);
$configuredDays = array_map('intval', array_filter(explode(',', $value), 'strlen'));
if (in_array($days, $configuredDays)) {
return $days === 1 ? '1_day' : "{$days}_days";
}
return null;
}
private function alreadySent($notifiable, string $eventType, $entity, string $reminderLevel): bool
{
return \App\Models\ReminderLog::where('user_type', get_class($notifiable))
->where('user_id', $notifiable->id)
->where('event_type', $eventType)
->where('entity_type', get_class($entity))
->where('entity_id', $entity->id)
->where('reminder_level', $reminderLevel)
->exists();
}
private function logReminder($notifiable, string $eventType, $entity, string $reminderLevel, string $channel): void
{
\App\Models\ReminderLog::create([
'user_type' => get_class($notifiable),
'user_id' => $notifiable->id,
'event_type' => $eventType,
'entity_type' => get_class($entity),
'entity_id' => $entity->id,
'reminder_level' => $reminderLevel,
'channel' => $channel,
'sent_at' => now(),
]);
}
private function checkWorkerDocuments()
{
$workerDocs = \App\Models\WorkerDocument::whereNotNull('expiry_date')->get();
foreach ($workerDocs as $doc) {
$worker = $doc->worker;
if (!$worker) continue;
$expiry = Carbon::parse($doc->expiry_date);
$days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false);
$level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1');
if (!$level) continue;
if ($this->alreadySent($worker, 'document_expiry', $doc, $level)) continue;
$this->info("Sending {$level} reminder to worker {$worker->name} for document {$doc->type} expiry.");
$worker->notify(new \App\Notifications\DocumentExpiryNotification(
ucfirst($doc->type),
$doc->expiry_date,
$days,
$level
));
$this->logReminder($worker, 'document_expiry', $doc, $level, 'all');
}
}
private function checkEmployerProfiles()
{
$profiles = \App\Models\EmployerProfile::whereNotNull('emirates_id_expiry')->get();
foreach ($profiles as $profile) {
$user = \App\Models\User::find($profile->user_id);
if (!$user) continue;
$expiry = Carbon::parse($profile->emirates_id_expiry);
$days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false);
$level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1');
if (!$level) continue;
if ($this->alreadySent($user, 'document_expiry', $profile, $level)) continue;
$this->info("Sending {$level} reminder to employer {$user->name} for Emirates ID expiry.");
$user->notify(new \App\Notifications\DocumentExpiryNotification(
'Emirates ID',
$profile->emirates_id_expiry,
$days,
$level
));
$this->logReminder($user, 'document_expiry', $profile, $level, 'all');
}
}
private function checkSponsors()
{
$sponsors = \App\Models\Sponsor::all();
foreach ($sponsors as $sponsor) {
// 1. Check Emirates ID expiry
if ($sponsor->emirates_id_expiry) {
$expiry = Carbon::parse($sponsor->emirates_id_expiry);
$days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false);
$level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1');
if ($level && !$this->alreadySent($sponsor, 'document_expiry_emirates', $sponsor, $level)) {
$this->info("Sending {$level} reminder to sponsor {$sponsor->full_name} for Emirates ID expiry.");
$sponsor->notify(new \App\Notifications\DocumentExpiryNotification(
'Emirates ID',
$sponsor->emirates_id_expiry,
$days,
$level
));
$this->logReminder($sponsor, 'document_expiry_emirates', $sponsor, $level, 'all');
}
}
// 2. Check License expiry
if ($sponsor->license_expiry) {
$expiry = Carbon::parse($sponsor->license_expiry);
$days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false);
$level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1');
if ($level && !$this->alreadySent($sponsor, 'document_expiry_license', $sponsor, $level)) {
$this->info("Sending {$level} reminder to sponsor {$sponsor->full_name} for License expiry.");
$sponsor->notify(new \App\Notifications\DocumentExpiryNotification(
'Trade License',
$sponsor->license_expiry->toDateString(),
$days,
$level
));
$this->logReminder($sponsor, 'document_expiry_license', $sponsor, $level, 'all');
}
}
}
}
private function checkHireConfirmations()
{
$applications = \App\Models\JobApplication::where('status', 'selected')
->with('jobPost.employer')
->get();
foreach ($applications as $app) {
$job = $app->jobPost;
if (!$job || !$job->start_date) continue;
$employer = $job->employer;
if (!$employer) continue;
$worker = $app->worker;
if (!$worker) continue;
$startDate = Carbon::parse($job->start_date);
$days = (int) now()->startOfDay()->diffInDays($startDate->startOfDay(), false);
$level = $this->getReminderLevel($days, 'reminders.employer_hire_confirm_reminder_days', '3,7');
if (!$level) continue;
if ($this->alreadySent($employer, 'hire_confirmation', $app, $level)) continue;
$this->info("Sending {$level} reminder to employer {$employer->name} to confirm hiring for job {$job->title}.");
$employer->notify(new \App\Notifications\HireConfirmationNotification(
$job->title,
$worker->name,
$days,
$level,
$app->id
));
$this->logReminder($employer, 'hire_confirmation', $app, $level, 'all');
}
}
private function checkJoiningConfirmations()
{
$applications = \App\Models\JobApplication::where('status', 'hired')
->whereNull('joining_confirmed_at')
->with(['jobPost.employer', 'worker'])
->get();
foreach ($applications as $app) {
$job = $app->jobPost;
if (!$job || !$job->start_date) continue;
$worker = $app->worker;
if (!$worker) continue;
$employer = $job->employer;
$employerName = $employer ? $employer->name : 'Employer';
$startDate = Carbon::parse($job->start_date);
$days = (int) now()->startOfDay()->diffInDays($startDate->startOfDay(), false);
$level = $this->getReminderLevel($days, 'reminders.worker_join_confirm_reminder_days', '3,7');
if (!$level) continue;
if ($this->alreadySent($worker, 'joining_confirmation', $app, $level)) continue;
$this->info("Sending {$level} reminder to worker {$worker->name} to confirm joining for job {$job->title}.");
$worker->notify(new \App\Notifications\JoiningConfirmationNotification(
$job->title,
$employerName,
$days,
$level,
$app->id
));
$this->logReminder($worker, 'joining_confirmation', $app, $level, 'all');
}
}
private function checkJobOffers()
{
$offers = \App\Models\JobOffer::where('status', 'pending')
->with(['worker', 'employer'])
->get();
foreach ($offers as $offer) {
$worker = $offer->worker;
if (!$worker) continue;
$employer = $offer->employer;
$employerName = $employer ? $employer->name : 'Employer';
if (!$offer->work_date) continue;
$workDate = Carbon::parse($offer->work_date);
$days = (int) now()->startOfDay()->diffInDays($workDate->startOfDay(), false);
$level = $this->getReminderLevel($days, 'reminders.worker_join_confirm_reminder_days', '3,7');
if (!$level) continue;
if ($this->alreadySent($worker, 'pending_confirmation', $offer, $level)) continue;
$this->info("Sending {$level} reminder to worker {$worker->name} for pending direct offer from {$employerName}.");
$worker->notify(new \App\Notifications\PendingConfirmationNotification(
'accept_offer',
"Pending Job Offer",
"You have a pending job offer from {$employerName} starting on {$offer->work_date->toDateString()}.",
$days,
$level,
get_class($offer),
$offer->id
));
$this->logReminder($worker, 'pending_confirmation', $offer, $level, 'all');
}
}
private function checkWorkerNoResponse()
{
$noResponseDays = (int) config('reminders.worker_no_response_reminder_days', 14);
// 1. Pending direct Job Offers
$offers = \App\Models\JobOffer::where('status', 'pending')
->with(['worker', 'employer'])
->get();
foreach ($offers as $offer) {
$worker = $offer->worker;
if (!$worker) continue;
$employer = $offer->employer;
$employerName = $employer ? $employer->name : 'Employer';
$daysSinceCreation = (int) Carbon::parse($offer->created_at)->startOfDay()->diffInDays(now()->startOfDay(), false);
if ($daysSinceCreation !== $noResponseDays) continue;
$level = $daysSinceCreation === 1 ? '1_day' : "{$daysSinceCreation}_days";
if ($this->alreadySent($worker, 'worker_no_response_offer', $offer, $level)) continue;
$this->info("Sending no-response reminder to worker {$worker->name} for pending direct offer from {$employerName}.");
$worker->notify(new \App\Notifications\WorkerNoResponseNotification(
'worker_no_response_offer',
"Action Required: Pending Job Offer",
"You have a pending job offer from {$employerName} sent on {$offer->created_at->toDateString()} that you have not responded to.",
$daysSinceCreation,
get_class($offer),
$offer->id
));
$this->logReminder($worker, 'worker_no_response_offer', $offer, $level, 'all');
}
// 2. Chat conversations where the last message is from the employer
$conversations = \App\Models\Conversation::with(['employer', 'worker'])->get();
foreach ($conversations as $conv) {
$worker = $conv->worker;
if (!$worker) continue;
$employer = $conv->employer;
$employerName = $employer ? $employer->name : 'Employer';
$lastMessage = $conv->messages()->latest()->first();
if (!$lastMessage) continue;
$isEmployerSender = false;
if ($lastMessage->sender_type === 'App\Models\User' || $lastMessage->sender_id == $conv->employer_id) {
$isEmployerSender = true;
}
if (!$isEmployerSender) continue;
$daysSinceMessage = (int) Carbon::parse($lastMessage->created_at)->startOfDay()->diffInDays(now()->startOfDay(), false);
if ($daysSinceMessage !== $noResponseDays) continue;
$level = $daysSinceMessage === 1 ? '1_day' : "{$daysSinceMessage}_days";
if ($this->alreadySent($worker, 'worker_no_response_chat', $lastMessage, $level)) continue;
$this->info("Sending no-response chat reminder to worker {$worker->name} for conversation with {$employerName}.");
$worker->notify(new \App\Notifications\WorkerNoResponseNotification(
'worker_no_response_chat',
"New Messages: Reply to {$employerName}",
"You have a message from {$employerName} that you have not responded to for {$daysSinceMessage} days.",
$daysSinceMessage,
get_class($lastMessage),
$lastMessage->id
));
$this->logReminder($worker, 'worker_no_response_chat', $lastMessage, $level, 'all');
}
}
private function checkReviewReminders()
{
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
$eligibilityMinutes = config('reminders.review_eligibility_minutes');
$isMinutes = ($eligibilityMinutes !== null && $eligibilityMinutes > 0);
$level = $isMinutes ? "{$eligibilityMinutes}_minutes" : "{$eligibilityMonths}_months";
// 1. Standard Job Applications with status 'hired'
$applications = \App\Models\JobApplication::where('status', 'hired')
->whereNotNull('joining_confirmed_at')
->with(['jobPost.employer', 'worker'])
->get();
foreach ($applications as $app) {
$job = $app->jobPost;
if (!$job) continue;
$employer = $job->employer;
$worker = $app->worker;
if (!$employer || !$worker) continue;
$joiningDate = Carbon::parse($app->joining_confirmed_at);
$eligible = false;
if ($isMinutes) {
$eligible = $joiningDate->diffInMinutes(now(), false) >= $eligibilityMinutes;
} else {
$months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
$eligible = $months >= $eligibilityMonths;
}
if ($eligible) {
// Remind worker to review employer
if (!$this->alreadySent($worker, 'review_employer_reminder', $app, $level)) {
$this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name}.");
$worker->notify(new \App\Notifications\WorkerReviewReminderNotification(
$employer->name,
$app->id,
$level
));
$this->logReminder($worker, 'review_employer_reminder', $app, $level, 'all');
}
// Remind employer to review worker
if (!$this->alreadySent($employer, 'review_worker_reminder', $app, $level)) {
$this->info("Sending review reminder to employer {$employer->name} to review worker {$worker->name}.");
$employer->notify(new \App\Notifications\EmployerReviewReminderNotification(
$worker->name,
$app->id,
$level
));
$this->logReminder($employer, 'review_worker_reminder', $app, $level, 'all');
}
}
}
// 2. Direct Job Offers with status 'accepted'
$offers = \App\Models\JobOffer::where('status', 'accepted')
->with(['worker', 'employer'])
->get();
foreach ($offers as $offer) {
$worker = $offer->worker;
$employer = $offer->employer;
if (!$worker || !$employer) continue;
$joiningDate = $offer->work_date ? Carbon::parse($offer->work_date) : $offer->updated_at;
$eligible = false;
if ($isMinutes) {
$eligible = $joiningDate->diffInMinutes(now(), false) >= $eligibilityMinutes;
} else {
$months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
$eligible = $months >= $eligibilityMonths;
}
if ($eligible) {
// Remind worker to review employer
if (!$this->alreadySent($worker, 'review_employer_reminder', $offer, $level)) {
$this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name} (direct offer).");
$worker->notify(new \App\Notifications\WorkerReviewReminderNotification(
$employer->name,
$offer->id,
$level
));
$this->logReminder($worker, 'review_employer_reminder', $offer, $level, 'all');
}
// Remind employer to review worker
if (!$this->alreadySent($employer, 'review_worker_reminder', $offer, $level)) {
$this->info("Sending review reminder to employer {$employer->name} to review worker {$worker->name} (direct offer).");
$employer->notify(new \App\Notifications\EmployerReviewReminderNotification(
$worker->name,
$offer->id,
$level
));
$this->logReminder($employer, 'review_worker_reminder', $offer, $level, 'all');
}
}
}
}
}

View File

@ -21,54 +21,32 @@ public function showLogin()
*/
public function login(Request $request)
{
$validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
], [
'email.required' => 'Please enter your email address.',
'email.email' => 'Please enter a valid email address.',
'password.required' => 'Please enter your password.',
]);
if ($validator->fails()) {
return back()->withErrors($validator)->withInput($request->only('email'));
}
$credentials = $validator->validated();
// Database-backed authentication check
$user = \App\Models\User::where('email', $credentials['email'])->first();
if (!$user) {
return back()->withErrors([
'email' => 'This email is not registered.',
])->withInput($request->only('email'));
if ($user && \Illuminate\Support\Facades\Hash::check($credentials['password'], $user->password) && $user->role === 'admin') {
$request->session()->regenerate();
auth()->login($user);
session(['user' => (object)[
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
]]);
return redirect()->intended('/admin/dashboard');
}
if ($user->role !== 'admin') {
return back()->withErrors([
'email' => 'This email is not registered as an admin.',
])->withInput($request->only('email'));
}
if (!\Illuminate\Support\Facades\Hash::check($credentials['password'], $user->password)) {
return back()->withErrors([
'password' => 'The password you entered is incorrect.',
])->withInput($request->only('email'));
}
$request->session()->regenerate();
auth()->login($user);
session(['user' => (object)[
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
]]);
return redirect()->intended('/admin/dashboard');
return back()->withErrors([
'email' => 'The provided credentials do not match our records. (Use admin@example.com / password)',
]);
}
/**

View File

@ -296,33 +296,6 @@ public function broadcastNotification(Request $request)
'updated_at' => now(),
]);
// Send FCM push notifications for broadcast
if ($request->channel === 'push' || $request->channel === 'both') {
$recipientsLower = strtolower($request->recipients);
if (str_contains($recipientsLower, 'worker') || $recipientsLower === 'all') {
$workers = \App\Models\Worker::whereNotNull('fcm_token')->get();
foreach ($workers as $worker) {
\App\Services\FCMService::sendPushNotification(
$worker->fcm_token,
$request->title,
$request->message,
['type' => 'broadcast']
);
}
}
if (str_contains($recipientsLower, 'employer') || $recipientsLower === 'all') {
$employers = \App\Models\User::where('role', 'employer')->whereNotNull('fcm_token')->get();
foreach ($employers as $employer) {
\App\Services\FCMService::sendPushNotification(
$employer->fcm_token,
$request->title,
$request->message,
['type' => 'broadcast']
);
}
}
}
return back()->with('success', "Notification campaign broadcasted successfully to all {$request->recipients} users!");
}
@ -336,10 +309,11 @@ public function refundPayment(Request $request, $id)
'amount' => 'required|numeric|min:1'
]);
$payment = DB::table('subscriptions')->where('id', $id)->first();
$payment = DB::table('payments')->where('id', $id)->first();
if ($payment) {
DB::table('subscriptions')->where('id', $id)->update([
DB::table('payments')->where('id', $id)->update([
'status' => 'refunded',
'description' => $payment->description . " (Refunded: " . $request->refund_reason . ")",
'updated_at' => now()
]);
@ -359,263 +333,66 @@ public function refundPayment(Request $request, $id)
/**
* Interactive Reports Hub
*/
public function analytics(Request $request)
public function analytics()
{
$reportType = $request->input('report_type', 'workers');
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
$status = $request->input('status', 'all');
$search = $request->input('search');
$nationality = $request->input('nationality', 'all');
$export = $request->input('export');
$perPage = (int)$request->input('per_page', 10);
if ($perPage < 1) $perPage = 10;
// 1. Worker Stats
$workerStats = [
'total' => DB::table('workers')->count(),
'verified' => DB::table('workers')->where('verified', 1)->count(),
'active' => DB::table('workers')->where('status', 'active')->count(),
'inactive' => DB::table('workers')->where('status', 'inactive')->count(),
];
$headers = [];
$query = null;
$orderColumn = 'created_at';
$mapFn = null;
// 2. Sponsor Stats
$sponsorStats = [
'total' => DB::table('sponsors')->count(),
'verified' => DB::table('sponsors')->where('is_verified', 1)->count(),
'active_subscribers' => DB::table('sponsors')->where('subscription_status', 'active')->count(),
'unpaid' => DB::table('sponsors')->where('payment_status', 'unpaid')->count(),
];
if ($reportType === 'workers') {
$query = DB::table('workers');
if ($startDate) {
$query->whereDate('created_at', '>=', $startDate);
}
if ($endDate) {
$query->whereDate('created_at', '<=', $endDate);
}
if ($status && $status !== 'all') {
$query->where('status', $status);
}
if ($nationality && $nationality !== 'all') {
$query->where('nationality', $nationality);
}
if ($search) {
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('nationality', 'like', "%{$search}%");
});
}
// 3. Dispute & Safety Stats
$disputeStats = [
'total' => DB::table('disputes')->count(),
'open' => DB::table('disputes')->where('status', 'Open')->count(),
'under_review' => DB::table('disputes')->where('status', 'Under Review')->count(),
'resolved' => DB::table('disputes')->where('status', 'Resolved')->count(),
];
$orderColumn = 'created_at';
$headers = ['Worker ID', 'Name', 'Email', 'Phone', 'Nationality', 'Status', 'Salary (AED)', 'Verified', 'Joined Date'];
$mapFn = function($w) {
$safetyStats = [
'total' => DB::table('moderation_reports')->count(),
'pending' => DB::table('moderation_reports')->where('status', 'Pending')->count(),
'resolved' => DB::table('moderation_reports')->where('status', 'Resolved')->count(),
];
// 4. Financial Payments & Ledger
$payments = DB::table('payments')
->leftJoin('users', 'payments.user_id', '=', 'users.id')
->select('payments.*', 'users.name as user_name', 'users.email as user_email')
->orderBy('payments.created_at', 'desc')
->get()
->map(function($payment) {
return [
'id' => 'WRK-' . str_pad($w->id, 4, '0', STR_PAD_LEFT),
'name' => $w->name,
'email' => $w->email,
'phone' => $w->phone ?: 'N/A',
'nationality' => $w->nationality ?: 'N/A',
'status' => $w->status,
'salary' => (int)$w->salary,
'verified' => $w->verified ? 'Yes' : 'No',
'joined_at' => date('Y-m-d', strtotime($w->created_at)),
'id' => $payment->id,
'user_name' => $payment->user_name ?: 'System Guest / Sponsor',
'user_email' => $payment->user_email ?: 'no-email@marketplace.com',
'amount' => (float)$payment->amount,
'currency' => $payment->currency,
'description' => $payment->description,
'status' => $payment->status,
'created_at' => date('M d, Y h:i A', strtotime($payment->created_at)),
];
};
});
} elseif ($reportType === 'employers') {
$query = DB::table('users')
->leftJoin('employer_profiles', 'users.id', '=', 'employer_profiles.user_id')
->leftJoin('sponsors', 'users.email', '=', 'sponsors.email')
->where('users.role', 'employer')
->select(
'users.id',
'users.name',
'users.email',
'sponsors.status as sponsor_status',
'sponsors.subscription_status',
'users.created_at'
);
if ($startDate) {
$query->whereDate('users.created_at', '>=', $startDate);
}
if ($endDate) {
$query->whereDate('users.created_at', '<=', $endDate);
}
if ($status && $status !== 'all') {
$query->where('sponsors.subscription_status', $status);
}
if ($search) {
$query->where(function($q) use ($search) {
$q->where('users.name', 'like', "%{$search}%")
->orWhere('users.email', 'like', "%{$search}%");
});
}
$orderColumn = 'users.created_at';
$headers = ['Employer ID', 'Name', 'Email', 'Status', 'Subscription Status', 'Total Spent', 'Joined Date'];
$mapFn = function($emp) {
$totalPaid = DB::table('subscriptions')->where('user_id', $emp->id)->where('status', 'active')->sum('amount_aed');
return [
'id' => 'EMP-' . str_pad($emp->id, 4, '0', STR_PAD_LEFT),
'name' => $emp->name,
'email' => $emp->email,
'status' => ucfirst($emp->sponsor_status ?? 'Active'),
'subscription' => ucfirst($emp->subscription_status ?? 'Inactive'),
'total_spent' => number_format($totalPaid, 2) . ' AED',
'joined_at' => date('Y-m-d', strtotime($emp->created_at)),
];
};
} elseif ($reportType === 'payments') {
$query = DB::table('subscriptions')
->leftJoin('users', 'subscriptions.user_id', '=', 'users.id')
->select('subscriptions.*', 'users.name as user_name', 'users.email as user_email');
if ($startDate) {
$query->whereDate('subscriptions.created_at', '>=', $startDate);
}
if ($endDate) {
$query->whereDate('subscriptions.created_at', '<=', $endDate);
}
if ($status && $status !== 'all') {
$query->where('subscriptions.status', $status === 'success' || $status === 'Completed' ? 'active' : $status);
}
if ($search) {
$query->where(function($q) use ($search) {
$q->where('users.name', 'like', "%{$search}%")
->orWhere('subscriptions.plan_id', 'like', "%{$search}%");
});
}
$orderColumn = 'subscriptions.created_at';
$headers = ['Payment ID', 'Employer Name', 'Employer Email', 'Amount', 'Description', 'Status', 'Date'];
$mapFn = function($pay) {
$planLabel = ucfirst($pay->plan_id) . ' Sponsor Pass';
return [
'id' => 'PAY-' . str_pad($pay->id, 4, '0', STR_PAD_LEFT),
'employer_name' => $pay->user_name ?: 'System Guest / Sponsor',
'employer_email' => $pay->user_email ?: 'no-email@marketplace.com',
'amount' => number_format((float)$pay->amount_aed, 2) . ' AED',
'description' => $planLabel,
'status' => $pay->status === 'active' ? 'Success' : ucfirst($pay->status),
'date' => date('Y-m-d H:i', strtotime($pay->created_at)),
];
};
} elseif ($reportType === 'tickets') {
$query = DB::table('support_tickets')
->leftJoin('users', 'support_tickets.user_id', '=', 'users.id')
->leftJoin('report_reasons', 'support_tickets.reason_id', '=', 'report_reasons.id')
->select('support_tickets.*', 'users.name as user_name', 'users.email as user_email', 'report_reasons.reason as reason_name');
if ($startDate) {
$query->whereDate('support_tickets.created_at', '>=', $startDate);
}
if ($endDate) {
$query->whereDate('support_tickets.created_at', '<=', $endDate);
}
if ($status && $status !== 'all') {
$query->where('support_tickets.status', $status);
}
if ($search) {
$query->where(function($q) use ($search) {
$q->where('users.name', 'like', "%{$search}%")
->orWhere('support_tickets.subject', 'like', "%{$search}%");
});
}
$orderColumn = 'support_tickets.created_at';
$headers = ['Ticket ID', 'User Name', 'User Email', 'Subject', 'Reason', 'Status', 'Voice Note Attached', 'Created Date'];
$mapFn = function($tick) {
return [
'id' => 'TCK-' . str_pad($tick->id, 4, '0', STR_PAD_LEFT),
'user_name' => $tick->user_name ?: 'System User',
'user_email' => $tick->user_email ?: 'N/A',
'subject' => $tick->subject,
'reason' => $tick->reason_name ?: 'General Support',
'status' => ucfirst($tick->status),
'has_voice_note' => $tick->voice_note_path ? 'Yes' : 'No',
'created_at' => date('Y-m-d H:i', strtotime($tick->created_at)),
];
};
} elseif ($reportType === 'safety') {
$query = DB::table('moderation_reports');
if ($startDate) {
$query->whereDate('reported_at', '>=', $startDate);
}
if ($endDate) {
$query->whereDate('reported_at', '<=', $endDate);
}
if ($status && $status !== 'all') {
$query->where('status', $status);
}
if ($search) {
$query->where(function($q) use ($search) {
$q->where('reported_user_name', 'like', "%{$search}%")
->orWhere('reported_by_name', 'like', "%{$search}%");
});
}
$orderColumn = 'reported_at';
$headers = ['Report ID', 'Reported User', 'Reported By', 'Reason', 'Status', 'Reported At'];
$mapFn = function($rep) {
return [
'id' => $rep->id,
'reported_user' => $rep->reported_user_name . ' (' . $rep->reported_user_role . ')',
'reported_by' => $rep->reported_by_name . ' (' . $rep->reported_by_role . ')',
'reason' => $rep->reason,
'status' => ucfirst($rep->status),
'reported_at' => date('Y-m-d H:i', strtotime($rep->reported_at)),
];
};
}
// Add sorting
$query->orderBy($orderColumn, 'desc');
if ($export === 'csv') {
$data = $query->get()->map($mapFn);
$filename = "report_" . $reportType . "_" . date('Ymd_His') . ".csv";
$callback = function() use ($data, $headers) {
$file = fopen('php://output', 'w');
// UTF-8 BOM
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($file, $headers);
foreach ($data as $row) {
fputcsv($file, array_values((array)$row));
}
fclose($file);
};
return response()->stream($callback, 200, [
"Content-type" => "text/csv; charset=UTF-8",
"Content-Disposition" => "attachment; filename={$filename}",
"Pragma" => "no-cache",
"Cache-Control" => "must-revalidate, post-check=0, pre-check=0",
"Expires" => "0"
]);
}
// Normal paginated view
$paginated = $query->paginate($perPage)->withQueryString();
$paginated->getCollection()->transform($mapFn);
// Fetch distinct nationalities for filter
$nationalities = DB::table('workers')
->whereNotNull('nationality')
->where('nationality', '!=', '')
->distinct()
->pluck('nationality')
->toArray();
$totalRevenue = DB::table('payments')->where('status', 'success')->sum('amount');
return Inertia::render('Admin/Analytics/Index', [
'reportType' => $reportType,
'startDate' => $startDate ?: '',
'endDate' => $endDate ?: '',
'status' => $status,
'search' => $search ?: '',
'nationality' => $nationality,
'headers' => $headers,
'reportData' => $paginated,
'nationalities' => $nationalities,
'perPage' => $perPage,
'worker_stats' => $workerStats,
'sponsor_stats' => $sponsorStats,
'dispute_stats' => $disputeStats,
'safety_stats' => $safetyStats,
'payments' => $payments,
'total_revenue' => (float)$totalRevenue,
]);
}
@ -663,7 +440,7 @@ public function auditLogs(Request $request)
}
/**
* Charity Events & Drives management
* Announcements management
*/
public function announcements()
{
@ -679,172 +456,74 @@ public function announcements()
$organization = optional($ann->employer->employerProfile)->company_name ?? 'Employer';
}
// Decode charity details if they exist in json
$charityDetails = null;
$content = $ann->body;
if (strpos($ann->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($ann->body, true);
if ($decoded) {
$charityDetails = $decoded;
$content = $decoded['content'] ?? $ann->body;
}
} else {
// Fallback structured details if plain text existed previously
$charityDetails = [
'type' => 'Charity',
'provided_items' => 'Free Medical Checks & Food Supplies',
'event_date' => $ann->created_at->addDays(2)->format('Y-m-d'),
'event_time' => '9:00 AM - 3:00 PM',
'location_details' => 'Al Quoz Community Center, Dubai',
'location_pin' => 'https://maps.google.com',
'content' => $ann->body,
];
}
return [
'id' => $ann->id,
'title' => $ann->title,
'content' => $content,
'type' => $ann->type ?? 'Charity',
'content' => $ann->body,
'type' => $ann->type,
'posted_by' => $postedBy,
'organization' => $organization,
'posted_by_email' => $ann->sponsor ? $ann->sponsor->email : ($ann->employer ? $ann->employer->email : null),
'status' => $ann->status ?? 'pending',
'remarks' => $ann->remarks,
'created_at' => $ann->created_at ? $ann->created_at->format('M d, Y h:i A') : 'Just now',
'charityDetails' => $charityDetails,
];
});
return Inertia::render('Admin/Events/Index', [
return Inertia::render('Admin/Announcements/Index', [
'initialAnnouncements' => $announcements,
]);
}
/**
* Store admin charity event
* Store admin announcement
*/
public function storeAnnouncement(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'provided_items' => 'required|string',
'event_date' => 'required|string',
'event_time' => 'required|string',
'location_details' => 'required|string',
'location_pin' => 'required|string|url',
'contact_person_name' => 'required|string|max:255',
'contact_number' => 'required|string|max:50',
'country_code' => 'nullable|string|max:10',
'type' => 'nullable|string',
]);
$body = json_encode([
'type' => 'Charity',
'provided_items' => $request->provided_items,
'event_date' => $request->event_date,
'event_time' => $request->event_time,
'location_details' => $request->location_details,
'location_pin' => $request->location_pin,
'content' => $request->content,
'contact_person_name' => $request->contact_person_name,
'contact_number' => $request->contact_number,
'country_code' => $request->country_code ?? '+971',
]);
$ann = \App\Models\Announcement::create([
\App\Models\Announcement::create([
'title' => $request->title,
'body' => $body,
'type' => 'Charity',
'body' => $request->content,
'type' => $request->type ?? 'info',
'status' => 'approved',
]);
$this->notifyUsersOfEvent($ann);
return back()->with('success', 'Charity Event created successfully.');
return back()->with('success', 'Announcement broadcasted successfully.');
}
/**
* Approve charity event
* Approve announcement
*/
public function approveAnnouncement(Request $request, $id)
{
$ann = \App\Models\Announcement::findOrFail($id);
$ann->update(['status' => 'approved']);
$this->notifyUsersOfEvent($ann);
return back()->with('success', 'Charity Event approved successfully.');
return back()->with('success', 'Announcement approved successfully.');
}
/**
* Reject charity event
* Reject announcement
*/
public function rejectAnnouncement(Request $request, $id)
{
$request->validate([
'remarks' => 'required|string|max:1000',
]);
$ann = \App\Models\Announcement::findOrFail($id);
$ann->update([
'status' => 'rejected',
'remarks' => $request->remarks,
]);
$ann->update(['status' => 'rejected']);
return back()->with('success', 'Charity Event rejected successfully.');
return back()->with('success', 'Announcement rejected successfully.');
}
/**
* Delete charity event
* Delete announcement
*/
public function deleteAnnouncement(Request $request, $id)
{
$ann = \App\Models\Announcement::findOrFail($id);
$ann->delete();
return back()->with('success', 'Charity Event deleted successfully.');
}
/**
* Broadcast FCM Push Notification and save to Database for new Event/Announcement.
*/
protected function notifyUsersOfEvent($ann)
{
$title = "New Event: " . $ann->title;
$body = $ann->body;
if (str_starts_with($ann->body, '{"type":"Charity"')) {
$decoded = json_decode($ann->body, true);
if ($decoded && isset($decoded['content'])) {
$body = $decoded['content'];
}
}
// Notify all workers
$workers = \App\Models\Worker::all();
foreach ($workers as $worker) {
$worker->notify(new \App\Notifications\GenericNotification(
$title,
$body,
[
'type' => 'announcement',
'announcement_id' => $ann->id,
]
));
}
// Notify all employers
$employers = \App\Models\User::where('role', 'employer')->get();
foreach ($employers as $employer) {
$employer->notify(new \App\Notifications\GenericNotification(
$title,
$body,
[
'type' => 'announcement',
'announcement_id' => $ann->id,
]
));
}
return back()->with('success', 'Announcement deleted successfully.');
}
}

View File

@ -18,19 +18,11 @@ public function index()
$activeWorkers = \App\Models\Worker::where('status', 'active')->count();
$inactiveWorkers = $totalWorkers - $activeWorkers;
$totalEmployers = \App\Models\User::where('role', 'employer')->count();
$activeEmployers = \App\Models\User::where('role', 'employer')
->where(function($query) {
$query->whereHas('sponsor', function($q) {
$q->where('status', 'active');
})->orWhereDoesntHave('sponsor');
})->count();
$inactiveEmployers = $totalEmployers - $activeEmployers;
$activeSubs = \Illuminate\Support\Facades\DB::table('subscriptions')->where('status', 'active')->count();
$expiredSubs = \Illuminate\Support\Facades\DB::table('subscriptions')->where('status', 'expired')->count();
$newEmployers = \App\Models\User::where('role', 'employer')->where('created_at', '>=', now()->subDays(7))->count();
$revenueSum = \Illuminate\Support\Facades\DB::table('subscriptions')->where('status', 'active')->sum('amount_aed');
$totalSponsors = \App\Models\Sponsor::count();
$activeSubs = \App\Models\Sponsor::where('subscription_status', 'active')->count();
$expiredSubs = \App\Models\Sponsor::where('subscription_status', 'expired')->count();
$newSponsors = \App\Models\Sponsor::where('created_at', '>=', now()->subDays(7))->count();
$revenueSum = \Illuminate\Support\Facades\DB::table('payments')->where('status', 'success')->sum('amount');
// Dynamic conversion rates
$verifiedCount = \App\Models\Worker::where('verified', true)->count();
@ -56,18 +48,15 @@ public function index()
$monthStart = $date->copy()->startOfMonth();
$monthEnd = $date->copy()->endOfMonth();
$basic = \Illuminate\Support\Facades\DB::table('subscriptions')
->where('plan_id', 'basic')
$basic = \App\Models\Sponsor::where('subscription_plan', 'basic')
->whereBetween('created_at', [$monthStart, $monthEnd])
->count();
$premium = \Illuminate\Support\Facades\DB::table('subscriptions')
->where('plan_id', 'premium')
$premium = \App\Models\Sponsor::where('subscription_plan', 'premium')
->whereBetween('created_at', [$monthStart, $monthEnd])
->count();
$vip = \Illuminate\Support\Facades\DB::table('subscriptions')
->whereIn('plan_id', ['vip', 'enterprise'])
$vip = \App\Models\Sponsor::where('subscription_plan', 'vip')
->whereBetween('created_at', [$monthStart, $monthEnd])
->count();
@ -93,12 +82,10 @@ public function index()
'total_workers' => $totalWorkers,
'active_workers' => $activeWorkers,
'inactive_workers' => $inactiveWorkers,
'total_employers' => $totalEmployers,
'active_employers' => $activeEmployers,
'inactive_employers' => $inactiveEmployers,
'total_employers' => $totalSponsors,
'active_subscriptions' => $activeSubs,
'revenue_this_month_aed' => $revenueSum,
'new_employers_this_week' => $newEmployers,
'new_employers_this_week' => $newSponsors,
'expired_subscriptions' => $expiredSubs,
'hiring_conversion_rate' => $hiringConversionRate,
'chat_to_hire_rate' => $chatToHireRate,
@ -140,23 +127,20 @@ public function index()
];
})->toArray();
// Fetch recent subscriptions from subscriptions table
$recentSubs = \Illuminate\Support\Facades\DB::table('subscriptions')
->leftJoin('users', 'subscriptions.user_id', '=', 'users.id')
->select('subscriptions.*', 'users.name as employer_name')
->latest('subscriptions.created_at')
// Fetch recent subscriptions from Sponsors table
$recentSponsorsWithPlans = \App\Models\Sponsor::whereNotNull('subscription_plan')
->latest('created_at')
->take(5)
->get();
$recentSubscriptions = [];
foreach ($recentSubs as $sub) {
$planName = ucfirst($sub->plan_id) . ' Pass';
foreach ($recentSponsorsWithPlans as $sp) {
$recentSubscriptions[] = [
'id' => $sub->id,
'employer_name' => $sub->employer_name ?: 'System Guest / Sponsor',
'plan_name' => $planName,
'amount_aed' => (float)$sub->amount_aed,
'subscribed_at' => \Carbon\Carbon::parse($sub->created_at)->diffForHumans(),
'id' => $sp->id,
'employer_name' => $sp->full_name,
'plan_name' => ucfirst($sp->subscription_plan) . ' Pass',
'amount_aed' => $sp->subscription_plan === 'vip' ? 499 : ($sp->subscription_plan === 'premium' ? 199 : 99),
'subscribed_at' => $sp->created_at->diffForHumans(),
];
}

View File

@ -1,286 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\EmployerProfile;
use App\Models\Sponsor;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
class EmployerController extends Controller
{
/**
* List all employers (role = 'employer') with filters, search, and sorting.
*/
public function index(Request $request)
{
$query = User::where('role', 'employer')->with(['employerProfile', 'sponsor']);
// Search
if ($request->filled('search')) {
$search = $request->input('search');
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhereHas('employerProfile', function($qp) use ($search) {
$qp->where('company_name', 'like', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%");
});
});
}
// Status filter (on sponsor status)
if ($request->filled('status') && $request->input('status') !== 'All') {
$status = strtolower($request->input('status'));
$query->whereHas('sponsor', function($qs) use ($status) {
$qs->where('status', $status);
});
}
// Location filter
if ($request->filled('location') && $request->input('location') !== 'All') {
$location = $request->input('location');
$query->whereHas('employerProfile', function($qp) use ($location) {
$qp->where('country', $location);
});
}
// Sorting
$sortField = $request->input('sort_field', 'created_at');
$sortOrder = $request->input('sort_order', 'desc');
if (in_array($sortField, ['name', 'email', 'created_at'])) {
$query->orderBy($sortField, $sortOrder);
} else {
$query->orderBy('created_at', 'desc');
}
$employers = $query->paginate(10)->withQueryString();
// Transform results to format status correctly from sponsor and prevent nulls/missing fields
$employers->getCollection()->transform(function($emp) {
$emp->status = $emp->sponsor ? $emp->sponsor->status : 'active';
// Populate backward-compatible sponsor properties to prevent front-end crashes (e.g. charAt on full_name)
$emp->full_name = $emp->name ?? ($emp->sponsor ? $emp->sponsor->full_name : 'Employer');
if (empty($emp->full_name)) {
$emp->full_name = 'Employer';
}
$emp->mobile = $emp->sponsor ? $emp->sponsor->mobile : ($emp->employerProfile ? $emp->employerProfile->phone : '');
if (empty($emp->mobile)) {
$emp->mobile = '';
}
$emp->city = $emp->sponsor ? $emp->sponsor->city : ($emp->employerProfile ? $emp->employerProfile->country : 'Dubai');
if (empty($emp->city)) {
$emp->city = 'Dubai';
}
$emp->subscription_plan = $emp->sponsor ? $emp->sponsor->subscription_plan : ($emp->subscription_status ?? 'none');
if (empty($emp->subscription_plan)) {
$emp->subscription_plan = 'none';
}
$emp->subscription_status = $emp->sponsor ? $emp->sponsor->subscription_status : ($emp->subscription_status ?? 'none');
if (empty($emp->subscription_status)) {
$emp->subscription_status = 'none';
}
$emp->is_verified = $emp->sponsor ? (bool)$emp->sponsor->is_verified : ($emp->employerProfile ? ($emp->employerProfile->verification_status === 'approved') : false);
return $emp;
});
return Inertia::render('Admin/Employers/Index', [
'employers' => $employers,
'sponsors' => $employers,
'filters' => $request->only(['search', 'status', 'location', 'sort_field', 'sort_order'])
]);
}
/**
* Export employers as CSV.
*/
public function export(Request $request)
{
$query = User::where('role', 'employer')->with(['employerProfile', 'sponsor']);
// Search
if ($request->filled('search')) {
$search = $request->input('search');
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhereHas('employerProfile', function($qp) use ($search) {
$qp->where('company_name', 'like', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%");
});
});
}
// Status filter
if ($request->filled('status') && $request->input('status') !== 'All') {
$status = strtolower($request->input('status'));
$query->whereHas('sponsor', function($qs) use ($status) {
$qs->where('status', $status);
});
}
$employers = $query->get();
$headers = [
"Content-type" => "text/csv",
"Content-Disposition" => "attachment; filename=employers_export_" . date('Ymd_His') . ".csv",
"Pragma" => "no-cache",
"Cache-Control" => "must-revalidate, post-check=0, pre-check=0",
"Expires" => "0"
];
$columns = ['ID', 'Name', 'Email', 'Company Name', 'Phone', 'Country', 'Status', 'Subscription Status', 'Created At'];
$callback = function() use ($employers, $columns) {
$file = fopen('php://output', 'w');
fputcsv($file, $columns);
foreach ($employers as $emp) {
fputcsv($file, [
$emp->id,
$emp->name,
$emp->email,
$emp->employerProfile ? ($emp->employerProfile->company_name ?? 'N/A') : 'N/A',
$emp->employerProfile ? ($emp->employerProfile->phone ?? 'N/A') : 'N/A',
$emp->employerProfile ? ($emp->employerProfile->country ?? 'N/A') : 'N/A',
$emp->sponsor ? $emp->sponsor->status : 'active',
$emp->subscription_status ?? 'none',
$emp->created_at ? $emp->created_at->toDateTimeString() : 'N/A',
]);
}
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
/**
* Verify / approve employer profile.
*/
public function verify($id)
{
$user = User::where('role', 'employer')->findOrFail($id);
if ($user->employerProfile) {
$user->employerProfile->update([
'verification_status' => 'approved'
]);
}
$sponsor = Sponsor::where('email', $user->email)->first();
if ($sponsor) {
$sponsor->update([
'is_verified' => true,
'status' => 'active'
]);
}
return back()->with('success', "Employer '{$user->name}' profile verified and activated successfully.");
}
/**
* Suspend an employer.
*/
public function suspend($id)
{
$user = User::where('role', 'employer')->findOrFail($id);
$sponsor = Sponsor::where('email', $user->email)->first();
if ($sponsor) {
$sponsor->update(['status' => 'suspended']);
}
return back()->with('success', "Employer '{$user->name}' suspended successfully.");
}
/**
* Activate a suspended/inactive employer.
*/
public function activate($id)
{
$user = User::where('role', 'employer')->findOrFail($id);
$sponsor = Sponsor::where('email', $user->email)->first();
if ($sponsor) {
$sponsor->update(['status' => 'active']);
}
return back()->with('success', "Employer '{$user->name}' activated successfully.");
}
/**
* Delete an employer.
*/
public function delete($id)
{
$user = User::where('role', 'employer')->findOrFail($id);
$name = $user->name;
$sponsor = Sponsor::where('email', $user->email)->first();
if ($sponsor) {
$sponsor->delete();
}
if ($user->employerProfile) {
$user->employerProfile->delete();
}
$user->delete();
return back()->with('success', "Employer '{$name}' deleted successfully.");
}
/**
* Update an employer's fields.
*/
public function update(Request $request, $id)
{
$user = User::where('role', 'employer')->findOrFail($id);
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $user->id,
'company_name' => 'nullable|string|max:255',
'phone' => 'nullable|string',
'country' => 'nullable|string',
'subscription_status' => 'nullable|string',
]);
$user->update([
'name' => $validated['name'],
'email' => $validated['email'],
'subscription_status' => $validated['subscription_status'] ?? $user->subscription_status,
]);
if ($user->employerProfile) {
$user->employerProfile->update([
'company_name' => $validated['company_name'] ?? $user->employerProfile->company_name,
'phone' => $validated['phone'] ?? $user->employerProfile->phone,
'country' => $validated['country'] ?? $user->employerProfile->country,
]);
} else {
EmployerProfile::create([
'user_id' => $user->id,
'company_name' => $validated['company_name'],
'phone' => $validated['phone'],
'country' => $validated['country'],
]);
}
$sponsor = Sponsor::where('email', $user->email)->first();
if ($sponsor) {
$sponsor->update([
'full_name' => $validated['name'],
'email' => $validated['email'],
'mobile' => $validated['phone'] ?? $sponsor->mobile,
'organization_name' => $validated['company_name'] ?? $sponsor->organization_name,
'city' => $validated['country'] ?? $sponsor->city,
]);
}
return back()->with('success', "Employer details saved successfully.");
}
}

View File

@ -1,87 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Faq;
use Inertia\Inertia;
class FaqController extends Controller
{
public function index()
{
$faqs = Faq::orderBy('id', 'desc')->get()->map(function ($faq) {
return [
'id' => $faq->id,
'question' => $faq->question,
'answer' => $faq->answer,
'category' => $faq->category,
'is_published' => (bool)$faq->is_published,
'created_at' => $faq->created_at->format('Y-m-d H:i'),
];
});
return Inertia::render('Admin/Faqs/Index', [
'faqs' => $faqs
]);
}
public function store(Request $request)
{
$request->validate([
'question' => 'required|string|max:1000',
'answer' => 'required|string|max:5000',
'category' => 'nullable|string|max:255',
'is_published' => 'boolean'
]);
$question = trim($request->question);
if (!str_ends_with($question, '?')) {
$question .= '?';
}
Faq::create([
'question' => $question,
'answer' => $request->answer,
'category' => $request->category ?: 'General',
'is_published' => $request->has('is_published') ? $request->is_published : true,
]);
return redirect()->back()->with('success', 'FAQ created successfully.');
}
public function update(Request $request, $id)
{
$faq = Faq::findOrFail($id);
$request->validate([
'question' => 'required|string|max:1000',
'answer' => 'required|string|max:5000',
'category' => 'nullable|string|max:255',
'is_published' => 'boolean'
]);
$question = trim($request->question);
if (!str_ends_with($question, '?')) {
$question .= '?';
}
$faq->update([
'question' => $question,
'answer' => $request->answer,
'category' => $request->category ?: 'General',
'is_published' => $request->has('is_published') ? $request->is_published : true,
]);
return redirect()->back()->with('success', 'FAQ updated successfully.');
}
public function destroy($id)
{
$faq = Faq::findOrFail($id);
$faq->delete();
return redirect()->back()->with('success', 'FAQ deleted successfully.');
}
}

View File

@ -15,16 +15,13 @@ class SponsorController extends Controller
*/
public function index(Request $request)
{
$query = Sponsor::whereNotIn('email', function($q) {
$q->select('email')->from('users')->where('role', 'employer');
});
$query = Sponsor::query();
// Server-side Search
if ($request->filled('search')) {
$search = $request->input('search');
$query->where(function($q) use ($search) {
$q->where('full_name', 'like', "%{$search}%")
->orWhere('organization_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('mobile', 'like', "%{$search}%");
});
@ -35,35 +32,15 @@ public function index(Request $request)
$query->where('status', strtolower($request->input('status')));
}
// Verification filter
if ($request->filled('verification') && $request->input('verification') !== 'All') {
$isVerified = $request->input('verification') === 'Verified';
$query->where('is_verified', $isVerified);
}
// License filter
if ($request->filled('license') && $request->input('license') !== 'All') {
$license = $request->input('license');
if ($license === 'Expired') {
$query->where('license_expiry', '<', now());
} elseif ($license === 'Valid') {
$query->where('license_expiry', '>=', now());
} elseif ($license === 'Expiring Soon') {
$query->whereBetween('license_expiry', [now(), now()->addDays(30)]);
}
}
// Nationality filter
if ($request->filled('nationality') && $request->input('nationality') !== 'All') {
$query->where('nationality', $request->input('nationality'));
// Server-side Location/City filter
if ($request->filled('location') && $request->input('location') !== 'All') {
$query->where('city', $request->input('location'));
}
// Server-side Sorting
$sortField = $request->input('sort_field', 'created_at');
$sortOrder = $request->input('sort_order', 'desc');
if (in_array($sortField, ['full_name', 'organization_name', 'email', 'city', 'status', 'created_at'])) {
if (in_array($sortField, ['full_name', 'email', 'city', 'status', 'created_at'])) {
$query->orderBy($sortField, $sortOrder);
} else {
$query->orderBy('created_at', 'desc');
@ -72,47 +49,9 @@ public function index(Request $request)
// Paginate
$sponsors = $query->paginate(10)->withQueryString();
// Get distinct nationalities
$nationalities = [
'Afghan', 'Albanian', 'Algerian', 'American', 'Andorran', 'Angolan', 'Argentinian', 'Armenian', 'Australian', 'Austrian',
'Azerbaijani', 'Bahamian', 'Bahraini', 'Bangladeshi', 'Barbadian', 'Belarusian', 'Belgian', 'Belizean', 'Beninese', 'Bhutanese',
'Bolivian', 'Bosnian', 'Brazilian', 'British', 'Bruneian', 'Bulgarian', 'Burkinabe', 'Burmese', 'Burundian', 'Cambodian',
'Cameroonian', 'Canadian', 'Cape Verdean', 'Central African', 'Chadian', 'Chilean', 'Chinese', 'Colombian', 'Comoran', 'Congolese',
'Costa Rican', 'Croatian', 'Cuban', 'Cypriot', 'Czech', 'Danish', 'Djiboutian', 'Dominican', 'Dutch', 'East Timorese',
'Ecuadorian', 'Egyptian', 'Emirati', 'Equatorial Guinean', 'Eritrean', 'Estonian', 'Ethiopian', 'Fijian', 'Filipino', 'Finnish',
'French', 'Gabonese', 'Gambian', 'Georgian', 'German', 'Ghanaian', 'Greek', 'Grenadian', 'Guatemalan', 'Guinean',
'Guyanese', 'Haitian', 'Honduran', 'Hungarian', 'Icelandic', 'Indian', 'Indonesian', 'Iranian', 'Iraqi', 'Irish',
'Italian', 'Ivorian', 'Jamaican', 'Japanese', 'Jordanian', 'Kazakh', 'Kenyan', 'Kuwaiti', 'Kyrgyz', 'Laotian',
'Latvian', 'Lebanese', 'Liberian', 'Libyan', 'Liechtensteiner', 'Lithuanian', 'Luxembourger', 'Macedonian', 'Malagasy', 'Malawian',
'Malaysian', 'Maldivian', 'Malian', 'Maltese', 'Mauritanian', 'Mauritian', 'Mexican', 'Micronesian', 'Moldovan', 'Monacan',
'Mongolian', 'Montenegrin', 'Moroccan', 'Mozambican', 'Namibian', 'Nauruan', 'Nepalese', 'New Zealander', 'Nicaraguan', 'Nigerian',
'Nigerien', 'North Korean', 'Norwegian', 'Omani', 'Pakistani', 'Palauans', 'Palestinian', 'Panamanian', 'Papua New Guinean', 'Paraguayan',
'Peruvian', 'Polish', 'Portuguese', 'Qatari', 'Romanian', 'Russian', 'Rwandan', 'Saint Lucian', 'Salvadoran', 'Samoan',
'San Marinese', 'Sao Tomean', 'Saudi Arabian', 'Senegalese', 'Serbian', 'Seychellois', 'Sierra Leonean', 'Singaporean', 'Slovak', 'Slovenian',
'Solomon Islander', 'Somali', 'South African', 'South Korean', 'Spanish', 'Sri Lankan', 'Sudanese', 'Surinamese', 'Swazi', 'Swedish',
'Swiss', 'Syrian', 'Taiwanese', 'Tajik', 'Tanzanian', 'Thai', 'Togolese', 'Tongan', 'Trinidadian', 'Tunisian',
'Turkish', 'Turkmen', 'Tuvaluan', 'Ugandan', 'Ukrainian', 'Uruguayan', 'Uzbek', 'Vanuatuan', 'Venezuelan', 'Vietnamese',
'Western Samoan', 'Yemeni', 'Zambian', 'Zimbabwean'
];
// Global stats counts (removing Active plans / Pending approval, replacing with Total, Verified, Suspended)
$stats = [
'total' => Sponsor::whereNotIn('email', function($q) {
$q->select('email')->from('users')->where('role', 'employer');
})->count(),
'verified' => Sponsor::whereNotIn('email', function($q) {
$q->select('email')->from('users')->where('role', 'employer');
})->where('is_verified', true)->count(),
'suspended' => Sponsor::whereNotIn('email', function($q) {
$q->select('email')->from('users')->where('role', 'employer');
})->where('status', 'suspended')->count(),
];
return Inertia::render('Admin/CharityOrganizations/Index', [
return Inertia::render('Admin/Employers/Index', [
'sponsors' => $sponsors,
'filters' => $request->only(['search', 'status', 'verification', 'license', 'nationality', 'sort_field', 'sort_order']),
'nationalities' => $nationalities,
'stats' => $stats
'filters' => $request->only(['search', 'status', 'location', 'sort_field', 'sort_order'])
]);
}
@ -121,16 +60,13 @@ public function index(Request $request)
*/
public function export(Request $request)
{
$query = Sponsor::whereNotIn('email', function($q) {
$q->select('email')->from('users')->where('role', 'employer');
});
$query = Sponsor::query();
// Server-side Search
if ($request->filled('search')) {
$search = $request->input('search');
$query->where(function($q) use ($search) {
$q->where('full_name', 'like', "%{$search}%")
->orWhere('organization_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('mobile', 'like', "%{$search}%");
});
@ -141,35 +77,15 @@ public function export(Request $request)
$query->where('status', strtolower($request->input('status')));
}
// Verification filter
if ($request->filled('verification') && $request->input('verification') !== 'All') {
$isVerified = $request->input('verification') === 'Verified';
$query->where('is_verified', $isVerified);
}
// License filter
if ($request->filled('license') && $request->input('license') !== 'All') {
$license = $request->input('license');
if ($license === 'Expired') {
$query->where('license_expiry', '<', now());
} elseif ($license === 'Valid') {
$query->where('license_expiry', '>=', now());
} elseif ($license === 'Expiring Soon') {
$query->whereBetween('license_expiry', [now(), now()->addDays(30)]);
}
}
// Nationality filter
if ($request->filled('nationality') && $request->input('nationality') !== 'All') {
$query->where('nationality', $request->input('nationality'));
// Server-side Location/City filter
if ($request->filled('location') && $request->input('location') !== 'All') {
$query->where('city', $request->input('location'));
}
// Server-side Sorting
$sortField = $request->input('sort_field', 'created_at');
$sortOrder = $request->input('sort_order', 'desc');
if (in_array($sortField, ['full_name', 'organization_name', 'email', 'city', 'status', 'created_at'])) {
if (in_array($sortField, ['full_name', 'email', 'city', 'status', 'created_at'])) {
$query->orderBy($sortField, $sortOrder);
} else {
$query->orderBy('created_at', 'desc');
@ -179,13 +95,13 @@ public function export(Request $request)
$headers = [
"Content-type" => "text/csv",
"Content-Disposition" => "attachment; filename=charity_organizations_export_" . date('Ymd_His') . ".csv",
"Content-Disposition" => "attachment; filename=sponsors_export_" . date('Ymd_His') . ".csv",
"Pragma" => "no-cache",
"Cache-Control" => "must-revalidate, post-check=0, pre-check=0",
"Expires" => "0"
];
$columns = ['ID', 'Organization Name', 'Full Name', 'Email', 'Mobile', 'City', 'Nationality', 'Address', 'Status', 'Verified', 'License Expiry', 'Created At'];
$columns = ['ID', 'Full Name', 'Email', 'Mobile', 'City', 'Nationality', 'Address', 'Status', 'OTP Verified', 'Verified At', 'Subscription Plan', 'Subscription Status', 'Subscription Expiry', 'Created At'];
$callback = function() use ($sponsors, $columns) {
$file = fopen('php://output', 'w');
@ -194,7 +110,6 @@ public function export(Request $request)
foreach ($sponsors as $sponsor) {
fputcsv($file, [
$sponsor->id,
$sponsor->organization_name ?? 'N/A',
$sponsor->full_name,
$sponsor->email,
$sponsor->mobile,
@ -203,7 +118,10 @@ public function export(Request $request)
$sponsor->address ?? 'N/A',
$sponsor->status,
$sponsor->is_verified ? 'Yes' : 'No',
$sponsor->license_expiry ? $sponsor->license_expiry->toDateString() : 'N/A',
$sponsor->otp_verified_at ? $sponsor->otp_verified_at->toDateTimeString() : 'N/A',
$sponsor->subscription_plan ?? 'None',
$sponsor->subscription_status ?? 'None',
$sponsor->subscription_end_date ? $sponsor->subscription_end_date->toDateString() : 'N/A',
$sponsor->created_at ? $sponsor->created_at->toDateTimeString() : 'N/A',
]);
}
@ -275,7 +193,6 @@ public function update(Request $request, $id)
$sponsor = Sponsor::findOrFail($id);
$validated = $request->validate([
'full_name' => 'required|string|max:255',
'organization_name' => 'nullable|string|max:255',
'email' => 'required|email|unique:sponsors,email,' . $sponsor->id,
'mobile' => 'required|string',
'city' => 'required|string',

View File

@ -56,7 +56,7 @@ public function index(Request $request)
public function show($id)
{
$ticket = SupportTicket::with(['user', 'worker', 'reason'])->findOrFail($id);
$ticket = SupportTicket::with(['user', 'worker'])->findOrFail($id);
$replies = SupportTicketReply::where('support_ticket_id', $ticket->id)
->with(['user', 'worker'])
@ -79,8 +79,6 @@ public function show($id)
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'description' => $ticket->description,
'reason_name' => $ticket->reason ? $ticket->reason->reason : null,
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
'status' => $ticket->status,
'priority' => $ticket->priority,
'user_type' => $ticket->user_id ? 'Employer' : 'Worker',
@ -114,7 +112,6 @@ public function reply(Request $request, $id)
$request->validate([
'message' => 'required|string',
'is_developer_response' => 'nullable|boolean',
'close_ticket' => 'nullable|boolean',
]);
SupportTicketReply::create([
@ -124,11 +121,9 @@ public function reply(Request $request, $id)
'is_developer_response' => $request->is_developer_response ?? false,
]);
// Auto transition status to resolved/closed or keep as in_progress
// Auto transition status to resolved if developer replies, or keep as in_progress
$newStatus = $ticket->status;
if ($request->close_ticket) {
$newStatus = 'closed';
} else if ($ticket->status === 'open') {
if ($ticket->status === 'open') {
$newStatus = 'in_progress';
}
$ticket->update(['status' => $newStatus]);

View File

@ -13,7 +13,7 @@ class WorkerController extends Controller
*/
public function index()
{
$workers = \App\Models\Worker::with(['skills', 'documents'])
$workers = \App\Models\Worker::with(['category', 'skills'])
->latest()
->get()
->map(function ($worker) {
@ -28,7 +28,7 @@ public function index()
// Preferred job types: full-time / part-time / live-in / live-out
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
$preferredJobType = $worker->preferred_job_type ?? $jobTypes[$worker->id % 4];
$preferredJobType = $jobTypes[$worker->id % 4];
return [
'id' => $worker->id,
@ -36,18 +36,14 @@ public function index()
'email' => $worker->email,
'phone' => $worker->phone,
'gender' => $worker->gender ?? 'Female',
'age' => $worker->age,
'religion' => $worker->religion,
'visa_status' => $worker->visa_status,
'in_country' => (bool)$worker->in_country,
'language' => $worker->language ?? 'English',
'languages' => $langs,
'nationality' => $worker->nationality,
'country' => $worker->country,
'city' => $worker->city,
'area' => $worker->area,
'preferred_location' => $worker->preferred_location,
'live_in_out' => $worker->live_in_out ?? 'Live-in',
'category' => $worker->category ? $worker->category->name : 'N/A',
'experience' => $worker->experience,
'salary' => (int)$worker->salary,
'skills' => $mappedSkills,
@ -56,7 +52,6 @@ public function index()
'availability' => $worker->availability,
'verified' => (bool)$worker->verified,
'bio' => $worker->bio,
'documents' => $worker->documents,
'joined_at' => $worker->created_at ? $worker->created_at->format('Y-m-d') : 'N/A',
];
});
@ -127,63 +122,36 @@ public function updateProfile(Request $request, $id)
'phone' => 'required|string',
'gender' => 'nullable|string',
'language' => 'nullable|string',
'preferred_location' => 'nullable|string',
'country' => 'nullable|string',
'city' => 'nullable|string',
'area' => 'nullable|string',
'live_in_out' => 'nullable|string',
'category' => 'required|string',
'experience' => 'required|string',
'salary' => 'nullable|numeric',
'nationality' => 'nullable|string',
'visa_status' => 'nullable|string',
'age' => [
'nullable',
function ($attribute, $value, $fail) {
if (is_numeric($value)) {
$intValue = (int)$value;
if ($intValue < 18 || $intValue > 100) {
$fail('The age must be between 18 and 100.');
}
} else {
if (!in_array($value, ['18-25', '26-35', '36-45', '46-55', '56-60'])) {
$fail('The age must be a valid number or one of the ranges: 18-25, 26-35, 36-45, 46-55, 56-60.');
}
}
}
],
'in_country' => 'nullable',
'preferred_job_type' => 'nullable|string',
'skills' => 'nullable|string',
'bio' => 'nullable|string'
]);
$worker = \App\Models\Worker::findOrFail($id);
$category = \App\Models\WorkerCategory::where('name', $validated['category'])->first();
if ($category) {
$worker->category_id = $category->id;
}
$worker->name = $validated['name'];
$worker->phone = $validated['phone'];
$worker->gender = $validated['gender'] ?? $worker->gender;
$worker->language = $validated['language'] ?? $worker->language;
$worker->preferred_location = $validated['preferred_location'] ?? $worker->preferred_location;
$worker->country = $validated['country'] ?? $worker->country;
$worker->city = $validated['city'] ?? $worker->city;
$worker->area = $validated['area'] ?? $worker->area;
$worker->live_in_out = $validated['live_in_out'] ?? $worker->live_in_out;
$worker->experience = $validated['experience'];
$worker->salary = $validated['salary'] ?? $worker->salary;
$worker->nationality = $validated['nationality'] ?? $worker->nationality;
$worker->visa_status = $validated['visa_status'] ?? $worker->visa_status;
$worker->age = $validated['age'] ?? $worker->age;
if (isset($validated['in_country'])) {
$worker->in_country = filter_var($validated['in_country'], FILTER_VALIDATE_BOOLEAN);
}
$worker->preferred_job_type = $validated['preferred_job_type'] ?? $worker->preferred_job_type;
$worker->bio = $validated['bio'] ?? $worker->bio;
$worker->save();
if ($request->has('skills')) {
$skillsArray = array_filter(array_map('trim', explode(',', $validated['skills'] ?? '')));
$skillIds = [];
foreach ($skillsArray as $name) {
$skill = \App\Models\Skill::firstOrCreate(['name' => $name]);
$skillIds[] = $skill->id;
}
$worker->skills()->sync($skillIds);
}
return back()->with('success', "Worker #{$id} profile details moderated and updated successfully.");
}

View File

@ -10,90 +10,13 @@
class EmployerAnnouncementController extends Controller
{
/**
* Get all approved announcements for employers.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getAnnouncements(Request $request)
{
try {
$page = (int)$request->input('page', 1);
$perPage = (int)$request->input('per_page', 15);
$query = Announcement::with(['employer.employerProfile', 'sponsor'])->where('status', 'approved')->latest();
$total = $query->count();
$offset = ($page - 1) * $perPage;
$announcements = $query->skip($offset)->take($perPage)->get()
->map(function ($announcement) {
$postedBy = 'System';
$organization = 'Migrant Support';
if ($announcement->sponsor_id) {
$postedBy = $announcement->sponsor->full_name;
$organization = $announcement->sponsor->organization_name;
} elseif ($announcement->employer_id) {
$postedBy = $announcement->employer->name;
$organization = $announcement->employer->employerProfile->company_name ?? 'Employer';
}
// Decode charity details if they exist in json
$charityDetails = null;
$content = $announcement->body;
if (strpos($announcement->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($announcement->body, true);
if ($decoded) {
$charityDetails = $decoded;
$content = $decoded['content'] ?? $announcement->body;
}
}
return [
'id' => $announcement->id,
'title' => $announcement->title,
'body' => $content,
'type' => $announcement->type,
'employer_name' => $postedBy,
'company_name' => $organization,
'created_at' => $announcement->created_at->toISOString(),
'time_ago' => $announcement->created_at->diffForHumans(),
'charity_details' => $charityDetails,
];
});
return response()->json([
'success' => true,
'data' => [
'announcements' => $announcements,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => max(1, (int)ceil($total / $perPage)),
]
]
], 200);
} catch (\Exception $e) {
logger()->error('Mobile Employer Get All Announcements Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while fetching announcements.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* Get all announcements posted by this employer.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getMyAnnouncements(Request $request)
public function getAnnouncements(Request $request)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
@ -108,26 +31,13 @@ public function getMyAnnouncements(Request $request)
$announcements = $query->skip($offset)->take($perPage)->get()
->map(function ($announcement) {
$charityDetails = null;
$content = $announcement->body;
if (strpos($announcement->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($announcement->body, true);
if ($decoded) {
$charityDetails = $decoded;
$content = $decoded['content'] ?? $announcement->body;
}
}
return [
'id' => $announcement->id,
'title' => $announcement->title,
'body' => $content,
'body' => $announcement->body,
'type' => $announcement->type,
'status' => $announcement->status ?? 'pending',
'remarks' => $announcement->remarks,
'created_at' => $announcement->created_at->toISOString(),
'time_ago' => $announcement->created_at->diffForHumans(),
'charity_details' => $charityDetails,
];
});
@ -145,7 +55,7 @@ public function getMyAnnouncements(Request $request)
], 200);
} catch (\Exception $e) {
logger()->error('Mobile Employer Get My Announcements Failure: ' . $e->getMessage());
logger()->error('Mobile Employer Get Announcements Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
@ -166,33 +76,12 @@ public function createAnnouncement(Request $request)
/** @var User $employer */
$employer = $request->attributes->get('employer');
$isCharity = $request->type === 'charity' ||
$request->has('event_date') ||
$request->has('location_details') ||
$request->has('provided_items') ||
$request->has('contact_person_name');
$rules = [
$validator = Validator::make($request->all(), [
'title' => 'required|string|max:255',
'body' => 'required_without:content|string|max:5000',
'content' => 'required_without:body|string|max:5000',
'type' => 'nullable|string|in:info,warning,success,charity',
];
if ($isCharity) {
$rules['event_date'] = 'required|string';
$rules['event_time'] = 'required_without_all:start_time,end_time|nullable|string';
$rules['start_time'] = 'required_without:event_time|nullable|string';
$rules['end_time'] = 'required_without:event_time|nullable|string';
$rules['provided_items'] = 'required|string';
$rules['location_details'] = 'required|string';
$rules['location_pin'] = 'required|string|url';
$rules['contact_person_name'] = 'required|string|max:255';
$rules['contact_number'] = 'required|string|regex:/^\d{7,15}$/';
$rules['country_code'] = 'required|string|regex:/^\+\d{1,4}$/';
}
$validator = Validator::make($request->all(), $rules);
'type' => 'nullable|string|in:info,warning,success',
]);
if ($validator->fails()) {
return response()->json([
@ -204,45 +93,26 @@ public function createAnnouncement(Request $request)
try {
$bodyText = $request->body ?? $request->content;
if ($isCharity) {
$eventTime = $request->event_time;
if (empty($eventTime)) {
$eventTime = $request->start_time . ' - ' . $request->end_time;
}
$bodyText = json_encode([
'type' => 'Charity',
'provided_items' => $request->provided_items,
'event_date' => $request->event_date,
'event_time' => $eventTime,
'location_details' => $request->location_details,
'location_pin' => $request->location_pin,
'content' => $request->body ?? $request->content,
'contact_person_name' => $request->contact_person_name,
'contact_number' => $request->contact_number,
'country_code' => $request->country_code,
]);
// Append extra event details if they are provided
$extras = [];
if ($request->event_date) $extras[] = "Date: " . $request->event_date;
if ($request->event_time) $extras[] = "Time: " . $request->event_time;
if ($request->location_details) $extras[] = "Location: " . $request->location_details;
if ($request->location_pin) $extras[] = "Map Pin: " . $request->location_pin;
if ($request->provided_items) $extras[] = "Provided: " . $request->provided_items;
if (!empty($extras)) {
$bodyText .= "\n\n" . implode("\n", $extras);
}
$announcement = Announcement::create([
'title' => $request->title,
'body' => $bodyText,
'type' => $request->type ?? ($isCharity ? 'charity' : 'info'),
'type' => $request->type ?? 'info',
'employer_id' => $employer->id,
'status' => 'pending',
]);
$charityDetails = null;
$content = $announcement->body;
if (strpos($announcement->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($announcement->body, true);
if ($decoded) {
$charityDetails = $decoded;
$content = $decoded['content'] ?? $announcement->body;
}
}
return response()->json([
'success' => true,
'message' => 'Announcement posted successfully.',
@ -250,13 +120,10 @@ public function createAnnouncement(Request $request)
'announcement' => [
'id' => $announcement->id,
'title' => $announcement->title,
'body' => $content,
'body' => $announcement->body,
'type' => $announcement->type,
'status' => $announcement->status ?? 'pending',
'remarks' => $announcement->remarks,
'created_at' => $announcement->created_at->toISOString(),
'time_ago' => $announcement->created_at->diffForHumans(),
'charity_details' => $charityDetails,
]
]
], 201);

View File

@ -25,7 +25,6 @@ public function login(Request $request)
'email' => 'nullable|string',
'mobile' => 'nullable|string',
'password' => 'required|string',
'fcm_token' => 'nullable|string|max:255',
]);
$validator->after(function ($validator) use ($request) {
@ -104,28 +103,12 @@ public function login(Request $request)
], 403);
}
$userUpdateData = ['api_token' => $apiToken];
if ($request->has('fcm_token')) {
$userUpdateData['fcm_token'] = $request->fcm_token;
}
$user->update($userUpdateData);
$user->update(['api_token' => $apiToken]);
if ($sponsor) {
$sponsorUpdateData = [
$sponsor->update([
'api_token' => $apiToken,
'last_login_at' => now(),
];
if ($request->has('fcm_token')) {
$sponsorUpdateData['fcm_token'] = $request->fcm_token;
}
$sponsor->update($sponsorUpdateData);
}
if ($request->filled('fcm_token')) {
\App\Services\FCMService::sendPushNotification(
$request->fcm_token,
'Successful Login',
'Welcome back to your Migrant employer account.'
);
]);
}
return response()->json([
@ -139,22 +122,10 @@ public function login(Request $request)
], 200);
} else {
// Pure Sponsor account
$sponsorUpdateData = [
$sponsor->update([
'api_token' => $apiToken,
'last_login_at' => now(),
];
if ($request->has('fcm_token')) {
$sponsorUpdateData['fcm_token'] = $request->fcm_token;
}
$sponsor->update($sponsorUpdateData);
if ($request->filled('fcm_token')) {
\App\Services\FCMService::sendPushNotification(
$request->fcm_token,
'Successful Login',
'Welcome back to your Migrant sponsor account.'
);
}
]);
return response()->json([
'success' => true,
@ -188,23 +159,10 @@ public function register(Request $request)
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users,email|unique:sponsors,email',
'phone' => 'required|string|max:50|unique:employer_profiles,phone|unique:sponsors,mobile',
'fcm_token' => 'nullable|string|max:255',
'emirates_id' => 'nullable|array',
'address' => 'nullable|string|max:255',
], [
'email.unique' => 'This email address is already registered.',
'phone.unique' => 'This mobile number is already registered.',
'email' => 'required|string|email|max:255|unique:users,email',
'phone' => 'required|string|max:50',
]);
$validator->after(function ($validator) use ($request) {
$emiratesIdInput = $request->input('emirates_id');
if (is_array($emiratesIdInput) && empty($emiratesIdInput['name'])) {
$validator->errors()->add('emirates_id.name', 'Failed to extract name from the Emirates ID. Please upload a clearer document.');
}
});
if ($validator->fails()) {
return response()->json([
'success' => false,
@ -224,40 +182,6 @@ public function register(Request $request)
'expires_at' => now()->addMinutes(10)->timestamp
], 600);
$emiratesIdInput = $request->input('emirates_id');
$emiratesIdNumber = null;
$emiratesIdExpiry = null;
$emiratesIdName = null;
$emiratesIdDob = null;
$emiratesIdNationality = null;
$emiratesIdIssueDate = null;
$emiratesIdEmployer = null;
$emiratesIdIssuePlace = null;
$emiratesIdOccupation = null;
$emiratesIdCardNumber = null;
$emiratesIdGender = null;
if (is_array($emiratesIdInput)) {
$emiratesIdNumber = $emiratesIdInput['emirates_id_number'] ?? $emiratesIdInput['id_number'] ?? $emiratesIdInput['id number'] ?? $emiratesIdInput['card_number'] ?? null;
$emiratesIdExpiry = $emiratesIdInput['expiry_date'] ?? $emiratesIdInput['expiry date'] ?? $emiratesIdInput['emirates_id_expiry'] ?? null;
$emiratesIdName = $emiratesIdInput['name'] ?? null;
if ($emiratesIdName) {
$request->merge(['name' => $emiratesIdName]);
}
$emiratesIdDob = $emiratesIdInput['date_of_birth'] ?? $emiratesIdInput['date of birth'] ?? $emiratesIdInput['dob'] ?? null;
$emiratesIdNationality = $emiratesIdInput['nationality'] ?? null;
$emiratesIdGender = $emiratesIdInput['gender'] ?? $emiratesIdInput['sex'] ?? null;
$emiratesIdIssueDate = $emiratesIdInput['issue_date'] ?? $emiratesIdInput['issue date'] ?? null;
$emiratesIdEmployer = $emiratesIdInput['employer'] ?? null;
$emiratesIdIssuePlace = $emiratesIdInput['issue_place'] ?? $emiratesIdInput['issue place'] ?? $emiratesIdInput['issuing_place'] ?? null;
$emiratesIdOccupation = $emiratesIdInput['occupation'] ?? $emiratesIdInput['occuption'] ?? null;
$emiratesIdCardNumber = $emiratesIdInput['card_number'] ?? null;
$emiratesIdExpiry = $this->normaliseDate($emiratesIdExpiry);
$emiratesIdDob = $this->normaliseDate($emiratesIdDob);
$emiratesIdIssueDate = $this->normaliseDate($emiratesIdIssueDate);
}
// Create inactive User
$user = User::create([
'name' => $request->name,
@ -266,7 +190,6 @@ public function register(Request $request)
'role' => 'employer',
'subscription_status' => 'none',
'subscription_expires_at' => null,
'fcm_token' => $request->fcm_token,
]);
// Create pending Profile
@ -278,19 +201,6 @@ public function register(Request $request)
'verification_status' => 'pending',
'language' => 'English',
'notifications' => true,
'emirates_id_number' => $emiratesIdNumber,
'emirates_id_expiry' => $emiratesIdExpiry,
'emirates_id_name' => $emiratesIdName,
'emirates_id_dob' => $emiratesIdDob,
'nationality' => $emiratesIdNationality,
'emirates_id_issue_date' => $emiratesIdIssueDate,
'emirates_id_employer' => $emiratesIdEmployer,
'emirates_id_issue_place' => $emiratesIdIssuePlace,
'emirates_id_occupation' => $emiratesIdOccupation,
'emirates_id_nationality' => $emiratesIdNationality,
'emirates_id_gender' => $emiratesIdGender,
'emirates_id_card_number' => $emiratesIdCardNumber,
'address' => $request->address,
]);
// Create unverified Sponsor
@ -308,20 +218,6 @@ public function register(Request $request)
'is_verified' => false,
'otp_verified_at' => null,
'status' => 'inactive',
'fcm_token' => $request->fcm_token,
'emirates_id' => $emiratesIdNumber,
'emirates_id_card_number' => $emiratesIdCardNumber,
'emirates_id_name' => $emiratesIdName,
'emirates_id_dob' => $emiratesIdDob,
'emirates_id_issue_date' => $emiratesIdIssueDate,
'emirates_id_expiry' => $emiratesIdExpiry,
'emirates_id_employer' => $emiratesIdEmployer,
'emirates_id_issue_place' => $emiratesIdIssuePlace,
'emirates_id_occupation' => $emiratesIdOccupation,
'emirates_id_nationality' => $emiratesIdNationality,
'emirates_id_gender' => $emiratesIdGender,
'nationality' => $emiratesIdNationality,
'address' => $request->address,
]);
// Try sending email
@ -356,7 +252,6 @@ public function verify(Request $request)
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'otp' => 'required|string|size:6',
'fcm_token' => 'nullable|string|max:255',
]);
if ($validator->fails()) {
@ -393,25 +288,13 @@ public function verify(Request $request)
try {
// Update Sponsor verification status
$sponsor = \App\Models\Sponsor::where('email', $request->email)->first();
$user = User::where('email', $request->email)->where('role', 'employer')->first();
$fcmToken = $request->fcm_token;
if ($sponsor) {
$sponsorUpdateData = [
$sponsor->update([
'is_verified' => true,
'otp_verified_at' => now(),
];
if ($fcmToken) {
$sponsorUpdateData['fcm_token'] = $fcmToken;
}
$sponsor->update($sponsorUpdateData);
]);
}
if ($user && $fcmToken) {
$user->update(['fcm_token' => $fcmToken]);
}
// Clear Cache
\Illuminate\Support\Facades\Cache::forget('employer_otp_' . $request->email);
@ -428,14 +311,11 @@ public function verify(Request $request)
}
}
// Upload Emirates ID during registration steps (Unauthenticated).
public function payment(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'plan_id' => 'required',
'plan_id' => 'required|string',
'amount_aed' => 'required|numeric',
'paytabs_transaction_id' => 'required|string',
]);
@ -465,21 +345,7 @@ public function payment(Request $request)
], 403);
}
$planMap = [
'1' => 'basic',
'2' => 'premium',
'3' => 'vip',
1 => 'basic',
2 => 'premium',
3 => 'vip',
'basic' => 'basic',
'premium' => 'premium',
'enterprise' => 'vip',
'vip' => 'vip',
];
$dbPlanId = $planMap[$request->plan_id] ?? $request->plan_id;
\Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor, $dbPlanId) {
\Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor) {
// Update User subscription
$user->update([
'subscription_status' => 'active',
@ -489,7 +355,7 @@ public function payment(Request $request)
// Update Sponsor status
$sponsor->update([
'subscription_status' => 'active',
'subscription_plan' => $dbPlanId,
'subscription_plan' => $request->plan_id,
'subscription_start_date' => now(),
'subscription_end_date' => now()->addDays(30),
'payment_status' => 'paid',
@ -498,7 +364,7 @@ public function payment(Request $request)
// Insert into subscriptions table
\Illuminate\Support\Facades\DB::table('subscriptions')->insert([
'user_id' => $user->id,
'plan_id' => $dbPlanId,
'plan_id' => $request->plan_id,
'amount_aed' => $request->amount_aed,
'starts_at' => now(),
'expires_at' => now()->addDays(30),
@ -527,7 +393,6 @@ public function password(Request $request)
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'password' => 'required|string|min:8|confirmed',
'fcm_token' => 'nullable|string|max:255',
]);
if ($validator->fails()) {
@ -560,25 +425,16 @@ public function password(Request $request)
\Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor, $apiToken) {
$hashedPassword = Hash::make($request->password);
$fcmToken = $request->fcm_token;
$userUpdateData = [
$user->update([
'password' => $hashedPassword,
'api_token' => $apiToken,
];
if ($fcmToken) {
$userUpdateData['fcm_token'] = $fcmToken;
}
$user->update($userUpdateData);
]);
$sponsorUpdateData = [
$sponsor->update([
'password' => $hashedPassword,
'status' => 'active',
];
if ($fcmToken) {
$sponsorUpdateData['fcm_token'] = $fcmToken;
}
$sponsor->update($sponsorUpdateData);
]);
// Approve profile verification
$profile = EmployerProfile::where('user_id', $user->id)->first();
@ -608,81 +464,38 @@ public function password(Request $request)
public function plans()
{
$dbPlans = \App\Models\Plan::where('status', 'Active')->get();
if ($dbPlans->isEmpty()) {
$plans = [
[
'id' => 1,
'name' => 'Basic Search',
'price' => 99.00,
'currency' => 'AED',
'period' => 'month',
'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR verification'],
'popular' => false,
'max_contact_people' => 10,
'unlimited_contacts' => false,
'enable_job_apply' => false,
'status' => 'Active',
'duration' => 'Monthly',
],
[
'id' => 2,
'name' => 'Premium Employer Pass',
'price' => 199.00,
'currency' => 'AED',
'period' => 'month',
'features' => ['Unlimited shortlisting', 'Direct candidate messaging', 'Priority interview scheduling', 'Dedicated support'],
'popular' => true,
'max_contact_people' => 50,
'unlimited_contacts' => false,
'enable_job_apply' => true,
'status' => 'Active',
'duration' => 'Monthly',
],
[
'id' => 3,
'name' => 'VIP Concierge',
'price' => 499.00,
'currency' => 'AED',
'period' => 'month',
'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'],
'popular' => false,
'max_contact_people' => null,
'unlimited_contacts' => true,
'enable_job_apply' => true,
'status' => 'Active',
'duration' => 'Monthly',
]
];
} else {
$planIdNumberMap = [
'basic' => 1,
'premium' => 2,
'vip' => 3,
'enterprise' => 3,
];
$plans = $dbPlans->map(function ($plan) use ($planIdNumberMap) {
return [
'id' => $planIdNumberMap[$plan->id] ?? 2,
'name' => $plan->name,
'price' => (float)$plan->price,
'currency' => 'AED',
'period' => strtolower($plan->duration) === 'monthly' ? 'month' : $plan->duration,
'features' => $plan->features,
'popular' => $plan->id === 'premium',
'max_contact_people' => $plan->max_contact_people,
'unlimited_contacts' => (bool)$plan->unlimited_contacts,
'enable_job_apply' => (bool)$plan->enable_job_apply,
'status' => $plan->status,
'duration' => $plan->duration,
];
})->toArray();
}
return response()->json([
'success' => true,
'data' => [
'plans' => $plans
'plans' => [
[
'id' => 'basic',
'name' => 'Basic Search',
'price' => 99.00,
'currency' => 'AED',
'period' => 'month',
'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR verification'],
'popular' => false,
],
[
'id' => 'premium',
'name' => 'Premium Employer Pass',
'price' => 199.00,
'currency' => 'AED',
'period' => 'month',
'features' => ['Unlimited shortlisting', 'Direct candidate messaging', 'Priority interview scheduling', 'Dedicated support'],
'popular' => true,
],
[
'id' => 'enterprise',
'name' => 'VIP Concierge',
'price' => 499.00,
'currency' => 'AED',
'period' => 'month',
'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'],
'popular' => false,
]
]
]
]);
}
@ -824,36 +637,4 @@ public function resetPassword(Request $request)
], 500);
}
}
/**
* Normalise date format to Y-m-d.
* E.g. "06/06/2025" -> "2025-06-06"
*/
private function normaliseDate(?string $raw): ?string
{
if (empty($raw)) {
return null;
}
$raw = trim($raw);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
return $raw;
}
try {
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) {
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
$month = $m[2];
$year = $m[3];
if (is_numeric($month)) {
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
return "{$year}-{$month}-{$day}";
}
$ts = strtotime("{$day} {$month} {$year}");
return $ts ? date('Y-m-d', $ts) : $raw;
}
$dt = new \DateTime($raw);
return $dt->format('Y-m-d');
} catch (\Exception $e) {
return $raw;
}
}
}

View File

@ -83,7 +83,7 @@ public function getConversations(Request $request)
try {
$dbConversations = Conversation::where('employer_id', $employer->id)
->with(['worker', 'messages'])
->with(['worker.category', 'messages'])
->latest('updated_at')
->get();
@ -99,9 +99,10 @@ public function getConversations(Request $request)
'id' => $conv->id,
'worker_id' => $conv->worker_id,
'worker_name' => $conv->worker->name ?? 'Candidate',
'category' => $conv->worker->category->name ?? 'General Helper',
'nationality' => $conv->worker->nationality ?? 'Unknown',
'worker_status' => strtolower($conv->worker->status ?? 'active'),
'salary' => ($conv->worker->salary ?? 2000) . ' AED',
'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED',
'last_message' => $lastMsg->text ?? 'No messages yet.',
'unread_count' => $unreadCount,
'sent_at' => $lastMsg ? $lastMsg->created_at->diffForHumans() : 'Just now',
@ -142,7 +143,7 @@ public function getMessages(Request $request, $id)
try {
$conv = Conversation::where('employer_id', $employer->id)
->where('id', $id)
->with(['worker', 'messages'])
->with(['worker.category', 'messages'])
->first();
if (!$conv) {
@ -174,9 +175,10 @@ public function getMessages(Request $request, $id)
$conversationDetail = [
'id' => $conv->id,
'worker_name' => $conv->worker->name ?? 'Candidate',
'category' => $conv->worker->category->name ?? 'General Helper',
'nationality' => $conv->worker->nationality ?? 'Unknown',
'worker_status' => strtolower($conv->worker->status ?? 'active'),
'salary' => ($conv->worker->salary ?? 2000) . ' AED',
'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED',
];
return response()->json([
@ -265,22 +267,33 @@ public function sendMessage(Request $request, $id)
// Touch conversation updated_at for sorting
$conv->touch();
});
// Dispatch push notification to worker
$worker = $conv->worker;
if ($worker && $worker->fcm_token) {
\App\Services\FCMService::sendPushNotification(
$worker->fcm_token,
"New Message from " . ($employer->name ?? "Employer"),
$request->text ?: "Sent an attachment",
[
// Check if employer sent predefined message "are you looking job?"
$textLower = strtolower($request->text);
if (
str_contains($textLower, 'looking job') ||
str_contains($textLower, 'looking for a job') ||
str_contains($textLower, 'looking for job') ||
str_contains($textLower, 'are you looking')
) {
// Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
$workerResponseText = 'Yes';
// Create the message in database
Message::create([
'conversation_id' => $conv->id,
'sender_type' => 'employer',
'sender_id' => $employer->id,
]
);
}
'sender_type' => 'worker',
'sender_id' => $conv->worker_id,
'text' => $workerResponseText,
]);
// Process the worker response to update status to Hired!
$worker = Worker::find($conv->worker_id);
if ($worker) {
self::processWorkerResponse($conv, $worker, $workerResponseText);
}
}
});
return response()->json([
'success' => true,
@ -334,28 +347,10 @@ public function startConversation(Request $request)
try {
// Find or create conversation
$conv = Conversation::where('employer_id', $employer->id)
->where('worker_id', $request->worker_id)
->first();
if (!$conv) {
if (!User::canContactWorker($employer->id, $request->worker_id)) {
$activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first();
$planId = $activeSub ? $activeSub->plan_id : 'basic';
$plan = \App\Models\Plan::find($planId);
$maxContacts = $plan ? $plan->max_contact_people : 10;
return response()->json([
'success' => false,
'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'
], 403);
}
$conv = Conversation::create([
'employer_id' => $employer->id,
'worker_id' => $request->worker_id,
]);
}
$conv = Conversation::firstOrCreate([
'employer_id' => $employer->id,
'worker_id' => $request->worker_id,
]);
return response()->json([
'success' => true,

View File

@ -1,113 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\User;
class EmployerNotificationController extends Controller
{
/**
* List all notifications for the employer.
* GET /api/employers/notifications
*/
public function index(Request $request)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
$filter = $request->query('filter', 'all');
$perPage = (int) $request->query('per_page', 15);
if ($filter === 'read') {
$query = $employer->readNotifications();
} elseif ($filter === 'unread') {
$query = $employer->unreadNotifications();
} else {
$query = $employer->notifications();
}
$paginator = $query->paginate($perPage);
$notifications = collect($paginator->items())->map(function ($notification) {
return [
'id' => $notification->id,
'type' => $notification->type,
'data' => $notification->data,
'read_at' => $notification->read_at ? $notification->read_at->toIso8601String() : null,
'created_at' => $notification->created_at->toIso8601String(),
'time_ago' => $notification->created_at->diffForHumans(),
];
});
return response()->json([
'success' => true,
'data' => [
'notifications' => $notifications,
'pagination' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
]
]
], 200);
}
/**
* Mark a single notification or all unread notifications as read.
* PUT /api/employers/notifications/{id}/read or PUT /api/employers/notifications/read
*/
public function markAsRead(Request $request, $id = null)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
if ($id) {
$notification = $employer->notifications()->where('id', $id)->first();
if (!$notification) {
return response()->json([
'success' => false,
'message' => 'Notification not found.'
], 404);
}
$notification->markAsRead();
} else {
$employer->unreadNotifications->markAsRead();
}
return response()->json([
'success' => true,
'message' => 'Notification(s) marked as read successfully.'
], 200);
}
/**
* Delete a single notification or all notifications.
* DELETE /api/employers/notifications/{id} or DELETE /api/employers/notifications
*/
public function destroy(Request $request, $id = null)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
if ($id) {
$notification = $employer->notifications()->where('id', $id)->first();
if (!$notification) {
return response()->json([
'success' => false,
'message' => 'Notification not found.'
], 404);
}
$notification->delete();
} else {
$employer->notifications()->delete();
}
return response()->json([
'success' => true,
'message' => 'Notification(s) deleted successfully.'
], 200);
}
}

View File

@ -22,28 +22,49 @@ public function getPayments(Request $request)
try {
$employerId = $employer->id;
// Auto-seed payments if none exist for a richer UX
$paymentsCount = Payment::where('user_id', $employerId)->count();
if ($paymentsCount === 0) {
// Determine their plan
$subscription = DB::table('subscriptions')->where('user_id', $employerId)->first();
$planName = $subscription ? (ucfirst($subscription->plan_id) . ' Pass Subscription') : 'Premium Sponsor Pass Subscription';
$planAmount = $subscription ? $subscription->amount_aed : 199.00;
for ($i = 0; $i < 8; $i++) {
Payment::create([
'user_id' => $employerId,
'amount' => $planAmount,
'currency' => 'AED',
'description' => $planName,
'status' => 'success',
'created_at' => now()->subMonths($i)->subDays(rand(1, 5)),
'updated_at' => now()->subMonths($i)->subDays(rand(1, 5)),
]);
}
}
$page = (int)$request->input('page', 1);
$perPage = (int)$request->input('per_page', 15);
$query = DB::table('subscriptions')
->where('user_id', $employerId)
->orderBy('created_at', 'desc');
$query = Payment::where('user_id', $employerId)
->where(function ($q) {
$q->where('description', 'like', '%Subscription%')
->orWhere('description', 'like', '%Pass%');
})
->latest();
$total = $query->count();
$offset = ($page - 1) * $perPage;
$payments = $query->skip($offset)->take($perPage)->get()
->map(function ($sub) {
$planLabel = ucfirst($sub->plan_id) . ' Sponsor Pass';
$startsAt = $sub->starts_at ?? $sub->created_at;
->map(function ($payment) {
return [
'id' => $sub->id,
'amount' => (float)$sub->amount_aed,
'currency' => 'AED',
'description' => $planLabel,
'status' => $sub->status === 'active' ? 'success' : $sub->status,
'date' => date('Y-m-d H:i:s', strtotime($startsAt)),
'formatted_date' => date('M d, Y', strtotime($startsAt)),
'id' => $payment->id,
'amount' => (float)$payment->amount,
'currency' => $payment->currency,
'description' => $payment->description,
'status' => $payment->status,
'date' => $payment->created_at->format('Y-m-d H:i:s'),
'formatted_date' => $payment->created_at->format('M d, Y'),
];
});

View File

@ -23,14 +23,13 @@ public function getProfile(Request $request)
$employer = $request->attributes->get('employer');
try {
$employer->load('employerReviews.worker');
$profile = $employer->employerProfile;
if (!$profile) {
$profile = EmployerProfile::create([
'user_id' => $employer->id,
'company_name' => 'Al Mansoor Household',
'phone' => '+971 50 123 4567',
'address' => 'Dubai, UAE',
'verification_status' => 'approved',
]);
}
@ -38,33 +37,13 @@ public function getProfile(Request $request)
$employerProfile = [
'name' => $employer->name,
'email' => $employer->email,
'company_name' => $profile->company_name,
'phone' => $profile->phone,
'address' => $profile->address,
'country' => $profile->country,
'language' => $profile->language ?? 'English',
'notifications' => (bool)($profile->notifications ?? true),
'verification_status' => $profile->verification_status ?? 'approved',
'id_number' => $profile->emirates_id_number,
'card_number' => $profile->emirates_id_card_number,
'full_name' => $profile->emirates_id_name,
'gender' => $profile->emirates_id_gender,
'rating' => $employer->rating,
'reviews_count' => $employer->reviews_count,
'reviews' => $employer->employerReviews,
'emirates_id' => [
'emirates_id_number' => $profile->emirates_id_number,
'name' => $profile->emirates_id_name,
'date_of_birth' => $profile->emirates_id_dob,
'issue_date' => $profile->emirates_id_issue_date,
'expiry_date' => $profile->emirates_id_expiry,
'employer' => $profile->emirates_id_employer,
'issue_place' => $profile->emirates_id_issue_place,
'occupation' => $profile->emirates_id_occupation,
'nationality' => $profile->nationality,
'gender' => $profile->emirates_id_gender,
'card_number' => $profile->emirates_id_card_number,
'country' => 'United Arab Emirates',
],
'emirates_id_number' => $profile->emirates_id_number,
'emirates_id_expiry' => $profile->emirates_id_expiry,
];
return response()->json([
@ -108,37 +87,14 @@ public function updateProfile(Request $request)
'unique:users,email,' . $employer->id,
$sponsor ? 'unique:sponsors,email,' . $sponsor->id : 'unique:sponsors,email',
],
'phone' => [
'required',
'string',
'max:255',
'unique:employer_profiles,phone,' . ($employer->employerProfile ? $employer->employerProfile->id : 'NULL'),
$sponsor ? 'unique:sponsors,mobile,' . $sponsor->id : 'unique:sponsors,mobile',
],
'address' => 'required|string|max:255',
'notifications' => 'nullable|boolean',
'fcm_token' => 'nullable|string|max:255',
// Emirates ID optional fields
'id_number' => 'nullable|string|max:255',
'card_number' => 'nullable|string|max:255',
'full_name' => 'nullable|string|max:255',
'date_of_birth' => 'nullable|string|max:255',
'nationality' => 'nullable|string|max:255',
'gender' => 'nullable|string|max:255',
'issue_date' => 'nullable|string|max:255',
'expiry_date' => 'nullable|string|max:255',
'occupation' => 'nullable|string|max:255',
'employer' => 'nullable|string|max:255',
'issuing_place' => 'nullable|string|max:255',
'phone' => 'required|string|max:255',
'company_name' => 'required|string|max:255',
'language' => 'required|string|in:English,Arabic,english,arabic',
'notifications' => 'required|boolean',
'current_password' => 'nullable|required_with:new_password|string',
'new_password' => 'nullable|string|min:8|confirmed',
]);
$validator->after(function ($validator) use ($request) {
if ($request->has('full_name') && !$request->filled('full_name')) {
$validator->errors()->add('full_name', 'Failed to extract name from the Emirates ID. Please upload a clearer document.');
}
});
if ($validator->fails()) {
return response()->json([
'success' => false,
@ -148,19 +104,14 @@ public function updateProfile(Request $request)
}
try {
if ($request->filled('full_name')) {
$request->merge(['name' => $request->full_name]);
}
// Validate Emirates ID immutability — once set, it cannot be changed
if ($request->filled('id_number')) {
$existingProfile = $employer->employerProfile;
if ($existingProfile && $existingProfile->emirates_id_number && $existingProfile->emirates_id_number !== $request->id_number) {
// Check current password if new password is provided
if ($request->filled('new_password')) {
if (!Hash::check($request->current_password, $employer->password)) {
return response()->json([
'success' => false,
'message' => 'Emirates ID mismatch. You cannot change your registered Emirates ID number.',
'message' => 'The provided current password does not match.',
'errors' => [
'id_number' => ['Emirates ID mismatch.']
'current_password' => ['The provided password does not match your current password.']
]
], 422);
}
@ -170,60 +121,19 @@ public function updateProfile(Request $request)
$oldEmail = $employer->getOriginal('email') ?? $employer->email;
// Update user table
$userData = [
$employer->update([
'name' => $request->name,
'email' => $request->email,
];
if ($request->has('fcm_token')) {
$userData['fcm_token'] = $request->fcm_token;
}
$employer->update($userData);
]);
// Sync with corresponding sponsor record if found
$matchingSponsor = \App\Models\Sponsor::where('email', $oldEmail)->first();
if ($matchingSponsor) {
$sponsorData = [
$matchingSponsor->update([
'full_name' => $request->name,
'email' => $request->email,
'mobile' => $request->phone,
'address' => $request->address,
];
if ($request->has('id_number')) {
$sponsorData['emirates_id'] = $request->id_number;
}
if ($request->has('card_number')) {
$sponsorData['emirates_id_card_number'] = $request->card_number;
}
if ($request->has('full_name')) {
$sponsorData['emirates_id_name'] = $request->full_name;
}
if ($request->has('date_of_birth')) {
$sponsorData['emirates_id_dob'] = $this->normaliseDate($request->date_of_birth);
}
if ($request->has('nationality')) {
$sponsorData['nationality'] = $request->nationality;
}
if ($request->has('gender')) {
$sponsorData['emirates_id_gender'] = $request->gender;
}
if ($request->has('issue_date')) {
$sponsorData['emirates_id_issue_date'] = $this->normaliseDate($request->issue_date);
}
if ($request->has('expiry_date')) {
$sponsorData['emirates_id_expiry'] = $this->normaliseDate($request->expiry_date);
}
if ($request->has('occupation')) {
$sponsorData['emirates_id_occupation'] = $request->occupation;
}
if ($request->has('employer')) {
$sponsorData['emirates_id_employer'] = $request->employer;
}
if ($request->has('issuing_place')) {
$sponsorData['emirates_id_issue_place'] = $request->issuing_place;
}
$matchingSponsor->update($sponsorData);
]);
}
// Update employer_profiles table
@ -231,78 +141,29 @@ public function updateProfile(Request $request)
if (!$profile) {
$profile = new EmployerProfile(['user_id' => $employer->id]);
}
$profile->address = $request->address;
$profile->company_name = $request->company_name;
$profile->phone = $request->phone;
$profile->notifications = $request->has('notifications') ? $request->notifications : ($profile->notifications ?? true);
if ($request->has('id_number')) {
$profile->emirates_id_number = $request->id_number;
}
if ($request->has('card_number')) {
$profile->emirates_id_card_number = $request->card_number;
}
if ($request->has('full_name')) {
$profile->emirates_id_name = $request->full_name;
}
if ($request->has('date_of_birth')) {
$profile->emirates_id_dob = $this->normaliseDate($request->date_of_birth);
}
if ($request->has('nationality')) {
$profile->nationality = $request->nationality;
}
if ($request->has('gender')) {
$profile->emirates_id_gender = $request->gender;
}
if ($request->has('issue_date')) {
$profile->emirates_id_issue_date = $this->normaliseDate($request->issue_date);
}
if ($request->has('expiry_date')) {
$profile->emirates_id_expiry = $this->normaliseDate($request->expiry_date);
}
if ($request->has('occupation')) {
$profile->emirates_id_occupation = $request->occupation;
}
if ($request->has('employer')) {
$profile->emirates_id_employer = $request->employer;
}
if ($request->has('issuing_place')) {
$profile->emirates_id_issue_place = $request->issuing_place;
}
$profile->language = ucfirst(strtolower($request->language));
$profile->notifications = $request->notifications;
$profile->save();
$employer->load('employerReviews.worker');
// Update password if present
if ($request->filled('new_password')) {
$employer->update([
'password' => Hash::make($request->new_password),
]);
}
$employerProfile = [
'name' => $employer->name,
'email' => $employer->email,
'company_name' => $profile->company_name,
'phone' => $profile->phone,
'address' => $profile->address,
'country' => $profile->country,
'language' => $profile->language,
'notifications' => (bool)$profile->notifications,
'verification_status' => $profile->verification_status ?? 'approved',
'id_number' => $profile->emirates_id_number,
'card_number' => $profile->emirates_id_card_number,
'full_name' => $profile->emirates_id_name,
'gender' => $profile->emirates_id_gender,
'rating' => $employer->rating,
'reviews_count' => $employer->reviews_count,
'reviews' => $employer->employerReviews,
'emirates_id' => [
'emirates_id_number' => $profile->emirates_id_number,
'name' => $profile->emirates_id_name,
'date_of_birth' => $profile->emirates_id_dob,
'issue_date' => $profile->emirates_id_issue_date,
'expiry_date' => $profile->emirates_id_expiry,
'employer' => $profile->emirates_id_employer,
'issue_place' => $profile->emirates_id_issue_place,
'occupation' => $profile->emirates_id_occupation,
'nationality' => $profile->nationality,
'gender' => $profile->emirates_id_gender,
'card_number' => $profile->emirates_id_card_number,
'country' => 'United Arab Emirates',
],
'emirates_id_number' => $profile->emirates_id_number,
'emirates_id_expiry' => $profile->emirates_id_expiry,
];
return response()->json([
@ -342,15 +203,6 @@ public function getDashboard(Request $request)
$savedCandidates = \App\Models\Shortlist::where('employer_id', $employer->id)->count();
$totalWorkers = \App\Models\Worker::with('documents')
->where('status', '!=', 'Hired')
->where('status', '!=', 'hidden')
->get()
->filter(function ($w) {
return !str_contains(strtolower($w->passport_status), 'pending');
})
->count();
// Resolve plan
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
->where('user_id', $employer->id)
@ -358,49 +210,23 @@ public function getDashboard(Request $request)
->latest('id')
->first();
$planId = $sub ? $sub->plan_id : 'premium';
// Map raw subscription plan_id to database plan ID string
$planMap = [
'1' => 'basic',
'2' => 'premium',
'3' => 'vip',
1 => 'basic',
2 => 'premium',
3 => 'vip',
'basic' => 'basic',
'premium' => 'premium',
'enterprise' => 'vip',
'vip' => 'vip',
];
$dbPlanId = $planMap[$planId] ?? 'premium';
$associatedPlan = \App\Models\Plan::find($dbPlanId);
$planIdNumberMap = [
'basic' => 1,
'premium' => 2,
'vip' => 3,
];
$currentPlan = [
'plan_id' => $associatedPlan ? ($planIdNumberMap[$associatedPlan->id] ?? 2) : 2,
'name' => $associatedPlan ? $associatedPlan->name : (ucfirst($planId) . ' Pass'),
'plan_id' => $sub ? $sub->plan_id : 'premium',
'name' => $sub ? (ucfirst($sub->plan_id) . ' Pass') : 'Premium Employer Pass',
'status' => $sub ? $sub->status : 'active',
'starts_at' => $sub ? date('Y-m-d', strtotime($sub->starts_at)) : now()->subDays(6)->format('Y-m-d'),
'expires_at' => $sub ? date('Y-m-d', strtotime($sub->expires_at)) : now()->addDays(24)->format('Y-m-d'),
'max_contact_people' => $associatedPlan ? $associatedPlan->max_contact_people : null,
'unlimited_contacts' => $associatedPlan ? (bool)$associatedPlan->unlimited_contacts : false,
'enable_job_apply' => $associatedPlan ? (bool)$associatedPlan->enable_job_apply : false,
'features' => $associatedPlan ? $associatedPlan->features : [],
'price' => $associatedPlan ? (float)$associatedPlan->price : 0.0,
'duration' => $associatedPlan ? $associatedPlan->duration : 'Monthly',
];
// Recent Announcements / Events
$dbAnnouncements = \App\Models\Announcement::where('status', 'approved')->latest()->limit(2)->get();
$dbAnnouncements = \App\Models\Announcement::latest()->limit(5)->get();
$recentAnnouncements = $dbAnnouncements->map(function ($ann) {
$charityDetails = null;
$body = $ann->body;
$eventDate = null;
$eventTime = null;
$locationDetails = null;
$locationPin = null;
$providedItems = null;
if (strpos($ann->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($ann->body, true);
@ -411,7 +237,6 @@ public function getDashboard(Request $request)
$locationDetails = $decoded['location_details'] ?? null;
$locationPin = $decoded['location_pin'] ?? null;
$providedItems = $decoded['provided_items'] ?? null;
$charityDetails = $decoded;
}
} else {
$body = $ann->body;
@ -434,7 +259,6 @@ public function getDashboard(Request $request)
'provided_items' => $providedItems,
'created_at' => $ann->created_at->toISOString(),
'time_ago' => $ann->created_at->diffForHumans(),
'charity_details' => $charityDetails,
];
});
@ -445,7 +269,6 @@ public function getDashboard(Request $request)
'contacted_workers_count' => $contactedWorkersCount,
'total_hired_workers' => $totalHiredWorkers,
'saved_candidates' => $savedCandidates,
'total_workers' => $totalWorkers,
],
'current_plan' => $currentPlan,
'recent_announcements' => $recentAnnouncements,
@ -463,102 +286,4 @@ public function getDashboard(Request $request)
], 500);
}
}
/**
* Change employer's password.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function changePassword(Request $request)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
$validator = Validator::make($request->all(), [
'current_password' => 'required|string',
'new_password' => 'required|string|min:8|confirmed',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
if (!Hash::check($request->current_password, $employer->password)) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => [
'current_password' => ['The current password is incorrect.']
]
], 422);
}
try {
$hashedPassword = Hash::make($request->new_password);
// Update user table
$employer->update([
'password' => $hashedPassword
]);
// Sync with corresponding sponsor record if found
$matchingSponsor = \App\Models\Sponsor::where('email', $employer->email)->first();
if ($matchingSponsor) {
$matchingSponsor->update([
'password' => $hashedPassword
]);
}
return response()->json([
'success' => true,
'message' => 'Password changed successfully.'
], 200);
} catch (\Exception $e) {
logger()->error('Mobile Employer Change Password Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while changing password.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* Normalise date format to Y-m-d.
* E.g. "06/06/2025" -> "2025-06-06"
*/
private function normaliseDate(?string $raw): ?string
{
if (empty($raw)) {
return null;
}
$raw = trim($raw);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
return $raw;
}
try {
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) {
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
$month = $m[2];
$year = $m[3];
if (is_numeric($month)) {
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
return "{$year}-{$month}-{$day}";
}
$ts = strtotime("{$day} {$month} {$year}");
return $ts ? date('Y-m-d', $ts) : $raw;
}
$dt = new \DateTime($raw);
return $dt->format('Y-m-d');
} catch (\Exception $e) {
return $raw;
}
}
}

View File

@ -35,76 +35,12 @@ public function addReview(Request $request)
}
try {
// 1. Validate confirmed hire and completed joining, and review eligibility period
$hasConfirmedHire = false;
$joiningDate = null;
// Check standard JobApplication where the job belongs to this employer and worker matches
$app = \App\Models\JobApplication::where('worker_id', $request->worker_id)
->where('status', 'hired')
->whereNotNull('joining_confirmed_at')
->whereHas('jobPost', function ($query) use ($employer) {
$query->where('employer_id', $employer->id);
})
->first();
if ($app) {
$hasConfirmedHire = true;
$joiningDate = \Carbon\Carbon::parse($app->joining_confirmed_at);
} else {
// Check direct JobOffer
$offer = \App\Models\JobOffer::where('worker_id', $request->worker_id)
->where('employer_id', $employer->id)
->where('status', 'accepted')
->first();
if ($offer) {
$hasConfirmedHire = true;
$joiningDate = $offer->work_date ? \Carbon\Carbon::parse($offer->work_date) : $offer->updated_at;
}
}
if (!$hasConfirmedHire) {
return response()->json([
'success' => false,
'message' => 'You can only review workers with a confirmed hire and completed joining.'
], 403);
}
// Verify if completed configured period from joining date
$eligibilityMinutes = config('reminders.review_eligibility_minutes');
if ($eligibilityMinutes !== null && $eligibilityMinutes > 0) {
if ($joiningDate->diffInMinutes(now(), false) < $eligibilityMinutes) {
return response()->json([
'success' => false,
'message' => "You can write a review only after {$eligibilityMinutes} minutes from the joining date."
], 403);
}
} else {
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) {
return response()->json([
'success' => false,
'message' => "You can write a review only after {$eligibilityMonths} months from the joining date."
], 403);
}
}
// Check if employer has already reviewed this worker
$existingReview = Review::where('employer_id', $employer->id)
->where('worker_id', $request->worker_id)
->first();
if ($existingReview) {
// Check if the edit window has expired
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
if ($existingReview->created_at->addDays($editPeriodDays)->isPast()) {
return response()->json([
'success' => false,
'message' => "The {$editPeriodDays}-day edit window for this review has expired."
], 403);
}
// If they already have a review, we can update it or direct them to the edit endpoint.
// To be robust, let's update it directly and inform them it was updated (acting as an edit).
$existingReview->update([
@ -182,15 +118,6 @@ public function editReview(Request $request, $id)
], 404);
}
// Check if the edit window has expired
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
if ($review->created_at->addDays($editPeriodDays)->isPast()) {
return response()->json([
'success' => false,
'message' => "The {$editPeriodDays}-day edit window for this review has expired."
], 403);
}
$review->update([
'rating' => $request->rating,
'comment' => $request->comment,

View File

@ -6,6 +6,7 @@
use Illuminate\Http\Request;
use App\Models\User;
use App\Models\Worker;
use App\Models\WorkerCategory;
use App\Models\Shortlist;
use App\Models\JobOffer;
use App\Models\JobApplication;
@ -20,80 +21,61 @@ class EmployerWorkerController extends Controller
/**
* Helper to map worker DB models to API presentation format.
*/
public function formatWorker(Worker $w)
private function formatWorker(Worker $w)
{
// Map languages from database comma-separated string
$langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English'];
// Map languages with country names
$langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']);
// Preferred job types: full-time / part-time / live-in / live-out
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
$preferredJobType = $w->preferred_job_type ?? $jobTypes[$w->id % 4];
// Emirates ID verification status
// Emirates ID verification status (dynamic passport status)
$emiratesIdStatus = $w->emirates_id_status;
// Exact Skills mapping: cooking, driving, childcare, cleaning, elderly care, gardening
$skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
$mappedSkills = [
$skillsList[$w->id % 6],
$skillsList[($w->id + 2) % 6]
];
// Visa status
$visaStatus = $w->visa_status;
$photo = null;
// Optional profile photos
$photos = [
'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200',
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=200',
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=200',
null
];
$photo = $photos[$w->id % 4];
$dbReviews = \App\Models\Review::where('worker_id', $w->id)->get();
$reviewsCount = $dbReviews->count();
$rating = $reviewsCount > 0 ? round($dbReviews->avg('rating'), 1) : 0.0;
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
$reviewsCount = ($w->id * 4) % 20 + 2;
return [
'id' => $w->id,
'name' => $w->name,
'email' => $w->email,
'phone' => $w->phone,
'nationality' => $w->nationality,
'age' => $w->age,
'salary' => $w->salary,
'experience' => $w->experience,
'verified' => (bool)$w->verified,
'status' => $w->status,
'created_at' => $w->created_at?->toISOString(),
'updated_at' => $w->updated_at?->toISOString(),
'deleted_at' => $w->deleted_at?->toISOString(),
'language' => $w->language,
'languages' => $langs,
'main_profession' => $w->main_profession,
'preferred_location' => $w->preferred_location,
'country' => $w->country,
'city' => $w->city,
'area' => $w->area,
'live_in_out' => $w->live_in_out,
'gender' => $w->gender,
'in_country' => (bool)$w->in_country,
'visa_status' => $visaStatus,
'preferred_job_type' => $preferredJobType,
'fcm_token' => $w->fcm_token,
'passport_status' => $w->passport_status,
'emirates_id_status' => $emiratesIdStatus,
'document_expiry_days' => $w->document_expiry_days,
'document_expiry_status' => $w->document_expiry_status,
'visa_expiry_date' => $w->visa_expiry_date,
'bio' => $w->bio,
'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus,
'passport_status' => $w->passport_status,
'skills' => $mappedSkills,
'visa_status' => $visaStatus,
'experience' => $w->experience,
'religion' => $w->religion,
'languages' => $langs,
'age' => $w->age,
'verified' => (bool)$w->verified,
'preferred_job_type' => $preferredJobType,
'bio' => $w->bio,
'rating' => $rating,
'reviews_count' => $reviewsCount,
'skills' => $w->skills->map(function ($s) {
return [
'id' => $s->id,
'name' => $s->name,
'created_at' => $s->created_at?->toISOString(),
'updated_at' => $s->updated_at?->toISOString(),
'image_path' => $s->image_path,
'pivot' => [
'worker_id' => $s->pivot->worker_id,
'skill_id' => $s->pivot->skill_id,
]
];
})->toArray(),
'category' => [
'id' => 7,
'name' => 'General Helper',
],
'preferred_location' => $w->preferred_location,
'live_in_out' => $w->live_in_out,
'in_country' => (bool)$w->in_country,
];
}
@ -104,7 +86,7 @@ public function formatWorker(Worker $w)
public function getWorkers(Request $request)
{
try {
$query = Worker::with(['skills', 'documents'])
$query = Worker::with(['category', 'skills', 'documents'])
->where('status', '!=', 'Hired')
->where('status', '!=', 'hidden');
@ -130,21 +112,7 @@ public function getWorkers(Request $request)
$workersArray = $formattedWorkers->toArray();
// Apply filters: gender, skills, language/languages, nationality, preferred_location, job_type, live_in_out, in_country, visa_status
if ($request->filled('main_profession')) {
$mainProfession = strtolower($request->main_profession);
$workersArray = array_values(array_filter($workersArray, function ($c) use ($mainProfession) {
return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession;
}));
}
if ($request->filled('gender')) {
$gender = strtolower($request->gender);
$workersArray = array_values(array_filter($workersArray, function ($c) use ($gender) {
return isset($c['gender']) && strtolower($c['gender']) === $gender;
}));
}
// Apply filters: skills, language/languages, nationality, preferred_location, job_type, live_in_out, in_country, visa_status
if ($request->filled('nationality')) {
$natInput = $request->nationality;
$natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput)));
@ -170,8 +138,7 @@ public function getWorkers(Request $request)
$workersArray = array_values(array_filter($workersArray, function ($c) use ($skillsArray) {
if (!isset($c['skills']) || !is_array($c['skills'])) return false;
foreach ($c['skills'] as $s) {
$skillName = is_array($s) ? ($s['name'] ?? '') : (is_object($s) ? ($s->name ?? '') : $s);
if (in_array(strtolower($skillName), $skillsArray)) {
if (in_array(strtolower($s), $skillsArray)) {
return true;
}
}
@ -201,40 +168,11 @@ public function getWorkers(Request $request)
$locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc)));
$locsArray = array_map('strtolower', $locsArray);
$locationMapping = [
'dubai' => ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'],
'abu dhabi' => ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'],
'oman' => ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq']
];
$workersArray = array_values(array_filter($workersArray, function ($c) use ($locsArray, $locationMapping) {
$workersArray = array_values(array_filter($workersArray, function ($c) use ($locsArray) {
if (!isset($c['preferred_location'])) return false;
$wLoc = strtolower($c['preferred_location']);
foreach ($locsArray as $l) {
if ($l === 'all') return true;
$allowedKeywords = $locationMapping[$l] ?? [$l];
foreach ($allowedKeywords as $keyword) {
if (str_contains($wLoc, $keyword)) {
return true;
}
}
}
return false;
}));
}
// Filter: area
if ($request->filled('area')) {
$areaParam = $request->area;
$areasArray = is_array($areaParam) ? $areaParam : array_filter(array_map('trim', explode(',', $areaParam)));
$areasArray = array_map('strtolower', $areasArray);
$workersArray = array_values(array_filter($workersArray, function ($c) use ($areasArray) {
if (!isset($c['area'])) return false;
$wArea = strtolower($c['area']);
foreach ($areasArray as $a) {
if ($a === 'all') return true;
if (str_contains($wArea, $a)) {
if (str_contains($wLoc, $l)) {
return true;
}
}
@ -343,22 +281,10 @@ public function getWorkers(Request $request)
}));
}
$page = (int)$request->input('page', 1);
$perPage = (int)$request->input('per_page', 15);
$total = count($workersArray);
$offset = ($page - 1) * $perPage;
$paginatedWorkers = array_slice($workersArray, $offset, $perPage);
return response()->json([
'success' => true,
'data' => [
'workers' => $paginatedWorkers,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => max(1, (int)ceil($total / $perPage)),
]
'workers' => $workersArray
]
], 200);
@ -388,11 +314,11 @@ public function getCandidates(Request $request)
// Fetch Job Applications
$jobIds = JobPost::where('employer_id', $employerId)->pluck('id');
$applicationsQuery = JobApplication::whereIn('job_id', $jobIds)
->with(['worker.skills', 'worker.documents', 'jobPost']);
->with(['worker.category', 'jobPost']);
// Fetch Direct Hiring Offers
$directOffersQuery = JobOffer::where('employer_id', $employerId)
->with('worker.skills', 'worker.documents');
->with('worker.category');
// Apply search filter if provided
if ($request->filled('search')) {
@ -416,22 +342,38 @@ public function getCandidates(Request $request)
if (!$w) return null;
$status = 'Reviewing';
$dbStatusLower = strtolower($app->status ?? '');
if ($dbStatusLower === 'hired') $status = 'Hired';
elseif ($dbStatusLower === 'rejected') $status = 'Rejected';
elseif ($dbStatusLower === 'shortlisted' || $dbStatusLower === 'offer_sent' || $dbStatusLower === 'offer sent') $status = 'Offer Sent';
elseif ($dbStatusLower === 'applied') $status = 'Reviewing';
if ($app->status === 'hired') $status = 'Hired';
elseif ($app->status === 'rejected') $status = 'Rejected';
elseif ($app->status === 'shortlisted' || $app->status === 'offer_sent') $status = 'Offer Sent';
elseif ($app->status === 'applied') $status = 'Reviewing';
else $status = ucfirst($app->status);
$formattedWorker = $this->formatWorker($w);
$langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']);
$skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
$mappedSkills = [
$skillsList[$w->id % 6],
$skillsList[($w->id + 2) % 6]
];
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
$preferredJobType = $w->preferred_job_type ?? $jobTypes[$w->id % 4];
return array_merge($formattedWorker, [
return [
'id' => $app->id,
'worker_id' => $w->id,
'name' => $w->name,
'nationality' => $w->nationality,
'status' => $status,
'applied_at' => $app->created_at->format('Y-m-d H:i:s'),
'type' => 'application',
]);
'skills' => $mappedSkills,
'languages' => $langs,
'availability' => $w->availability ?? 'Immediate',
'preferred_location' => $w->preferred_location,
'preferred_job_type' => $preferredJobType,
'live_in_out' => $w->live_in_out,
'in_country' => (bool)$w->in_country,
'visa_status' => $w->visa_status,
];
})->filter()->values()->toArray();
// Fetch Direct Hiring Offers
@ -442,54 +384,47 @@ public function getCandidates(Request $request)
if (!$w) return null;
$status = 'Offer Sent';
$dbOfferStatusLower = strtolower($offer->status ?? '');
if ($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') $status = 'Hired';
elseif ($dbOfferStatusLower === 'rejected') $status = 'Rejected';
elseif ($dbOfferStatusLower === 'pending' || $dbOfferStatusLower === 'offer sent' || $dbOfferStatusLower === 'offer_sent') $status = 'Offer Sent';
if ($offer->status === 'accepted') $status = 'Hired';
elseif ($offer->status === 'rejected') $status = 'Rejected';
elseif ($offer->status === 'pending') $status = 'Offer Sent';
$formattedWorker = $this->formatWorker($w);
$langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']);
$skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
$mappedSkills = [
$skillsList[$w->id % 6],
$skillsList[($w->id + 2) % 6]
];
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
$preferredJobType = $w->preferred_job_type ?? $jobTypes[$w->id % 4];
return array_merge($formattedWorker, [
return [
'id' => 'offer_' . $offer->id,
'worker_id' => $w->id,
'name' => $w->name,
'nationality' => $w->nationality,
'status' => $status,
'applied_at' => $offer->created_at->format('Y-m-d H:i:s'),
'type' => 'direct_offer',
]);
'skills' => $mappedSkills,
'languages' => $langs,
'availability' => $w->availability ?? 'Immediate',
'preferred_location' => $w->preferred_location,
'preferred_job_type' => $preferredJobType,
'live_in_out' => $w->live_in_out,
'in_country' => (bool)$w->in_country,
'visa_status' => $w->visa_status,
];
})->filter()->values()->toArray();
// Merge and sort (filtering out duplicates for direct offers where already hired via application)
$hiredAppWorkerIds = collect($selectedWorkers)
->filter(fn($w) => strtolower($w['status'] ?? '') === 'hired')
->pluck('worker_id')
->toArray();
$filteredDirectWorkers = array_filter($directWorkers, function($dw) use ($hiredAppWorkerIds) {
return $dw && !in_array($dw['worker_id'], $hiredAppWorkerIds);
});
$mergedCandidates = array_merge($selectedWorkers, $filteredDirectWorkers);
// Merge and sort
$mergedCandidates = array_merge($selectedWorkers, $directWorkers);
// Only display Hired candidates in the candidates pipeline (exclude active states)
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) {
return $c && strtolower($c['status'] ?? '') === 'hired';
return $c && $c['status'] === 'Hired';
}));
// Apply filters: gender, skills, languages, nationality, availability, preferred_location, job_type, live_in_out, in_country, visa_status
if ($request->filled('main_profession')) {
$mainProfession = strtolower($request->main_profession);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($mainProfession) {
return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession;
}));
}
if ($request->filled('gender')) {
$gender = strtolower($request->gender);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($gender) {
return isset($c['gender']) && strtolower($c['gender']) === $gender;
}));
}
// Apply filters: skills, languages, nationality, availability, preferred_location, job_type, live_in_out, in_country, visa_status
if ($request->filled('nationality')) {
$natInput = $request->nationality;
$natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput)));
@ -564,25 +499,6 @@ public function getCandidates(Request $request)
}));
}
// Filter: area
if ($request->filled('area')) {
$areaParam = $request->area;
$areasArray = is_array($areaParam) ? $areaParam : array_filter(array_map('trim', explode(',', $areaParam)));
$areasArray = array_map('strtolower', $areasArray);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($areasArray) {
if (!isset($c['area'])) return false;
$wArea = strtolower($c['area']);
foreach ($areasArray as $a) {
if ($a === 'all') return true;
if (str_contains($wArea, $a)) {
return true;
}
}
return false;
}));
}
// Filter: job_type / preferred_job_type
$jobTypeParam = $request->input('job_type') ?? $request->input('preferred_job_type');
if ($jobTypeParam) {
@ -739,46 +655,16 @@ public function hireCandidate(Request $request, $id = null)
$updatedApplication = null;
$updatedOffer = null;
// Resolve the worker ID to perform limit check
$resolvedWorkerId = null;
if (is_string($targetId) && str_starts_with($targetId, 'offer_')) {
$offerId = substr($targetId, 6);
$offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->first();
if ($offer) {
$resolvedWorkerId = $offer->worker_id;
}
} else {
$application = JobApplication::where('id', $targetId)
->whereHas('jobPost', function($q) use ($employerId) {
$q->where('employer_id', $employerId);
})->first();
if ($application) {
$resolvedWorkerId = $application->worker_id;
} else {
$resolvedWorkerId = $targetId;
}
}
if ($resolvedWorkerId && !\App\Models\User::canContactWorker($employerId, $resolvedWorkerId)) {
$activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first();
$planId = $activeSub ? $activeSub->plan_id : 'basic';
$plan = \App\Models\Plan::find($planId);
$maxContacts = $plan ? $plan->max_contact_people : 10;
return response()->json([
'success' => false,
'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'
], 403);
}
// Direct Offer check
if (is_string($targetId) && str_starts_with($targetId, 'offer_')) {
$offerId = substr($targetId, 6);
$offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->firstOrFail();
$offer->update(['status' => 'pending']);
$worker = $offer->worker;
$offer->update(['status' => 'accepted']);
if ($offer->worker) {
$offer->worker->update(['status' => 'Hired']);
$worker = $offer->worker;
}
$updatedOffer = $offer;
} else {
// Check if targetId is an application
@ -788,8 +674,11 @@ public function hireCandidate(Request $request, $id = null)
})->first();
if ($application) {
$application->update(['status' => 'hire_requested']);
$worker = $application->worker;
$application->update(['status' => 'hired']);
if ($application->worker) {
$application->worker->update(['status' => 'Hired']);
$worker = $application->worker;
}
$updatedApplication = $application;
} else {
// Try targeting by Worker ID directly
@ -802,13 +691,15 @@ public function hireCandidate(Request $request, $id = null)
], 404);
}
// Find existing direct offer or create a new one with status 'pending'
$worker->update(['status' => 'Hired']);
// Find existing direct offer
$offer = JobOffer::where('employer_id', $employerId)
->where('worker_id', $worker->id)
->first();
if ($offer) {
$offer->update(['status' => 'pending']);
$offer->update(['status' => 'accepted']);
$updatedOffer = $offer;
} else {
// Find existing application
@ -818,57 +709,30 @@ public function hireCandidate(Request $request, $id = null)
->first();
if ($application) {
$application->update(['status' => 'hire_requested']);
$application->update(['status' => 'hired']);
$updatedApplication = $application;
} else {
// Create a new direct job offer in 'pending' status
// Create a new direct job offer and accept it
$updatedOffer = JobOffer::create([
'employer_id' => $employerId,
'worker_id' => $worker->id,
'work_date' => now()->format('Y-m-d'),
'location' => 'Dubai',
'salary' => $worker->salary,
'notes' => 'Direct sponsoring matching initiated via API.',
'status' => 'pending',
'notes' => 'Direct sponsoring matching finalized via mobile API.',
'status' => 'accepted',
]);
}
}
}
}
// Dispatch push & database notification to worker for confirmation request
if ($worker) {
if ($updatedApplication) {
$job = $updatedApplication->jobPost;
$worker->notify(new \App\Notifications\GenericNotification(
"Action Required: Confirm Hiring",
"Employer " . ($employer->name ?? "Employer") . " wants to hire you for '" . ($job->title ?? 'Job Post') . "'. Please confirm.",
[
'type' => 'hire_request',
'job_id' => $job->id ?? '',
'application_id' => $updatedApplication->id,
'status' => 'hire_requested',
]
));
} else if ($updatedOffer) {
$worker->notify(new \App\Notifications\GenericNotification(
"Direct Hiring Offer",
($employer->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?",
[
'type' => 'direct_hire_request',
'offer_id' => $updatedOffer->id,
'status' => 'pending',
]
));
}
}
return response()->json([
'success' => true,
'message' => 'Hire request initiated successfully. Awaiting candidate confirmation.',
'message' => 'Candidate successfully marked as Hired!',
'data' => [
'worker_id' => $worker ? $worker->id : null,
'worker_status' => $worker ? $worker->status : 'Active',
'worker_status' => $worker ? $worker->status : 'Hired',
'application' => $updatedApplication,
'offer' => $updatedOffer,
]
@ -893,7 +757,7 @@ public function getWorkerDetail(Request $request, $id)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
$w = Worker::with(['skills', 'documents'])->find($id);
$w = Worker::with(['category', 'skills', 'documents'])->find($id);
if (!$w) {
return response()->json([
@ -937,10 +801,17 @@ public function getWorkerDetail(Request $request, $id)
];
// Visa status
$visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa'];
$visaStatus = $visaStatusesList[$w->id % 3];
$visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa'];
$visaStatus = $visaStatusesList[$w->id % 5];
$photo = null;
// Profile photo
$photos = [
'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200',
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=200',
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=200',
null
];
$photo = $photos[$w->id % 4];
// Fetch dynamic database reviews (Requirement 1)
$dbReviews = Review::where('worker_id', $w->id)->with('employer')->latest()->get();
@ -958,13 +829,28 @@ public function getWorkerDetail(Request $request, $id)
if ($reviewsCount > 0) {
$rating = round($dbReviews->avg('rating'), 1);
} else {
$rating = 0.0;
$reviewsCount = 0;
$reviews = [];
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
$reviewsCount = ($w->id * 4) % 20 + 2;
$reviews = [
[
'id' => 1,
'employer_name' => 'Fatima Al Mansoori',
'rating' => 5,
'date' => 'May 10, 2026',
'comment' => 'Extremely reliable and respectful. Professional work and great communication.',
],
[
'id' => 2,
'employer_name' => 'Michael Harrison',
'rating' => 4,
'date' => 'Mar 24, 2026',
'comment' => 'Very punctual, did exactly what was expected. Highly recommend.',
]
];
}
// Similar workers matching web view
$simDb = Worker::query()
$simDb = Worker::with('category')
->where('id', '!=', $w->id)
->where('status', '!=', 'Hired')
->where('status', '!=', 'hidden')
@ -994,54 +880,24 @@ public function getWorkerDetail(Request $request, $id)
$workerProfile = [
'id' => $w->id,
'name' => $w->name,
'email' => $w->email,
'phone' => $w->phone,
'nationality' => $w->nationality,
'age' => $w->age,
'salary' => $w->salary,
'experience' => $w->experience,
'verified' => (bool)$w->verified,
'status' => $w->status,
'created_at' => $w->created_at?->toISOString(),
'updated_at' => $w->updated_at?->toISOString(),
'deleted_at' => $w->deleted_at?->toISOString(),
'language' => $w->language,
'languages' => $langs,
'preferred_location' => $w->preferred_location,
'country' => $w->country,
'city' => $w->city,
'area' => $w->area,
'live_in_out' => $w->live_in_out,
'gender' => $w->gender,
'in_country' => (bool)$w->in_country,
'visa_status' => $visaStatus,
'preferred_job_type' => $preferredJobType,
'fcm_token' => $w->fcm_token,
'passport_status' => $w->passport_status,
'emirates_id_status' => $emiratesIdStatus,
'document_expiry_days' => $w->document_expiry_days,
'document_expiry_status' => $w->document_expiry_status,
'visa_expiry_date' => $w->visa_expiry_date,
'bio' => $w->bio,
'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus,
'skills' => $mappedSkills,
'visa_status' => $visaStatus,
'experience' => $w->experience,
'experience_years' => 5,
'religion' => $w->religion,
'languages' => $langs,
'age' => $w->age,
'verified' => (bool)$w->verified,
'preferred_job_type' => $preferredJobType,
'bio' => $w->bio,
'rating' => $rating,
'reviews_count' => $reviewsCount,
'reviews' => $reviews,
'similar_workers' => $similarWorkers,
'conversation_id' => $conversation ? $conversation->id : null,
'skills' => $w->skills->map(function ($s) {
return [
'id' => $s->id,
'name' => $s->name,
'created_at' => $s->created_at?->toISOString(),
'updated_at' => $s->updated_at?->toISOString(),
'image_path' => $s->image_path,
'pivot' => [
'worker_id' => $s->pivot->worker_id,
'skill_id' => $s->pivot->skill_id,
]
];
})->toArray(),
];
return response()->json([
@ -1076,7 +932,7 @@ public function getShortlist(Request $request)
$perPage = (int)$request->input('per_page', 15);
$shortlists = Shortlist::where('employer_id', $employer->id)
->with(['worker.skills', 'worker.documents'])
->with(['worker.category', 'worker.skills'])
->get();
$formattedWorkers = $shortlists->map(function ($s) {

View File

@ -1,46 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\GoogleVisionOcrService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class GoogleVisionOcrController extends Controller
{
/**
* Upload passport image and extract fields using Google Cloud Vision API.
*/
public function extractPassport(Request $request)
{
$validator = Validator::make($request->all(), [
'passport_file' => 'required|file|image|max:10240', // Max 10MB
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors(),
], 422);
}
$file = $request->file('passport_file');
$result = GoogleVisionOcrService::extractPassportData($file);
if (!$result['success']) {
return response()->json([
'success' => false,
'message' => $result['message'] ?? 'Could not extract data from passport image.',
'data' => $result['data'] ?? null,
], 400);
}
return response()->json([
'success' => true,
'data' => $result['data'],
], 200);
}
}

View File

@ -280,17 +280,12 @@ public function reportFromEmployer(Request $request)
*/
public function getReasonsForWorker(Request $request)
{
$type = $request->query('type'); // Chat or Support
$type = $request->query('type'); // Chat or Review
$query = DB::table('report_reasons')->where('status', 'Active');
if ($type && in_array(strtolower($type), ['chat', 'support'])) {
$mappedType = ucfirst(strtolower($type));
if ($mappedType === 'Support') {
$query->where('type', 'Support');
} else {
$query->whereIn('type', [$mappedType, 'Both']);
}
if ($type && in_array($type, ['Chat', 'Review'])) {
$query->whereIn('type', [$type, 'Both']);
}
$reasons = $query->orderBy('id', 'asc')->get(['id', 'reason', 'type']);
@ -307,17 +302,12 @@ public function getReasonsForWorker(Request $request)
*/
public function getReasonsForEmployer(Request $request)
{
$type = $request->query('type'); // Chat or Support
$type = $request->query('type'); // Chat or Review
$query = DB::table('report_reasons')->where('status', 'Active');
if ($type && in_array(strtolower($type), ['chat', 'support'])) {
$mappedType = ucfirst(strtolower($type));
if ($mappedType === 'Support') {
$query->where('type', 'Support');
} else {
$query->whereIn('type', [$mappedType, 'Both']);
}
if ($type && in_array($type, ['Chat', 'Review'])) {
$query->whereIn('type', [$type, 'Both']);
}
$reasons = $query->orderBy('id', 'asc')->get(['id', 'reason', 'type']);

View File

@ -24,30 +24,21 @@ public function register(Request $request)
{
$validator = Validator::make($request->all(), [
'full_name' => 'required|string|max:255',
'mobile' => 'required|string|max:50|unique:sponsors,mobile|unique:employer_profiles,phone',
'mobile' => 'required|string|max:50|unique:sponsors,mobile',
'password' => 'required|string|min:6',
'license' => 'nullable|array',
'emirates_id' => 'nullable|array',
'organization_name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:sponsors,email|unique:users,email',
'nationality' => 'required|string|max:100',
'city' => 'required|string|max:100',
'address' => 'required|string|max:255',
'country_code' => 'required|string|max:10',
'license_expiry' => 'nullable|date_format:Y-m-d',
'fcm_token' => 'nullable|string|max:255',
'license_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240',
'organization_name' => 'nullable|string|max:255',
'email' => 'nullable|email|max:255|unique:sponsors,email',
'nationality' => 'nullable|string|max:100',
'city' => 'nullable|string|max:100',
'address' => 'nullable|string|max:255',
'country_code' => 'nullable|string|max:10',
], [
'mobile.unique' => 'This mobile number is already registered.',
'email.unique' => 'This email address is already registered.',
'license_file.required' => 'Please upload your organization or trade license.',
]);
$validator->after(function ($validator) use ($request) {
$emiratesIdInput = $request->input('emirates_id');
if (is_array($emiratesIdInput) && empty($emiratesIdInput['name'])) {
$validator->errors()->add('emirates_id.name', 'Failed to extract name from the Emirates ID. Please upload a clearer document.');
}
});
if ($validator->fails()) {
return response()->json([
'success' => false,
@ -64,61 +55,20 @@ public function register(Request $request)
$email = "sponsor.{$mobileClean}." . rand(10, 99) . "@migrant.ae";
}
$licenseExpiry = $request->license_expiry;
$licenseInput = $request->input('license');
$licenseNumber = null;
if (is_array($licenseInput)) {
$rawExpiry = $licenseInput['expiry_date'] ?? $licenseInput['license_expiry'] ?? null;
if ($rawExpiry) {
$licenseExpiry = $licenseExpiry ?? \App\Services\OcrDocumentService::normaliseDate($rawExpiry);
}
$licenseNumber = $licenseInput['license_number'] ?? $licenseInput['license_no'] ?? $licenseInput['document_number'] ?? null;
// Store license file
$destinationPath = public_path('uploads/licenses');
if (!file_exists($destinationPath)) {
mkdir($destinationPath, 0755, true);
}
$emiratesIdInput = $request->input('emirates_id');
$emiratesId = null;
$emiratesIdExpiry = null;
$emiratesIdName = null;
$emiratesIdDob = null;
$emiratesIdNationality = null;
$emiratesIdIssueDate = null;
$emiratesIdEmployer = null;
$emiratesIdIssuePlace = null;
$emiratesIdOccupation = null;
$emiratesIdGender = null;
$emiratesIdCardNumber = null;
$licenseFile = $request->file('license_file');
$licenseFileName = time() . '_license_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $licenseFile->getClientOriginalName());
$licenseFile->move($destinationPath, $licenseFileName);
$licensePath = 'uploads/licenses/' . $licenseFileName;
if (is_array($emiratesIdInput)) {
$emiratesId = $emiratesIdInput['emirates_id_number'] ?? $emiratesIdInput['id_number'] ?? $emiratesIdInput['id number'] ?? $emiratesIdInput['card_number'] ?? null;
$emiratesIdExpiry = $emiratesIdInput['expiry_date'] ?? $emiratesIdInput['expiry date'] ?? $emiratesIdInput['emirates_id_expiry'] ?? null;
$emiratesIdName = $emiratesIdInput['name'] ?? null;
if ($emiratesIdName) {
$request->merge(['full_name' => $emiratesIdName]);
}
$emiratesIdDob = $emiratesIdInput['date_of_birth'] ?? $emiratesIdInput['date of birth'] ?? $emiratesIdInput['dob'] ?? null;
$emiratesIdNationality = $emiratesIdInput['nationality'] ?? null;
$emiratesIdGender = $emiratesIdInput['gender'] ?? $emiratesIdInput['sex'] ?? null;
$emiratesIdIssueDate = $emiratesIdInput['issue_date'] ?? $emiratesIdInput['issue date'] ?? null;
$emiratesIdEmployer = $emiratesIdInput['employer'] ?? null;
$emiratesIdIssuePlace = $emiratesIdInput['issue_place'] ?? $emiratesIdInput['issue place'] ?? null;
$emiratesIdOccupation = $emiratesIdInput['occupation'] ?? $emiratesIdInput['occuption'] ?? null;
$emiratesIdCardNumber = $emiratesIdInput['card_number'] ?? null;
$emiratesIdExpiry = $this->normaliseDate($emiratesIdExpiry);
$emiratesIdDob = $this->normaliseDate($emiratesIdDob);
$emiratesIdIssueDate = $this->normaliseDate($emiratesIdIssueDate);
}
$emiratesIdPath = null;
$licensePath = null;
$apiToken = Str::random(80);
$sponsor = DB::transaction(function () use (
$request, $email, $licensePath, $emiratesIdPath, $emiratesId, $apiToken, $licenseExpiry, $licenseNumber, $licenseInput,
$emiratesIdExpiry, $emiratesIdName, $emiratesIdDob, $emiratesIdIssueDate, $emiratesIdEmployer,
$emiratesIdIssuePlace, $emiratesIdOccupation, $emiratesIdNationality, $emiratesIdGender, $emiratesIdCardNumber
) {
$sponsor = DB::transaction(function () use ($request, $email, $licensePath, $apiToken) {
return Sponsor::create([
'full_name' => $request->full_name,
'organization_name' => $request->organization_name,
@ -130,75 +80,18 @@ public function register(Request $request)
'city' => $request->city,
'address' => $request->address,
'license_file' => $licensePath,
'emirates_id_file' => $emiratesIdPath,
'emirates_id' => $emiratesId,
'license_expiry' => $licenseExpiry,
'license_number' => $licenseNumber,
'license_data' => $licenseInput,
'status' => 'active',
'is_verified' => false,
'subscription_status' => 'none',
'api_token' => $apiToken,
'fcm_token' => $request->fcm_token,
'emirates_id_name' => $emiratesIdName,
'emirates_id_dob' => $emiratesIdDob,
'emirates_id_issue_date' => $emiratesIdIssueDate,
'emirates_id_expiry' => $emiratesIdExpiry,
'emirates_id_employer' => $emiratesIdEmployer,
'emirates_id_issue_place' => $emiratesIdIssuePlace,
'emirates_id_occupation' => $emiratesIdOccupation,
'emirates_id_nationality' => $emiratesIdNationality,
'emirates_id_gender' => $emiratesIdGender,
'emirates_id_card_number' => $emiratesIdCardNumber,
]);
});
if ($request->filled('fcm_token')) {
\App\Services\FCMService::sendPushNotification(
$request->fcm_token,
'Welcome to Migrant',
'Sponsor account registered successfully. Your license is pending admin review.'
);
}
return response()->json([
'success' => true,
'message' => 'Sponsor account registered successfully. Your license is pending admin review.',
'data' => [
'sponsor' => [
'id' => $sponsor->id,
'full_name' => $sponsor->full_name,
'organization_name' => $sponsor->organization_name,
'mobile' => $sponsor->mobile,
'email' => $sponsor->email,
'nationality' => $sponsor->nationality,
'city' => $sponsor->city,
'address' => $sponsor->address,
'country_code' => $sponsor->country_code,
'is_verified' => $sponsor->is_verified,
'status' => $sponsor->status,
'license_file' => $sponsor->license_file ? asset($sponsor->license_file) : null,
'emirates_id_file' => $sponsor->emirates_id_file ? asset($sponsor->emirates_id_file) : null,
'license_expiry' => $sponsor->license_expiry ? $sponsor->license_expiry->toDateString() : null,
'license_number' => $sponsor->license_number,
'license' => $sponsor->license_data,
'validity' => $sponsor->license_expiry ? ($sponsor->license_expiry->isFuture() ? 'Valid' : 'Expired') : 'Pending Review',
'joined_at' => $sponsor->created_at->toIso8601String(),
'emirates_id' => [
'emirates_id_number' => $sponsor->emirates_id,
'name' => $sponsor->emirates_id_name,
'date_of_birth' => $sponsor->emirates_id_dob,
'issue_date' => $sponsor->emirates_id_issue_date,
'expiry_date' => $sponsor->emirates_id_expiry,
'employer' => $sponsor->emirates_id_employer,
'issue_place' => $sponsor->emirates_id_issue_place,
'occupation' => $sponsor->emirates_id_occupation,
'nationality' => $sponsor->emirates_id_nationality,
'gender' => $sponsor->emirates_id_gender,
'card_number' => $sponsor->emirates_id_card_number,
'country' => 'United Arab Emirates',
],
],
'sponsor' => $sponsor->makeHidden(['password', 'api_token']),
'token' => $apiToken,
]
], 201);
@ -222,9 +115,8 @@ public function register(Request $request)
public function login(Request $request)
{
$validator = Validator::make($request->all(), [
'mobile' => 'required|string',
'password' => 'required|string',
'fcm_token' => 'nullable|string|max:255',
'mobile' => 'required|string',
'password' => 'required|string',
]);
if ($validator->fails()) {
@ -253,283 +145,18 @@ public function login(Request $request)
// Rotate token on each login for security
$apiToken = Str::random(80);
$updateData = [
$sponsor->update([
'api_token' => $apiToken,
'last_login_at' => now(),
];
if ($request->has('fcm_token')) {
$updateData['fcm_token'] = $request->fcm_token;
}
$sponsor->update($updateData);
if ($request->filled('fcm_token')) {
\App\Services\FCMService::sendPushNotification(
$request->fcm_token,
'Successful Login',
'Welcome back to your Migrant sponsor account.'
);
}
]);
return response()->json([
'success' => true,
'message' => 'Login successful.',
'data' => [
'sponsor' => [
'id' => $sponsor->id,
'full_name' => $sponsor->full_name,
'organization_name' => $sponsor->organization_name,
'mobile' => $sponsor->mobile,
'email' => $sponsor->email,
'nationality' => $sponsor->nationality,
'city' => $sponsor->city,
'address' => $sponsor->address,
'country_code' => $sponsor->country_code,
'is_verified' => $sponsor->is_verified,
'status' => $sponsor->status,
'license_file' => $sponsor->license_file ? asset($sponsor->license_file) : null,
'emirates_id_file' => $sponsor->emirates_id_file ? asset($sponsor->emirates_id_file) : null,
'license_expiry' => $sponsor->license_expiry ? $sponsor->license_expiry->toDateString() : null,
'license_number' => $sponsor->license_number,
'license' => $sponsor->license_data,
'validity' => $sponsor->license_expiry ? ($sponsor->license_expiry->isFuture() ? 'Valid' : 'Expired') : 'Pending Review',
'joined_at' => $sponsor->created_at->toIso8601String(),
'emirates_id' => [
'emirates_id_number' => $sponsor->emirates_id,
'name' => $sponsor->emirates_id_name,
'date_of_birth' => $sponsor->emirates_id_dob,
'issue_date' => $sponsor->emirates_id_issue_date,
'expiry_date' => $sponsor->emirates_id_expiry,
'employer' => $sponsor->emirates_id_employer,
'issue_place' => $sponsor->emirates_id_issue_place,
'occupation' => $sponsor->emirates_id_occupation,
'nationality' => $sponsor->emirates_id_nationality,
'gender' => $sponsor->emirates_id_gender,
'card_number' => $sponsor->emirates_id_card_number,
'country' => 'United Arab Emirates',
],
],
'sponsor' => $sponsor->makeHidden(['password', 'api_token']),
'token' => $apiToken,
]
], 200);
}
/**
* Upload Sponsor's License document.
* POST /api/sponsors/register/license
*/
public function uploadLicense(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|string|email|max:255',
'license_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240',
], [
'email.required' => 'The email address is required.',
'license_file.required' => 'Please upload the organization or trade license.',
'license_file.mimes' => 'The license file must be an image or a PDF.',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
try {
$sponsor = Sponsor::where('email', $request->email)->first();
if (!$sponsor) {
return response()->json([
'success' => false,
'message' => 'Sponsor account not found.'
], 404);
}
$licenseFile = $request->file('license_file');
$ocrLicense = \App\Services\OcrDocumentService::extractSponsorLicenseData($licenseFile);
$extractedExpiry = $ocrLicense['expiry_date'];
$extractedNumber = $ocrLicense['license_number'];
$sponsor->update([
'license_file' => null,
'license_expiry' => $extractedExpiry,
'license_number' => $extractedNumber,
'license_data' => $ocrLicense,
]);
return response()->json([
'success' => true,
'message' => 'License uploaded and processed successfully.',
'ocr_extracted_data' => $ocrLicense,
'email' => $request->email,
], 200);
} catch (\Exception $e) {
logger()->error('API Sponsor License Upload Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while uploading the license.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
// -------------------------------------------------------------------------
// Forgot / Reset Password
// -------------------------------------------------------------------------
/**
* POST /api/sponsors/forgot-password
* Send a 6-digit OTP to the sponsor's registered email.
* Accepts: email OR mobile
*/
public function forgotPassword(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'nullable|email',
'mobile' => 'nullable|string',
]);
$validator->after(function ($v) use ($request) {
if (!$request->filled('email') && !$request->filled('mobile')) {
$v->errors()->add('email', 'Either email or mobile number is required.');
}
});
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors(),
], 422);
}
$sponsor = null;
if ($request->filled('email')) {
$sponsor = Sponsor::where('email', $request->email)->first();
} elseif ($request->filled('mobile')) {
$sponsor = Sponsor::where('mobile', $request->mobile)->first();
}
if (!$sponsor || !$sponsor->email) {
return response()->json([
'success' => false,
'message' => 'user not found this email id',
], 404);
}
$otp = (string) mt_rand(100000, 999999);
$cacheKey = 'sponsor_pwd_reset_' . md5($sponsor->email);
\Illuminate\Support\Facades\Cache::put($cacheKey, [
'otp' => Hash::make($otp),
'expires_at' => now()->addMinutes(10)->timestamp,
], now()->addMinutes(10));
try {
\Illuminate\Support\Facades\Mail::to($sponsor->email)->send(
new \App\Mail\ForgotPasswordOtpMail($otp, $sponsor->full_name, 'Sponsor')
);
} catch (\Exception $e) {
logger()->error('Sponsor forgot-password mail failed: ' . $e->getMessage());
}
return response()->json([
'success' => true,
'message' => 'If an account with that contact exists, a reset OTP has been sent.',
]);
}
/**
* POST /api/sponsors/reset-password
* Verify OTP and set new password.
*/
public function resetPassword(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'otp' => 'required|string|size:6',
'password' => 'required|string|min:8|confirmed',
'password_confirmation' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors(),
], 422);
}
$cacheKey = 'sponsor_pwd_reset_' . md5($request->email);
$cached = \Illuminate\Support\Facades\Cache::get($cacheKey);
if (!$cached || $cached['expires_at'] < now()->timestamp) {
\Illuminate\Support\Facades\Cache::forget($cacheKey);
return response()->json([
'success' => false,
'message' => 'OTP has expired or is invalid. Please request a new one.',
], 422);
}
if (!Hash::check($request->otp, $cached['otp'])) {
return response()->json([
'success' => false,
'message' => 'Invalid OTP. Please check and try again.',
], 422);
}
$sponsor = Sponsor::where('email', $request->email)->first();
if (!$sponsor) {
return response()->json([
'success' => false,
'message' => 'Account not found.',
], 404);
}
$sponsor->update(['password' => Hash::make($request->password)]);
\Illuminate\Support\Facades\Cache::forget($cacheKey);
return response()->json([
'success' => true,
'message' => 'Password reset successfully. You can now log in with your new password.',
]);
}
/**
* Normalise date format to Y-m-d.
* E.g. "06/06/2025" -> "2025-06-06"
*/
private function normaliseDate(?string $raw): ?string
{
if (empty($raw)) {
return null;
}
$raw = trim($raw);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
return $raw;
}
try {
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) {
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
$month = $m[2];
$year = $m[3];
if (is_numeric($month)) {
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
return "{$year}-{$month}-{$day}";
}
$ts = strtotime("{$day} {$month} {$year}");
return $ts ? date('Y-m-d', $ts) : $raw;
}
$dt = new \DateTime($raw);
return $dt->format('Y-m-d');
} catch (\Exception $e) {
return $raw;
}
}
}

View File

@ -18,33 +18,19 @@ public function getDashboard(Request $request)
{
/** @var Sponsor $sponsor */
$sponsor = $request->attributes->get('sponsor');
$sponsorId = $sponsor ? $sponsor->id : null;
try {
// Recent charity/update announcements for the dashboard preview
$recentEvents = Announcement::where(function ($q) use ($sponsorId) {
$q->where('status', 'approved');
if ($sponsorId) {
$q->orWhere('sponsor_id', $sponsorId);
}
})
$recentEvents = Announcement::where('status', 'approved')
->latest()
->limit(5)
->get()
->map(function ($event) {
$content = $event->body;
if (strpos($event->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($event->body, true);
if ($decoded) {
$content = $decoded['content'] ?? $event->body;
}
}
return [
'id' => $event->id,
'title' => $event->title,
'body' => $content,
'type' => strtolower($event->type),
'status' => $event->status ?? 'pending',
'body' => $event->body,
'type' => $event->type,
'created_at' => $event->created_at->toIso8601String(),
'time_ago' => $event->created_at->diffForHumans(),
];
@ -64,32 +50,10 @@ public function getDashboard(Request $request)
'is_verified' => $sponsor->is_verified,
'status' => $sponsor->status,
'license_file' => $sponsor->license_file ? asset($sponsor->license_file) : null,
'emirates_id_file' => $sponsor->emirates_id_file ? asset($sponsor->emirates_id_file) : null,
'license_expiry' => $sponsor->license_expiry ? $sponsor->license_expiry->toDateString() : null,
'license_number' => $sponsor->license_number,
'license' => $sponsor->license_data,
'validity' => $sponsor->license_expiry ? ($sponsor->license_expiry->isFuture() ? 'Valid' : 'Expired') : 'Pending Review',
'joined_at' => $sponsor->created_at->toIso8601String(),
'emirates_id' => [
'emirates_id_number' => $sponsor->emirates_id,
'name' => $sponsor->emirates_id_name,
'date_of_birth' => $sponsor->emirates_id_dob,
'issue_date' => $sponsor->emirates_id_issue_date,
'expiry_date' => $sponsor->emirates_id_expiry,
'employer' => $sponsor->emirates_id_employer,
'issue_place' => $sponsor->emirates_id_issue_place,
'occupation' => $sponsor->emirates_id_occupation,
'nationality' => $sponsor->emirates_id_nationality,
'gender' => $sponsor->emirates_id_gender,
],
],
'recent_charity_events' => $recentEvents,
'total_events' => Announcement::where(function ($q) use ($sponsorId) {
$q->where('status', 'approved');
if ($sponsorId) {
$q->orWhere('sponsor_id', $sponsorId);
}
})->count(),
'total_events' => Announcement::where('status', 'approved')->count(),
'employer_stats' => [
'total' => \App\Models\User::where('role', 'employer')->count(),
'active' => \App\Models\User::where('role', 'employer')->where('subscription_status', 'active')->count(),
@ -124,25 +88,10 @@ public function getCharityEvents(Request $request)
$perPage = (int) $request->input('per_page', 15);
$type = $request->input('type'); // optional filter: 'charity', 'update', etc.
/** @var Sponsor $sponsor */
$sponsor = $request->attributes->get('sponsor');
$sponsorId = $sponsor ? $sponsor->id : null;
$query = Announcement::with(['employer.employerProfile', 'sponsor'])
->where(function ($q) use ($sponsorId) {
$q->where('status', 'approved');
if ($sponsorId) {
$q->orWhere('sponsor_id', $sponsorId);
}
})
->latest();
$query = Announcement::with(['employer.employerProfile', 'sponsor'])->where('status', 'approved')->latest();
if ($type) {
if (strtolower($type) === 'charity') {
$query->whereIn('type', ['charity', 'Charity']);
} else {
$query->where('type', $type);
}
$query->where('type', $type);
}
$total = $query->count();
@ -161,29 +110,15 @@ public function getCharityEvents(Request $request)
$organization = optional($event->employer)->employerProfile->company_name ?? 'Migrant Support';
}
// Decode charity details if they exist in json
$charityDetails = null;
$content = $event->body;
if (strpos($event->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($event->body, true);
if ($decoded) {
$charityDetails = $decoded;
$content = $decoded['content'] ?? $event->body;
}
}
return [
'id' => $event->id,
'title' => $event->title,
'body' => $content,
'type' => strtolower($event->type),
'status' => $event->status ?? 'pending',
'remarks' => $event->remarks,
'posted_by' => $postedBy,
'organization' => $organization,
'created_at' => $event->created_at->toIso8601String(),
'time_ago' => $event->created_at->diffForHumans(),
'charity_details' => $charityDetails,
'id' => $event->id,
'title' => $event->title,
'body' => $event->body,
'type' => $event->type,
'posted_by' => $postedBy,
'organization' => $organization,
'created_at' => $event->created_at->toIso8601String(),
'time_ago' => $event->created_at->diffForHumans(),
];
});
@ -229,18 +164,9 @@ public function postCharityEvent(Request $request)
}
$validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
'title' => 'required|string|max:255',
'body' => 'required|string',
'type' => 'nullable|string|in:charity,info,warning,success',
'event_date' => 'required|string',
'start_time' => 'required|string',
'end_time' => 'required|string',
'provided_items' => 'required|string',
'location_details' => 'required|string',
'location_pin' => 'required|string|url',
'contact_person_name' => 'required|string|max:255',
'contact_number' => 'required|string|regex:/^\d{7,15}$/',
'country_code' => 'required|string|regex:/^\+\d{1,4}$/',
'title' => 'required|string|max:255',
'body' => 'required|string',
'type' => 'nullable|string|in:charity,info,warning,success',
]);
if ($validator->fails()) {
@ -252,52 +178,24 @@ public function postCharityEvent(Request $request)
}
try {
$eventTime = $request->start_time . ' - ' . $request->end_time;
$bodyJson = json_encode([
'type' => 'Charity',
'provided_items' => $request->provided_items,
'event_date' => $request->event_date,
'event_time' => $eventTime,
'location_details' => $request->location_details,
'location_pin' => $request->location_pin,
'content' => $request->body,
'contact_person_name' => $request->contact_person_name,
'contact_number' => $request->contact_number,
'country_code' => $request->country_code,
]);
$event = Announcement::create([
'title' => $request->title,
'body' => $bodyJson,
'body' => $request->body,
'type' => $request->type ?? 'charity',
'sponsor_id' => $sponsor->id,
'status' => 'pending',
]);
// Decode charity details for the response representation
$charityDetails = null;
$content = $event->body;
if (strpos($event->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($event->body, true);
if ($decoded) {
$charityDetails = $decoded;
$content = $decoded['content'] ?? $event->body;
}
}
return response()->json([
'success' => true,
'message' => 'Charity event posted successfully.',
'data' => [
'id' => $event->id,
'title' => $event->title,
'body' => $content,
'type' => strtolower($event->type),
'posted_by' => $sponsor->full_name,
'organization' => $sponsor->organization_name,
'created_at' => $event->created_at->toIso8601String(),
'charity_details' => $charityDetails,
'id' => $event->id,
'title' => $event->title,
'body' => $event->body,
'type' => $event->type,
'posted_by' => $sponsor->full_name,
'organization' => $sponsor->organization_name,
'created_at' => $event->created_at->toIso8601String(),
]
], 201);
@ -338,477 +236,9 @@ public function getProfile(Request $request)
'is_verified' => $sponsor->is_verified,
'status' => $sponsor->status,
'license_file' => $sponsor->license_file ? asset($sponsor->license_file) : null,
'emirates_id_file' => $sponsor->emirates_id_file ? asset($sponsor->emirates_id_file) : null,
'license_expiry' => $sponsor->license_expiry ? $sponsor->license_expiry->toDateString() : null,
'license_number' => $sponsor->license_number,
'license' => $sponsor->license_data,
'validity' => $sponsor->license_expiry ? ($sponsor->license_expiry->isFuture() ? 'Valid' : 'Expired') : 'Pending Review',
'joined_at' => $sponsor->created_at->toIso8601String(),
'emirates_id' => [
'emirates_id_number' => $sponsor->emirates_id,
'name' => $sponsor->emirates_id_name,
'date_of_birth' => $sponsor->emirates_id_dob,
'issue_date' => $sponsor->emirates_id_issue_date,
'expiry_date' => $sponsor->emirates_id_expiry,
'employer' => $sponsor->emirates_id_employer,
'issue_place' => $sponsor->emirates_id_issue_place,
'occupation' => $sponsor->emirates_id_occupation,
'nationality' => $sponsor->emirates_id_nationality,
'gender' => $sponsor->emirates_id_gender,
'card_number' => $sponsor->emirates_id_card_number,
'country' => 'United Arab Emirates',
],
]
]
], 200);
}
/**
* Build the standardized sponsor response array with nested license and emirates_id objects.
*/
private function buildSponsorResponse(Sponsor $sponsor): array
{
$licenseData = [
'license_number' => $sponsor->license_data['license_no'] ?? $sponsor->license_data['license_number'] ?? null,
'organization_name' => $sponsor->organization_name,
'expiry_date' => $sponsor->license_expiry ? $sponsor->license_expiry->toDateString() : ($sponsor->license_data['expiry_date'] ?? null),
'document_type' => $sponsor->license_data['document_type'] ?? null,
'country' => $sponsor->license_data['country'] ?? null,
'authority' => $sponsor->license_data['authority'] ?? null,
'license_no' => $sponsor->license_data['license_no'] ?? null,
'company_name' => $sponsor->license_data['company_name'] ?? null,
'business_name' => $sponsor->license_data['business_name'] ?? null,
'license_category' => $sponsor->license_data['license_category'] ?? null,
'legal_type' => $sponsor->license_data['legal_type'] ?? null,
'issue_date' => $sponsor->license_data['issue_date'] ?? null,
'main_license_no' => $sponsor->license_data['main_license_no'] ?? null,
'register_no' => $sponsor->license_data['register_no'] ?? null,
'dcci_no' => $sponsor->license_data['dcci_no'] ?? null,
'members' => $sponsor->license_data['members'] ?? [],
];
$emiratesIdData = null;
if ($sponsor->emirates_id) {
$emiratesIdData = [
'emirates_id_number' => $sponsor->emirates_id,
'name' => $sponsor->emirates_id_name,
'nationality' => $sponsor->emirates_id_nationality,
'date_of_birth' => $sponsor->emirates_id_dob,
'expiry_date' => $sponsor->emirates_id_expiry,
'issue_date' => $sponsor->emirates_id_issue_date,
'employer' => $sponsor->emirates_id_employer,
'issue_place' => $sponsor->emirates_id_issue_place,
'occupation' => $sponsor->emirates_id_occupation,
'card_number' => $sponsor->emirates_id_card_number,
'gender' => $sponsor->emirates_id_gender,
'country' => 'United Arab Emirates',
];
}
return [
'id' => $sponsor->id,
'full_name' => $sponsor->full_name,
'organization_name' => $sponsor->organization_name,
'mobile' => $sponsor->mobile,
'email' => $sponsor->email,
'nationality' => $sponsor->nationality,
'city' => $sponsor->city,
'address' => $sponsor->address,
'country_code' => $sponsor->country_code,
'is_verified' => $sponsor->is_verified,
'status' => $sponsor->status,
'license_file' => $sponsor->license_file ? asset($sponsor->license_file) : null,
'emirates_id_file' => $sponsor->emirates_id_file ? asset($sponsor->emirates_id_file) : null,
'license_expiry' => $sponsor->license_expiry ? $sponsor->license_expiry->toDateString() : null,
'validity' => $sponsor->license_expiry ? ($sponsor->license_expiry->isFuture() ? 'Valid' : 'Expired') : 'Pending Review',
'joined_at' => $sponsor->created_at->toIso8601String(),
'fcm_token' => $sponsor->fcm_token,
'license' => $licenseData,
'emirates_id' => $emiratesIdData,
];
}
/**
* Change sponsor's password.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function changePassword(Request $request)
{
/** @var Sponsor $sponsor */
$sponsor = $request->attributes->get('sponsor');
$validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
'current_password' => 'required|string',
'new_password' => 'required|string|min:8|confirmed',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
if (!\Illuminate\Support\Facades\Hash::check($request->current_password, $sponsor->password)) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => [
'current_password' => ['The current password is incorrect.']
]
], 422);
}
try {
$hashedPassword = \Illuminate\Support\Facades\Hash::make($request->new_password);
// Update sponsor table
$sponsor->update([
'password' => $hashedPassword
]);
// Sync with corresponding employer user record if found
$matchingEmployer = \App\Models\User::where('email', $sponsor->email)
->where('role', 'employer')
->first();
if ($matchingEmployer) {
$matchingEmployer->update([
'password' => $hashedPassword
]);
}
return response()->json([
'success' => true,
'message' => 'Password changed successfully.'
], 200);
} catch (\Exception $e) {
logger()->error('Mobile Sponsor Change Password Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while changing password.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* POST /api/sponsors/profile/update
*
* Updates the authenticated sponsor's profile.
*/
public function updateProfile(Request $request)
{
/** @var Sponsor $sponsor */
$sponsor = $request->attributes->get('sponsor');
if (!$sponsor) {
return response()->json([
'success' => false,
'message' => 'Unauthorized.'
], 401);
}
// Find matching employer user using sponsor's original email
$oldEmail = $sponsor->getOriginal('email') ?? $sponsor->email;
$matchingUser = \App\Models\User::where('email', $oldEmail)
->where('role', 'employer')
->first();
$matchingUserId = $matchingUser ? $matchingUser->id : 'NULL';
$validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
'name' => 'required_without:full_name|nullable|string|max:255',
'full_name' => 'required_without:name|nullable|string|max:255',
'email' => 'required|email|max:255|unique:sponsors,email,' . $sponsor->id . '|unique:users,email,' . $matchingUserId,
'mobile' => 'required|string|max:255|unique:sponsors,mobile,' . $sponsor->id,
'organization_name' => 'required|string|max:255',
'city' => 'required|string|max:100',
'address' => 'required|string|max:255',
'nationality' => 'required|string|max:100',
'country_code' => 'required|string|max:10',
'fcm_token' => 'nullable|string|max:255',
// Emirates ID — accepts nested object OR flat fields
'emirates_id' => 'nullable|array',
'emirates_id.emirates_id_number' => 'nullable|string|max:255',
'emirates_id.name' => 'nullable|string|max:255',
'emirates_id.nationality' => 'nullable|string|max:255',
'emirates_id.date_of_birth' => 'nullable|string|max:255',
'emirates_id.expiry_date' => 'nullable|string|max:255',
'emirates_id.issue_date' => 'nullable|string|max:255',
'emirates_id.employer' => 'nullable|string|max:255',
'emirates_id.issue_place' => 'nullable|string|max:255',
'emirates_id.occupation' => 'nullable|string|max:255',
// Flat emirates ID fields (backward compatibility)
'id_number' => 'nullable|string|max:255',
'card_number' => 'nullable|string|max:255',
'date_of_birth' => 'nullable|string|max:255',
'gender' => 'nullable|string|max:255',
'issue_date' => 'nullable|string|max:255',
'expiry_date' => 'nullable|string|max:255',
'occupation' => 'nullable|string|max:255',
'employer' => 'nullable|string|max:255',
'issuing_place' => 'nullable|string|max:255',
// License — accepts nested object OR flat fields
'license' => 'nullable|array',
'license.document_type' => 'nullable|string|max:255',
'license.country' => 'nullable|string|max:255',
'license.authority' => 'nullable|string|max:255',
'license.license_no' => 'nullable|string|max:255',
'license.company_name' => 'nullable|string|max:255',
'license.business_name' => 'nullable|string|max:255',
'license.license_category' => 'nullable|string|max:255',
'license.legal_type' => 'nullable|string|max:255',
'license.main_license_no' => 'nullable|string|max:255',
'license.register_no' => 'nullable|string|max:255',
'license.dcci_no' => 'nullable|string|max:255',
'license.members' => 'nullable|array',
// Flat license fields (backward compatibility)
'document_type' => 'nullable|string|max:255',
'country' => 'nullable|string|max:255',
'authority' => 'nullable|string|max:255',
'license_no' => 'nullable|string|max:255',
'company_name' => 'nullable|string|max:255',
'business_name' => 'nullable|string|max:255',
'license_category' => 'nullable|string|max:255',
'legal_type' => 'nullable|string|max:255',
'main_license_no' => 'nullable|string|max:255',
'register_no' => 'nullable|string|max:255',
'dcci_no' => 'nullable|string|max:255',
'members' => 'nullable|array',
]);
$validator->after(function ($validator) use ($request) {
$nameFromEid = $request->input('emirates_id.name') ?? $request->input('full_name');
if ($request->has('full_name') && !$request->filled('full_name') && !$nameFromEid) {
$validator->errors()->add('full_name', 'Failed to extract name from the Emirates ID. Please upload a clearer document.');
}
});
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
try {
// Resolve nested emirates_id input into flat variables
$eidInput = $request->input('emirates_id');
$eidNumber = null;
$eidName = null;
$eidDob = null;
$eidExpiry = null;
$eidIssueDate = null;
$eidEmployer = null;
$eidIssuePlace = null;
$eidOccupation = null;
$eidCardNumber = null;
$eidGender = null;
if (is_array($eidInput)) {
$eidNumber = $eidInput['emirates_id_number'] ?? $eidInput['id_number'] ?? null;
$eidName = $eidInput['name'] ?? null;
$eidDob = $eidInput['date_of_birth'] ?? $eidInput['dob'] ?? null;
$eidExpiry = $eidInput['expiry_date'] ?? null;
$eidIssueDate = $eidInput['issue_date'] ?? null;
$eidEmployer = $eidInput['employer'] ?? null;
$eidIssuePlace = $eidInput['issue_place'] ?? $eidInput['issuing_place'] ?? null;
$eidOccupation = $eidInput['occupation'] ?? null;
$eidCardNumber = $eidInput['card_number'] ?? null;
$eidGender = $eidInput['gender'] ?? null;
}
// Flat field fallbacks (backward compatibility)
$eidNumber = $eidNumber ?? $request->input('id_number');
$eidName = $eidName ?? $request->input('full_name');
$eidDob = $eidDob ?? $request->input('date_of_birth');
$eidExpiry = $eidExpiry ?? $request->input('expiry_date');
$eidIssueDate = $eidIssueDate ?? $request->input('issue_date');
$eidEmployer = $eidEmployer ?? $request->input('employer');
$eidIssuePlace = $eidIssuePlace ?? $request->input('issuing_place');
$eidOccupation = $eidOccupation ?? $request->input('occupation');
$eidCardNumber = $eidCardNumber ?? $request->input('card_number');
$eidGender = $eidGender ?? $request->input('gender');
// Normalize dates
$eidDob = $this->normaliseDate($eidDob);
$eidExpiry = $this->normaliseDate($eidExpiry);
$eidIssueDate = $this->normaliseDate($eidIssueDate);
$nameToUse = $eidName ?? $request->name ?? $request->full_name;
// Validate Emirates ID immutability — once set, it cannot be changed
if ($eidNumber) {
if ($sponsor->emirates_id && $sponsor->emirates_id !== $eidNumber) {
return response()->json([
'success' => false,
'message' => 'Emirates ID mismatch. You cannot change your registered Emirates ID number.',
'errors' => [
'id_number' => ['Emirates ID mismatch.']
]
], 422);
}
}
// Sync with corresponding employer user and profile records if found
if ($matchingUser) {
$userData = [
'name' => $nameToUse,
'email' => $request->email,
];
if ($request->has('fcm_token')) {
$userData['fcm_token'] = $request->fcm_token;
}
$matchingUser->update($userData);
$profile = $matchingUser->employerProfile;
if (!$profile) {
$profile = new \App\Models\EmployerProfile(['user_id' => $matchingUser->id]);
}
$profile->address = $request->address;
$profile->phone = $request->mobile;
if ($eidNumber) { $profile->emirates_id_number = $eidNumber; }
if ($eidCardNumber) { $profile->emirates_id_card_number = $eidCardNumber; }
if ($eidName) { $profile->emirates_id_name = $eidName; }
if ($eidDob) { $profile->emirates_id_dob = $eidDob; }
if ($request->has('nationality')) { $profile->nationality = $request->nationality; }
if ($eidGender) { $profile->emirates_id_gender = $eidGender; }
if ($eidIssueDate) { $profile->emirates_id_issue_date = $eidIssueDate; }
if ($eidExpiry) { $profile->emirates_id_expiry = $eidExpiry; }
if ($eidOccupation) { $profile->emirates_id_occupation = $eidOccupation; }
if ($eidEmployer) { $profile->emirates_id_employer = $eidEmployer; }
if ($eidIssuePlace) { $profile->emirates_id_issue_place = $eidIssuePlace; }
$profile->save();
}
// Update Sponsor profile
$sponsorData = [
'full_name' => $nameToUse,
'email' => $request->email,
'mobile' => $request->mobile,
'organization_name' => $request->organization_name,
'city' => $request->city,
'address' => $request->address,
'nationality' => $request->nationality,
'country_code' => $request->country_code,
];
if ($request->has('fcm_token')) {
$sponsorData['fcm_token'] = $request->fcm_token;
}
if ($eidNumber) { $sponsorData['emirates_id'] = $eidNumber; }
if ($eidCardNumber) { $sponsorData['emirates_id_card_number'] = $eidCardNumber; }
if ($eidName) { $sponsorData['emirates_id_name'] = $eidName; }
if ($eidDob) { $sponsorData['emirates_id_dob'] = $eidDob; }
if ($eidGender) { $sponsorData['emirates_id_gender'] = $eidGender; }
if ($eidIssueDate) { $sponsorData['emirates_id_issue_date'] = $eidIssueDate; }
if ($eidExpiry) { $sponsorData['emirates_id_expiry'] = $eidExpiry; }
if ($eidOccupation) { $sponsorData['emirates_id_occupation'] = $eidOccupation; }
if ($eidEmployer) { $sponsorData['emirates_id_employer'] = $eidEmployer; }
if ($eidIssuePlace) { $sponsorData['emirates_id_issue_place'] = $eidIssuePlace; }
// Parse and merge license data — from nested license object or flat fields
$currentLicenseData = is_array($sponsor->license_data) ? $sponsor->license_data : [];
$licenseInput = $request->input('license');
$newLicenseData = [];
if (is_array($licenseInput)) {
foreach ([
'document_type', 'country', 'authority', 'license_no', 'company_name',
'business_name', 'license_category', 'legal_type', 'issue_date', 'expiry_date',
'main_license_no', 'register_no', 'dcci_no', 'members'
] as $field) {
if (isset($licenseInput[$field])) {
$newLicenseData[$field] = $licenseInput[$field];
}
}
}
// Flat field fallbacks (backward compatibility)
foreach ([
'document_type', 'country', 'authority', 'license_no', 'company_name',
'business_name', 'license_category', 'legal_type', 'issue_date', 'expiry_date',
'main_license_no', 'register_no', 'dcci_no', 'members'
] as $field) {
if (!isset($newLicenseData[$field]) && $request->has($field)) {
$newLicenseData[$field] = $request->input($field);
}
}
if (!empty($newLicenseData)) {
if (isset($newLicenseData['expiry_date'])) {
$newLicenseData['expiry_date'] = $this->normaliseDate($newLicenseData['expiry_date']);
$sponsorData['license_expiry'] = $newLicenseData['expiry_date'];
}
if (isset($newLicenseData['issue_date'])) {
$newLicenseData['issue_date'] = $this->normaliseDate($newLicenseData['issue_date']);
}
$sponsorData['license_data'] = array_merge($currentLicenseData, $newLicenseData);
}
$sponsor->update($sponsorData);
return response()->json([
'success' => true,
'message' => 'Profile updated successfully.',
'data' => [
'sponsor' => $this->buildSponsorResponse($sponsor)
]
], 200);
} catch (\Exception $e) {
logger()->error('Mobile Sponsor Update Profile Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while updating profile.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* Normalise date format to Y-m-d.
* E.g. "06/06/2025" -> "2025-06-06"
*/
private function normaliseDate(?string $raw): ?string
{
if (empty($raw)) {
return null;
}
$raw = trim($raw);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
return $raw;
}
try {
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) {
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
$month = $m[2];
$year = $m[3];
if (is_numeric($month)) {
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
return "{$year}-{$month}-{$day}";
}
$ts = strtotime("{$day} {$month} {$year}");
return $ts ? date('Y-m-d', $ts) : $raw;
}
$dt = new \DateTime($raw);
return $dt->format('Y-m-d');
} catch (\Exception $e) {
return $raw;
}
}
}

View File

@ -22,7 +22,7 @@ public function getTicketsForWorker(Request $request)
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401);
}
$tickets = SupportTicket::with('reason')->where('worker_id', $worker->id)
$tickets = SupportTicket::where('worker_id', $worker->id)
->orderBy('created_at', 'desc')
->get()
->map(function ($ticket) {
@ -31,9 +31,6 @@ public function getTicketsForWorker(Request $request)
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'description' => $ticket->description,
'reason_id' => $ticket->reason_id,
'reason_name' => $ticket->reason ? $ticket->reason->reason : null,
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->toIso8601String(),
@ -57,8 +54,6 @@ public function createTicketFromWorker(Request $request)
'subject' => 'required|string|max:255',
'description' => 'required|string',
'priority' => 'nullable|string|in:low,medium,high',
'reason_id' => 'nullable|exists:report_reasons,id',
'voice_note' => 'nullable|file|mimes:mp3,wav,m4a,ogg,webm,aac,3gp,amr|max:10240', // 10MB limit
]);
if ($validator->fails()) {
@ -69,24 +64,15 @@ public function createTicketFromWorker(Request $request)
], 422);
}
$voiceNotePath = null;
if ($request->hasFile('voice_note')) {
$voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public');
}
$ticket = SupportTicket::create([
'ticket_number' => 'TKT-' . rand(100000, 999999),
'worker_id' => $worker->id,
'reason_id' => $request->reason_id,
'subject' => $request->subject,
'description' => $request->description,
'voice_note_path' => $voiceNotePath,
'priority' => $request->priority ?? 'medium',
'status' => 'open',
]);
$ticket->load('reason');
return response()->json([
'success' => true,
'message' => 'Ticket created successfully.',
@ -95,9 +81,6 @@ public function createTicketFromWorker(Request $request)
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'description' => $ticket->description,
'reason_id' => $ticket->reason_id,
'reason_name' => $ticket->reason ? $ticket->reason->reason : null,
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->toIso8601String(),
@ -122,8 +105,7 @@ public function replyToTicketFromWorker(Request $request, $id)
}
$validator = Validator::make($request->all(), [
'message' => 'required_without:voice_note|nullable|string',
'voice_note' => 'nullable|file|mimes:mp3,wav,m4a,ogg,webm,aac,3gp,amr|max:10240', // 10MB limit
'message' => 'required|string',
]);
if ($validator->fails()) {
@ -134,16 +116,10 @@ public function replyToTicketFromWorker(Request $request, $id)
], 422);
}
$voiceNotePath = null;
if ($request->hasFile('voice_note')) {
$voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public');
}
$reply = SupportTicketReply::create([
'support_ticket_id' => $ticket->id,
'worker_id' => $worker->id,
'message' => $request->message,
'voice_note_path' => $voiceNotePath,
]);
if ($ticket->status === 'resolved') {
@ -157,7 +133,6 @@ public function replyToTicketFromWorker(Request $request, $id)
'id' => $reply->id,
'message' => $reply->message,
'sender_name' => $reply->sender_name,
'voice_note_url' => $reply->voice_note_path ? asset('storage/' . $reply->voice_note_path) : null,
'created_at' => $reply->created_at->toIso8601String(),
]
], 201);
@ -175,7 +150,7 @@ public function getTicketsForEmployer(Request $request)
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401);
}
$tickets = SupportTicket::with('reason')->where('user_id', $employer->id)
$tickets = SupportTicket::where('user_id', $employer->id)
->orderBy('created_at', 'desc')
->get()
->map(function ($ticket) {
@ -184,9 +159,6 @@ public function getTicketsForEmployer(Request $request)
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'description' => $ticket->description,
'reason_id' => $ticket->reason_id,
'reason_name' => $ticket->reason ? $ticket->reason->reason : null,
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->toIso8601String(),
@ -210,8 +182,6 @@ public function createTicketFromEmployer(Request $request)
'subject' => 'required|string|max:255',
'description' => 'required|string',
'priority' => 'nullable|string|in:low,medium,high',
'reason_id' => 'nullable|exists:report_reasons,id',
'voice_note' => 'nullable|file|mimes:mp3,wav,m4a,ogg,webm,aac,3gp,amr|max:10240', // 10MB limit
]);
if ($validator->fails()) {
@ -222,24 +192,15 @@ public function createTicketFromEmployer(Request $request)
], 422);
}
$voiceNotePath = null;
if ($request->hasFile('voice_note')) {
$voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public');
}
$ticket = SupportTicket::create([
'ticket_number' => 'TKT-' . rand(100000, 999999),
'user_id' => $employer->id,
'reason_id' => $request->reason_id,
'subject' => $request->subject,
'description' => $request->description,
'voice_note_path' => $voiceNotePath,
'priority' => $request->priority ?? 'medium',
'status' => 'open',
]);
$ticket->load('reason');
return response()->json([
'success' => true,
'message' => 'Ticket created successfully.',
@ -248,9 +209,6 @@ public function createTicketFromEmployer(Request $request)
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'description' => $ticket->description,
'reason_id' => $ticket->reason_id,
'reason_name' => $ticket->reason ? $ticket->reason->reason : null,
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->toIso8601String(),
@ -275,8 +233,7 @@ public function replyToTicketFromEmployer(Request $request, $id)
}
$validator = Validator::make($request->all(), [
'message' => 'required_without:voice_note|nullable|string',
'voice_note' => 'nullable|file|mimes:mp3,wav,m4a,ogg,webm,aac,3gp,amr|max:10240', // 10MB limit
'message' => 'required|string',
]);
if ($validator->fails()) {
@ -287,16 +244,10 @@ public function replyToTicketFromEmployer(Request $request, $id)
], 422);
}
$voiceNotePath = null;
if ($request->hasFile('voice_note')) {
$voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public');
}
$reply = SupportTicketReply::create([
'support_ticket_id' => $ticket->id,
'user_id' => $employer->id,
'message' => $request->message,
'voice_note_path' => $voiceNotePath,
]);
if ($ticket->status === 'resolved') {
@ -310,7 +261,6 @@ public function replyToTicketFromEmployer(Request $request, $id)
'id' => $reply->id,
'message' => $reply->message,
'sender_name' => $reply->sender_name,
'voice_note_url' => $reply->voice_note_path ? asset('storage/' . $reply->voice_note_path) : null,
'created_at' => $reply->created_at->toIso8601String(),
]
], 201);
@ -330,7 +280,7 @@ public function getTicketDetail(Request $request, $id)
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401);
}
$query = SupportTicket::with('reason')->where('id', $id);
$query = SupportTicket::where('id', $id);
if ($worker) {
$query->where('worker_id', $worker->id);
} else {
@ -353,7 +303,6 @@ public function getTicketDetail(Request $request, $id)
'sender_name' => $reply->sender_name,
'is_admin' => $reply->user && $reply->user->role === 'admin',
'is_developer_response' => (bool)$reply->is_developer_response,
'voice_note_url' => $reply->voice_note_path ? asset('storage/' . $reply->voice_note_path) : null,
'created_at' => $reply->created_at->toIso8601String(),
];
});
@ -365,9 +314,6 @@ public function getTicketDetail(Request $request, $id)
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'description' => $ticket->description,
'reason_id' => $ticket->reason_id,
'reason_name' => $ticket->reason ? $ticket->reason->reason : null,
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->toIso8601String(),

View File

@ -4,7 +4,6 @@
use App\Http\Controllers\Controller;
use App\Models\Announcement;
use App\Models\AnnouncementView;
use Illuminate\Http\Request;
class WorkerAnnouncementController extends Controller
@ -21,44 +20,21 @@ public function getAnnouncements(Request $request)
$page = (int)$request->input('page', 1);
$perPage = (int)$request->input('per_page', 15);
$query = Announcement::with(['employer.employerProfile', 'sponsor'])->where('status', 'approved')->latest();
$query = Announcement::with('employer.employerProfile')->where('status', 'approved')->latest();
$total = $query->count();
$offset = ($page - 1) * $perPage;
$announcements = $query->skip($offset)->take($perPage)->get()
->map(function ($announcement) {
$postedBy = 'System';
$organization = 'Migrant Support';
if ($announcement->sponsor_id) {
$postedBy = $announcement->sponsor->full_name;
$organization = $announcement->sponsor->organization_name;
} elseif ($announcement->employer_id) {
$postedBy = $announcement->employer->name;
$organization = $announcement->employer->employerProfile->company_name ?? 'Employer';
}
// Decode charity details if they exist in json
$charityDetails = null;
$content = $announcement->body;
if (strpos($announcement->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($announcement->body, true);
if ($decoded) {
$charityDetails = $decoded;
$content = $decoded['content'] ?? $announcement->body;
}
}
return [
'id' => $announcement->id,
'title' => $announcement->title,
'body' => $content,
'body' => $announcement->body,
'type' => $announcement->type,
'employer_name' => $postedBy,
'company_name' => $organization,
'employer_name' => $announcement->employer->name ?? 'System',
'company_name' => $announcement->employer->employerProfile->company_name ?? 'Migrant Support',
'created_at' => $announcement->created_at->toISOString(),
'time_ago' => $announcement->created_at->diffForHumans(),
'charity_details' => $charityDetails,
];
});
@ -85,114 +61,4 @@ public function getAnnouncements(Request $request)
], 500);
}
}
/**
* Get up to 3 unviewed announcements for the worker, and mark them as viewed.
*/
public function getNewAnnouncements(Request $request)
{
/** @var \App\Models\Worker $worker */
$worker = $request->attributes->get('worker');
try {
$viewedIds = AnnouncementView::where('worker_id', $worker->id)->pluck('announcement_id');
$dbAnnouncements = Announcement::with(['employer.employerProfile', 'sponsor'])
->where('status', 'approved')
->whereNotIn('id', $viewedIds)
->latest('id')
->limit(3)
->get();
// Mark them as viewed
foreach ($dbAnnouncements as $announcement) {
AnnouncementView::firstOrCreate([
'worker_id' => $worker->id,
'announcement_id' => $announcement->id,
]);
}
$announcements = $dbAnnouncements->map(function ($announcement) {
$postedBy = 'System';
$organization = 'Migrant Support';
if ($announcement->sponsor_id) {
$postedBy = $announcement->sponsor->full_name;
$organization = $announcement->sponsor->organization_name;
} elseif ($announcement->employer_id) {
$postedBy = $announcement->employer->name;
$organization = $announcement->employer->employerProfile->company_name ?? 'Employer';
}
// Decode charity details if they exist in json
$charityDetails = null;
$content = $announcement->body;
if (strpos($announcement->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($announcement->body, true);
if ($decoded) {
$charityDetails = $decoded;
$content = $decoded['content'] ?? $announcement->body;
}
}
return [
'id' => $announcement->id,
'title' => $announcement->title,
'body' => $content,
'type' => $announcement->type,
'employer_name' => $postedBy,
'company_name' => $organization,
'created_at' => $announcement->created_at->toISOString(),
'time_ago' => $announcement->created_at->diffForHumans(),
'charity_details' => $charityDetails,
];
});
return response()->json([
'success' => true,
'data' => [
'announcements' => $announcements,
]
], 200);
} catch (\Exception $e) {
logger()->error('Mobile Worker Get New Announcements Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while fetching new announcements.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* Explicitly mark an announcement as viewed by the worker.
*/
public function markAnnouncementViewed(Request $request, $id)
{
/** @var \App\Models\Worker $worker */
$worker = $request->attributes->get('worker');
try {
AnnouncementView::firstOrCreate([
'worker_id' => $worker->id,
'announcement_id' => $id,
]);
return response()->json([
'success' => true,
'message' => 'Announcement marked as viewed.'
], 200);
} catch (\Exception $e) {
logger()->error('Mobile Worker Mark Announcement Viewed Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -271,33 +271,6 @@ public function sendMessage(Request $request, $id)
}
});
// Dispatch push notification to employer (User and/or Sponsor device)
$employer = $conv->employer;
$tokens = [];
if ($employer) {
if ($employer->fcm_token) {
$tokens[] = $employer->fcm_token;
}
$sponsor = \App\Models\Sponsor::where('email', $employer->email)->first();
if ($sponsor && $sponsor->fcm_token) {
$tokens[] = $sponsor->fcm_token;
}
}
$tokens = array_unique(array_filter($tokens));
foreach ($tokens as $token) {
\App\Services\FCMService::sendPushNotification(
$token,
"New Message from " . ($worker->name ?? "Worker"),
$request->text ?: "Sent an attachment",
[
'conversation_id' => $conv->id,
'sender_type' => 'worker',
'sender_id' => $worker->id,
]
);
}
return response()->json([
'success' => true,
'message' => 'Message sent successfully.',

View File

@ -1,113 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Worker;
class WorkerNotificationController extends Controller
{
/**
* List all notifications for the worker.
* GET /api/workers/notifications
*/
public function index(Request $request)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
$filter = $request->query('filter', 'all');
$perPage = (int) $request->query('per_page', 15);
if ($filter === 'read') {
$query = $worker->readNotifications();
} elseif ($filter === 'unread') {
$query = $worker->unreadNotifications();
} else {
$query = $worker->notifications();
}
$paginator = $query->paginate($perPage);
$notifications = collect($paginator->items())->map(function ($notification) {
return [
'id' => $notification->id,
'type' => $notification->type,
'data' => $notification->data,
'read_at' => $notification->read_at ? $notification->read_at->toIso8601String() : null,
'created_at' => $notification->created_at->toIso8601String(),
'time_ago' => $notification->created_at->diffForHumans(),
];
});
return response()->json([
'success' => true,
'data' => [
'notifications' => $notifications,
'pagination' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
]
]
], 200);
}
/**
* Mark a single notification or all unread notifications as read.
* PUT /api/workers/notifications/{id}/read or PUT /api/workers/notifications/read
*/
public function markAsRead(Request $request, $id = null)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
if ($id) {
$notification = $worker->notifications()->where('id', $id)->first();
if (!$notification) {
return response()->json([
'success' => false,
'message' => 'Notification not found.'
], 404);
}
$notification->markAsRead();
} else {
$worker->unreadNotifications->markAsRead();
}
return response()->json([
'success' => true,
'message' => 'Notification(s) marked as read successfully.'
], 200);
}
/**
* Delete a single notification or all notifications.
* DELETE /api/workers/notifications/{id} or DELETE /api/workers/notifications
*/
public function destroy(Request $request, $id = null)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
if ($id) {
$notification = $worker->notifications()->where('id', $id)->first();
if (!$notification) {
return response()->json([
'success' => false,
'message' => 'Notification not found.'
], 404);
}
$notification->delete();
} else {
$worker->notifications()->delete();
}
return response()->json([
'success' => true,
'message' => 'Notification(s) deleted successfully.'
], 200);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,341 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Worker;
use App\Models\User;
use App\Models\JobPost;
use App\Models\JobApplication;
use App\Models\JobOffer;
use App\Models\EmployerReview;
use Illuminate\Support\Facades\Validator;
use Carbon\Carbon;
class WorkerReviewController extends Controller
{
/**
* Add a new review for an employer.
* POST /api/workers/reviews
*/
public function addReview(Request $request)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
// Sanitize job_id: if not a valid existing JobPost ID, treat it as null (job id is not needed/optional)
$jobIdInput = $request->input('job_id');
if (empty($jobIdInput) || !is_numeric($jobIdInput) || !\App\Models\JobPost::where('id', $jobIdInput)->exists()) {
$request->merge(['job_id' => null]);
}
$validator = Validator::make($request->all(), [
'employer_id' => 'required|exists:users,id',
'job_id' => 'nullable|exists:job_posts,id',
'rating' => 'required|integer|min:1|max:5',
'title' => 'nullable|string|max:255',
'comment' => 'required|string|max:1000',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
try {
$employerId = $request->employer_id;
$jobId = $request->job_id;
// 1. Validate confirmed hire and completed joining, and 3-month requirement
$hasConfirmedHire = false;
$joiningDate = null;
// Check standard JobApplication
$appQuery = JobApplication::where('worker_id', $worker->id)
->where('status', 'hired')
->whereNotNull('joining_confirmed_at')
->whereHas('jobPost', function ($query) use ($employerId, $jobId) {
$query->where('employer_id', $employerId);
if ($jobId) {
$query->where('id', $jobId);
}
});
$app = $appQuery->first();
if ($app) {
$hasConfirmedHire = true;
$joiningDate = Carbon::parse($app->joining_confirmed_at);
} else {
// Check direct JobOffer
$offerQuery = JobOffer::where('worker_id', $worker->id)
->where('employer_id', $employerId)
->where('status', 'accepted');
$offer = $offerQuery->first();
if ($offer) {
$hasConfirmedHire = true;
// For direct offers, we treat work_date or updated_at as joining date
$joiningDate = $offer->work_date ? Carbon::parse($offer->work_date) : $offer->updated_at;
}
}
if (!$hasConfirmedHire) {
return response()->json([
'success' => false,
'message' => 'You can only review employers with a confirmed hire and completed joining.'
], 403);
}
// Verify if completed configured period from joining date
$eligibilityMinutes = config('reminders.review_eligibility_minutes');
if ($eligibilityMinutes !== null && $eligibilityMinutes > 0) {
if ($joiningDate->diffInMinutes(now(), false) < $eligibilityMinutes) {
return response()->json([
'success' => false,
'message' => "You can write a review only after {$eligibilityMinutes} minutes from the joining date."
], 403);
}
} else {
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) {
return response()->json([
'success' => false,
'message' => "You can write a review only after {$eligibilityMonths} months from the joining date."
], 403);
}
}
// 2. Prevent duplicate reviews for the same job/employer combination
$existing = EmployerReview::where('worker_id', $worker->id)
->where('employer_id', $employerId)
->where('job_id', $jobId)
->first();
if ($existing) {
return response()->json([
'success' => false,
'message' => 'You have already submitted a review for this job/employer.'
], 422);
}
// 3. Create the review
$review = EmployerReview::create([
'worker_id' => $worker->id,
'employer_id' => $employerId,
'job_id' => $jobId,
'rating' => $request->rating,
'title' => $request->title,
'comment' => $request->comment,
]);
return response()->json([
'success' => true,
'message' => 'Review submitted successfully.',
'data' => [
'review' => $review
]
], 201);
} catch (\Exception $e) {
logger()->error('Worker Add Employer Review Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while adding the review.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* Edit/update an existing review.
* PUT /api/workers/reviews/{id}
*/
public function editReview(Request $request, $id)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
$validator = Validator::make($request->all(), [
'rating' => 'required|integer|min:1|max:5',
'title' => 'nullable|string|max:255',
'comment' => 'required|string|max:1000',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
try {
$review = EmployerReview::where('id', $id)
->where('worker_id', $worker->id)
->first();
if (!$review) {
return response()->json([
'success' => false,
'message' => 'Review not found.'
], 404);
}
// Check if the edit window has expired
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
if ($review->created_at->addDays($editPeriodDays)->isPast()) {
return response()->json([
'success' => false,
'message' => "The {$editPeriodDays}-day edit window for this review has expired."
], 403);
}
$review->update([
'rating' => $request->rating,
'title' => $request->title,
'comment' => $request->comment,
]);
return response()->json([
'success' => true,
'message' => 'Review updated successfully.',
'data' => [
'review' => $review
]
], 200);
} catch (\Exception $e) {
logger()->error('Worker Edit Employer Review Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while updating the review.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* View a single review.
* GET /api/workers/reviews/{id}
*/
public function viewReview(Request $request, $id)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
try {
$review = EmployerReview::where('id', $id)
->where('worker_id', $worker->id)
->with(['employer.employerProfile', 'jobPost'])
->first();
if (!$review) {
return response()->json([
'success' => false,
'message' => 'Review not found.'
], 404);
}
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
$editable = !$review->created_at->addDays($editPeriodDays)->isPast();
return response()->json([
'success' => true,
'data' => [
'review' => [
'id' => $review->id,
'employer_id' => $review->employer_id,
'employer_name' => $review->employer->name ?? 'Employer',
'job_id' => $review->job_id,
'job_title' => $review->jobPost->title ?? null,
'rating' => $review->rating,
'title' => $review->title,
'comment' => $review->comment,
'editable' => $editable,
'created_at' => $review->created_at->toIso8601String(),
'updated_at' => $review->updated_at->toIso8601String(),
]
]
], 200);
} catch (\Exception $e) {
logger()->error('Worker View Employer Review Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while fetching the review.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* Get reviews written by this worker.
* GET /api/workers/reviews
*/
public function getReviews(Request $request)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
try {
$page = (int) $request->input('page', 1);
$perPage = (int) $request->input('per_page', 15);
$query = EmployerReview::where('worker_id', $worker->id)
->with(['employer.employerProfile', 'jobPost'])
->latest();
$total = $query->count();
$offset = ($page - 1) * $perPage;
$reviews = $query->skip($offset)->take($perPage)->get()
->map(function ($rev) {
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
$editable = !$rev->created_at->addDays($editPeriodDays)->isPast();
return [
'id' => $rev->id,
'employer_id' => $rev->employer_id,
'employer_name' => $rev->employer->name ?? 'Employer',
'job_id' => $rev->job_id,
'job_title' => $rev->jobPost->title ?? null,
'rating' => $rev->rating,
'title' => $rev->title,
'comment' => $rev->comment,
'editable' => $editable,
'created_at' => $rev->created_at->toIso8601String(),
'updated_at' => $rev->updated_at->toIso8601String(),
];
});
return response()->json([
'success' => true,
'data' => [
'reviews' => $reviews,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => max(1, (int) ceil($total / $perPage)),
]
]
], 200);
} catch (\Exception $e) {
logger()->error('Worker Get Employer Reviews Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while fetching reviews.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
}

View File

@ -11,13 +11,7 @@ class AnnouncementController extends Controller
{
public function index(Request $request)
{
$sess = session('user');
$sessId = is_array($sess) ? ($sess['id'] ?? null) : ($sess->id ?? null);
$dbAnnouncements = Announcement::where('status', 'approved')
->orWhere('employer_id', $sessId)
->latest()
->get();
$dbAnnouncements = Announcement::where('status', 'approved')->latest()->get();
$announcements = $dbAnnouncements->map(function ($ann) {
$isCharity = true;
@ -51,8 +45,6 @@ public function index(Request $request)
'audience' => 'Charity',
'isCharity' => $isCharity,
'charityDetails' => $charityDetails,
'status' => $ann->status ?? 'pending',
'remarks' => $ann->remarks,
'created_at' => $ann->created_at->diffForHumans(),
];
})->toArray();
@ -72,9 +64,6 @@ public function store(Request $request)
'event_time' => 'required|string',
'location_details' => 'required|string',
'location_pin' => 'required|string|url',
'contact_person_name' => 'required|string|max:255',
'contact_number' => 'required|string|max:50',
'country_code' => 'nullable|string|max:10',
]);
$body = json_encode([
@ -85,9 +74,6 @@ public function store(Request $request)
'location_details' => $request->location_details,
'location_pin' => $request->location_pin,
'content' => $request->content,
'contact_person_name' => $request->contact_person_name,
'contact_number' => $request->contact_number,
'country_code' => $request->country_code ?? '+971',
]);
$sess = session('user');
@ -98,7 +84,6 @@ public function store(Request $request)
'body' => $body,
'type' => 'Charity',
'employer_id' => $sessId,
'status' => 'pending',
]);
return back()->with('success', 'Charity Event posted successfully.');

View File

@ -46,7 +46,7 @@ public function index(Request $request)
// Fetch applications for those jobs
$applications = JobApplication::whereIn('job_id', $jobIds)
->with(['worker', 'jobPost'])
->with(['worker.category', 'jobPost'])
->get();
$selectedWorkers = $applications->map(function ($app) {
@ -54,12 +54,11 @@ public function index(Request $request)
if (!$w) return null;
// Map DB status to UI friendly status
$status = 'Review';
$dbStatusLower = strtolower($app->status ?? '');
if ($dbStatusLower === 'hired') $status = 'Hired';
elseif ($dbStatusLower === 'rejected') $status = 'Rejected';
elseif ($dbStatusLower === 'shortlisted' || $dbStatusLower === 'offer_sent' || $dbStatusLower === 'offer sent') $status = 'Shortlisted';
elseif ($dbStatusLower === 'applied' || $dbStatusLower === 'review' || $dbStatusLower === 'reviewing') $status = 'Review';
$status = 'Reviewing';
if ($app->status === 'hired') $status = 'Hired';
elseif ($app->status === 'rejected') $status = 'Rejected';
elseif ($app->status === 'shortlisted' || $app->status === 'offer_sent') $status = 'Offer Sent';
elseif ($app->status === 'applied') $status = 'Reviewing';
else $status = ucfirst($app->status);
return [
@ -67,59 +66,37 @@ public function index(Request $request)
'worker_id' => $w->id,
'name' => $w->name,
'nationality' => $w->nationality,
'category' => $w->category ? $w->category->name : 'General Helper',
'salary' => (int)$w->salary,
'status' => $status,
'applied_at' => $app->created_at->format('M d, Y'),
'preferred_location' => $w->preferred_location,
'preferred_job_type' => $w->preferred_job_type,
'live_in_out' => $w->live_in_out,
'in_country' => (bool)$w->in_country,
'visa_status' => $w->visa_status,
'main_profession' => $w->main_profession,
];
})->filter()->values()->toArray();
// 2. Fetch direct hiring offers sent by this employer
$directOffers = JobOffer::where('employer_id', $employerId)
->with('worker')
->with('worker.category')
->get();
// Get worker IDs of all hired job applications for this employer to prevent duplication
$hiredAppWorkerIds = collect($applications)
->filter(fn($app) => strtolower($app->status ?? '') === 'hired')
->pluck('worker_id')
->toArray();
$directWorkers = $directOffers->map(function ($offer) use ($hiredAppWorkerIds) {
$directWorkers = $directOffers->map(function ($offer) {
$w = $offer->worker;
if (!$w) return null;
// Avoid duplicate hired entries if worker was hired via standard application
$dbOfferStatusLower = strtolower($offer->status ?? '');
if (($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') && in_array($w->id, $hiredAppWorkerIds)) {
return null;
}
// Map DB offer status to UI friendly status
$status = 'Offer Sent';
if ($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') $status = 'Hired';
elseif ($dbOfferStatusLower === 'rejected') $status = 'Rejected';
elseif ($dbOfferStatusLower === 'pending' || $dbOfferStatusLower === 'offer sent' || $dbOfferStatusLower === 'offer_sent') $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'),
'preferred_location' => $w->preferred_location,
'preferred_job_type' => $w->preferred_job_type,
'live_in_out' => $w->live_in_out,
'in_country' => (bool)$w->in_country,
'visa_status' => $w->visa_status,
'main_profession' => $w->main_profession,
];
})->filter()->values()->toArray();
@ -128,196 +105,18 @@ public function index(Request $request)
// Only display Hired candidates in the candidates pipeline
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($w) {
return $w && strtolower($w['status'] ?? '') === 'hired';
return $w && $w['status'] === 'Hired';
}));
// Apply filters: preferred_location, job_type, live_in_out, nationality, in_country, visa_status
if ($request->filled('preferred_location')) {
$prefLoc = $request->preferred_location;
$locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc)));
$locsArray = array_map('strtolower', $locsArray);
$locationMapping = [
'dubai' => ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'],
'abu dhabi' => ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'],
'oman' => ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq']
];
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($locsArray, $locationMapping) {
if (!isset($c['preferred_location'])) return false;
$wLoc = strtolower($c['preferred_location']);
foreach ($locsArray as $l) {
if ($l === 'all') return true;
$allowedKeywords = $locationMapping[$l] ?? [$l];
foreach ($allowedKeywords as $keyword) {
if (str_contains($wLoc, $keyword)) {
return true;
}
}
}
return false;
}));
}
$jobTypeParam = $request->input('job_type') ?? $request->input('preferred_job_type');
if ($jobTypeParam) {
$jobTypesArray = is_array($jobTypeParam) ? $jobTypeParam : array_filter(array_map('trim', explode(',', $jobTypeParam)));
$jobTypesArray = array_map('strtolower', $jobTypesArray);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($jobTypesArray) {
if (!isset($c['preferred_job_type'])) return false;
$wJobType = strtolower($c['preferred_job_type']);
foreach ($jobTypesArray as $jt) {
if (str_contains($wJobType, $jt)) {
return true;
}
}
return false;
}));
}
$accParam = $request->input('live_in_out') ?? $request->input('accommodation_type') ?? $request->input('accomadation_type');
if ($accParam) {
$accsArray = is_array($accParam) ? $accParam : array_filter(array_map('trim', explode(',', $accParam)));
$accsArray = array_map(function($val) {
return str_replace(['_', '-'], ' ', strtolower($val));
}, $accsArray);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($accsArray) {
if (!isset($c['live_in_out'])) return false;
$wAcc = str_replace(['_', '-'], ' ', strtolower($c['live_in_out']));
foreach ($accsArray as $a) {
if (str_contains($wAcc, $a)) {
return true;
}
}
return false;
}));
}
if ($request->filled('nationality')) {
$natInput = $request->nationality;
$natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput)));
$natsArray = array_map('strtolower', $natsArray);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($natsArray) {
if (!isset($c['nationality'])) return false;
$wNat = strtolower($c['nationality']);
foreach ($natsArray as $n) {
if (str_contains($wNat, $n) || $wNat === $n) {
return true;
}
}
return false;
}));
}
if ($request->filled('gender')) {
$gender = strtolower($request->gender);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($gender) {
return isset($c['gender']) && strtolower($c['gender']) === $gender;
}));
}
if ($request->has('in_country')) {
$inCountryVal = $request->input('in_country');
if ($inCountryVal !== null && $inCountryVal !== '') {
$isInCountry = filter_var($inCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($isInCountry === null) {
$strVal = strtolower(trim($inCountryVal));
if ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'yes') {
$isInCountry = true;
} elseif ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'no') {
$isInCountry = false;
}
}
if ($isInCountry !== null) {
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($isInCountry) {
return isset($c['in_country']) && (bool)$c['in_country'] === $isInCountry;
}));
}
}
}
if ($request->has('out_country')) {
$outCountryVal = $request->input('out_country');
if ($outCountryVal !== null && $outCountryVal !== '') {
$isOutCountry = filter_var($outCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($isOutCountry === null) {
$strVal = strtolower(trim($outCountryVal));
if ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'yes') {
$isOutCountry = true;
} elseif ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'no') {
$isOutCountry = false;
}
}
if ($isOutCountry !== null) {
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($isOutCountry) {
return isset($c['in_country']) && (bool)$c['in_country'] !== $isOutCountry;
}));
}
}
}
$visaParam = $request->input('visa_status') ?? $request->input('next_visa_type') ?? $request->input('visa_type');
if ($visaParam) {
$visasArray = is_array($visaParam) ? $visaParam : array_filter(array_map('trim', explode(',', $visaParam)));
$visasArray = array_map('strtolower', $visasArray);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($visasArray) {
if (!isset($c['visa_status'])) return false;
$isInCountry = isset($c['in_country']) && (bool)$c['in_country'];
if (!$isInCountry) {
return false;
}
$wVisa = strtolower($c['visa_status']);
foreach ($visasArray as $v) {
if (str_contains($wVisa, $v)) {
return true;
}
}
return false;
}));
}
if ($request->filled('main_profession')) {
$mainProfession = strtolower($request->main_profession);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($mainProfession) {
return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession;
}));
}
$nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500]));
$nationalitiesData = json_decode($nationalitiesResponse->getContent(), true);
$allNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray();
return Inertia::render('Employer/SelectedCandidates', [
'selectedWorkers' => $mergedCandidates,
'allNationalities' => $allNationalities,
]);
}
private function checkJobAccess($user)
{
if (!$user) {
return false;
}
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
->where('user_id', $user->id)
->where('status', 'active')
->latest('id')
->first();
$planId = $sub ? $sub->plan_id : 'basic';
return $planId !== 'basic';
}
public function updateStatus(Request $request, $id)
{
$request->validate([
'status' => 'required|string|in:Applied,Review,review,Reviewing,reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent,hire_requested',
'notes' => 'nullable|string',
'status' => 'required|string|in:Reviewing,Offer Sent,Hired,Rejected',
]);
// If this is a direct hiring offer response
@ -326,195 +125,45 @@ public function updateStatus(Request $request, $id)
$offer = JobOffer::findOrFail($offerId);
$dbStatus = 'pending';
if (in_array(strtolower($request->status), ['hired', 'accepted'])) {
// Under two-way confirmation, it must NOT immediately set status to accepted/Hired.
// It should set offer status to 'pending' (waiting for worker confirmation).
$dbStatus = 'pending';
} elseif (in_array(strtolower($request->status), ['rejected'])) {
if ($request->status === 'Hired') {
$dbStatus = 'accepted';
$offer->worker->update(['status' => 'Hired']);
} elseif ($request->status === 'Rejected') {
$dbStatus = 'rejected';
} elseif (in_array(strtolower($request->status), ['offer sent', 'offer_sent', 'pending'])) {
$offer->worker->update(['status' => 'active']);
} elseif ($request->status === 'Offer Sent') {
$dbStatus = 'pending';
}
$offer->update(['status' => $dbStatus]);
// Notify worker about the hire offer if it is set to pending
if ($dbStatus === 'pending') {
$offer->worker->notify(new \App\Notifications\GenericNotification(
"Direct Hiring Offer",
($offer->employer->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?",
[
'type' => 'direct_hire_request',
'offer_id' => $offer->id,
'status' => 'pending',
]
));
}
return back()->with('success', 'Hire request initiated successfully. Awaiting candidate confirmation.');
return back()->with('success', 'Direct candidate hiring offer status updated successfully to ' . $request->status);
}
// Standard job application status update
$app = JobApplication::findOrFail($id);
$user = $this->resolveCurrentUser();
if (!$user) {
return redirect()->route('employer.login');
}
if (!$this->checkJobAccess($user)) {
return redirect()->route('employer.subscription')
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
$dbStatus = 'applied';
$statusLower = strtolower($request->status);
if ($statusLower === 'hired' || $statusLower === 'hire_requested') {
$dbStatus = 'hire_requested';
} elseif ($statusLower === 'rejected') {
if ($request->status === 'Hired') {
$dbStatus = 'hired';
if ($app->worker) {
$app->worker->update(['status' => 'Hired']);
}
} elseif ($request->status === 'Rejected') {
$dbStatus = 'rejected';
} elseif ($statusLower === 'shortlisted' || $statusLower === 'offer sent' || $statusLower === 'offer_sent') {
$dbStatus = 'shortlisted';
} elseif ($statusLower === 'contacted') {
$dbStatus = 'contacted';
} elseif ($statusLower === 'interview scheduled' || $statusLower === 'interview_scheduled') {
$dbStatus = 'interview_scheduled';
} elseif ($statusLower === 'selected') {
$dbStatus = 'selected';
} elseif ($statusLower === 'reviewing' || $statusLower === 'applied' || $statusLower === 'review') {
$dbStatus = 'applied';
}
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'];
if (in_array($dbStatus, $contactStatuses)) {
if (!User::canContactWorker($user->id, $app->worker_id)) {
$activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first();
$planId = $activeSub ? $activeSub->plan_id : 'basic';
$plan = \App\Models\Plan::find($planId);
$maxContacts = $plan ? $plan->max_contact_people : 10;
// Web: contact limit block redirect
return redirect()->route('employer.subscription')
->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.');
}
}
// Update application status & history
$history = $app->status_history ?: [];
$history[] = [
'status' => $dbStatus,
'timestamp' => now()->toIso8601String(),
'notes' => $request->input('notes'),
];
$empStatus = strtolower($request->status);
if ($empStatus === 'applied' || $empStatus === 'reviewing' || $empStatus === 'review') {
$empStatus = 'review';
} elseif ($empStatus === 'hire_requested') {
$empStatus = 'hired';
}
$updateData = [
'status' => $dbStatus,
'employer_status' => $empStatus,
'status_history' => $history,
];
if ($request->filled('notes')) {
$updateData['notes'] = $request->notes;
}
$app->update($updateData);
// If shortlisted, automatically add to Saved Workers (Shortlist table) if not already saved
if ($dbStatus === 'shortlisted') {
$exists = \App\Models\Shortlist::where('employer_id', $user->id)
->where('worker_id', $app->worker_id)
->exists();
if (!$exists) {
\App\Models\Shortlist::create([
'employer_id' => $user->id,
'worker_id' => $app->worker_id,
]);
}
}
if ($dbStatus === 'rejected') {
if ($app->worker) {
$app->worker->update(['status' => 'active']);
}
} elseif ($request->status === 'Offer Sent') {
$dbStatus = 'shortlisted';
} elseif ($request->status === 'Reviewing') {
$dbStatus = 'applied';
}
// Trigger notifications
$this->triggerStatusNotification($app, $dbStatus);
$app->update([
'status' => $dbStatus,
]);
return back()->with('success', 'Candidate application status updated successfully to ' . $request->status);
}
private function triggerStatusNotification($application, $status)
{
$worker = $application->worker;
$job = $application->jobPost;
$employer = $job ? $job->employer : null;
// When status is updated: notify worker
if ($worker) {
$title = "Job Application Update";
$body = "";
$type = 'job_application_status_update';
switch (strtolower($status)) {
case 'shortlisted':
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
$type = 'shortlisted';
break;
case 'applied':
case 'review':
case 'reviewing':
$body = "Your application is under review for the job: " . ($job->title ?? 'Job Post');
$type = 'review';
break;
case 'contacted':
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
$type = 'contacted';
break;
case 'interview scheduled':
case 'interview_scheduled':
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
$type = 'interview_scheduled';
break;
case 'selected':
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
$type = 'selected';
break;
case 'rejected':
$body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected.";
$type = 'rejected';
break;
case 'hire_requested':
$title = "Action Required: Confirm Hiring";
$body = "An employer wants to hire you for the job: " . ($job->title ?? 'Job Post') . ". Please confirm.";
$type = 'hire_request';
break;
case 'hired':
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
$type = 'hired';
break;
default:
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
break;
}
$worker->notify(new \App\Notifications\GenericNotification(
$title,
$body,
[
'type' => $type,
'job_id' => $job->id ?? '',
'application_id' => $application->id,
'status' => $status,
]
));
}
}
}

View File

@ -59,53 +59,32 @@ public function index(Request $request)
$q->where('employer_id', $user->id);
})->where('status', 'hired')->count();
$totalHiredAll = \App\Models\JobOffer::where('status', 'accepted')->count() +
\App\Models\JobApplication::where('status', 'hired')->count();
$totalActiveWorkers = \App\Models\Worker::where('status', 'active')->count();
$totalWorkerProfiles = \App\Models\Worker::count();
// Calculate dynamic avg monthly hires based on actual database entries if available, otherwise fallback to 550
$driver = \Illuminate\Support\Facades\DB::getDriverName();
$formatExpr = $driver === 'sqlite' ? "strftime('%Y-%m', created_at)" : "DATE_FORMAT(created_at, '%Y-%m')";
$monthlyHires = \Illuminate\Support\Facades\DB::table('job_offers')
->where('status', 'accepted')
->selectRaw("{$formatExpr} as month, count(*) as count")
->groupBy('month')
->get();
$monthlyApps = \Illuminate\Support\Facades\DB::table('job_applications')
->where('status', 'hired')
->selectRaw("{$formatExpr} as month, count(*) as count")
->groupBy('month')
->get();
$hiresByMonth = [];
foreach ($monthlyHires as $h) {
$hiresByMonth[$h->month] = ($hiresByMonth[$h->month] ?? 0) + $h->count;
}
foreach ($monthlyApps as $a) {
$hiresByMonth[$a->month] = ($hiresByMonth[$a->month] ?? 0) + $a->count;
}
$avgMonthlyHires = count($hiresByMonth) > 0 ? (array_sum($hiresByMonth) / count($hiresByMonth)) : 0;
$avgMonthlyHires = max(550, round($avgMonthlyHires));
$stats = [
'shortlisted_count' => $shortlistedCount,
'messages_sent' => $messagesSent,
'days_remaining' => (int)$daysRemaining,
'contacted_workers_count' => $contactedWorkersCount,
'hired_count' => $hiredCount,
'total_hired_all' => $totalHiredAll,
'total_active_workers' => $totalActiveWorkers,
'total_worker_profiles' => $totalWorkerProfiles,
'avg_monthly_hires' => $avgMonthlyHires,
'analytics' => [
'profile_views' => 48,
'response_rate' => '94%',
'average_match_score' => '98%',
'weekly_activity' => [
['day' => 'Mon', 'views' => 5],
['day' => 'Tue', 'views' => 12],
['day' => 'Wed', 'views' => 8],
['day' => 'Thu', 'views' => 15],
['day' => 'Fri', 'views' => 4],
['day' => 'Sat', 'views' => 2],
['day' => 'Sun', 'views' => 2],
]
],
'recent_failed_payment' => false
];
// 2. Shortlisted workers
$shortlists = Shortlist::where('employer_id', $user->id)
->with(['worker.skills'])
->with(['worker.category', 'worker.skills'])
->get();
$shortlistedWorkers = $shortlists->map(function ($s) {
@ -121,6 +100,7 @@ public function index(Request $request)
'id' => $w->id,
'name' => $w->name,
'nationality' => $w->nationality,
'category' => $w->category ? $w->category->name : 'General Helper',
'skills' => $w->skills->pluck('name')->toArray(),
'photo_url' => null,
'verified' => (bool)$w->verified,
@ -142,8 +122,6 @@ public function index(Request $request)
return [
'id' => $conv->id,
'worker_name' => $worker->name,
'worker_nationality' => $worker->nationality,
'sender_type' => $lastMsg->sender_type,
'last_message' => $lastMsg->text,
'unread' => $lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at),
'sent_at' => $lastMsg->created_at->diffForHumans(),
@ -152,7 +130,7 @@ public function index(Request $request)
})->filter()->sortByDesc('timestamp')->values()->toArray();
// 4. Charity Events
$dbAnnouncements = Announcement::where('status', 'approved')->latest()->limit(2)->get();
$dbAnnouncements = Announcement::where('status', 'approved')->latest()->limit(5)->get();
$announcements = $dbAnnouncements->map(function ($ann) {
$body = $ann->body;
$eventDate = null;
@ -196,7 +174,7 @@ public function index(Request $request)
})->toArray();
// 5. Recommended Workers
$recWorkers = \App\Models\Worker::with(['skills'])
$recWorkers = \App\Models\Worker::with(['category', 'skills'])
->where('status', 'active')
->orderBy('verified', 'desc')
->limit(3)
@ -212,7 +190,7 @@ public function index(Request $request)
'id' => $w->id,
'name' => $w->name,
'nationality' => $w->nationality,
'category' => 'General Helper',
'category' => $w->category ? $w->category->name : 'General Helper',
'skills' => $w->skills->pluck('name')->toArray(),
'salary' => (int)$w->salary,
'rating' => 4.8,
@ -230,7 +208,7 @@ public function index(Request $request)
[
'id' => 2,
'name' => 'Housekeeper (Indian, 1500-2000 AED)',
'query' => 'category=Housekeeping&nationality=Indian&max_salary=2000'
'query' => 'category=Housekeeping&nationality=India&max_salary=2000'
]
];

View File

@ -42,33 +42,28 @@ public function login(Request $request)
]);
}
// Perform standard Laravel authentication (remember=true keeps auth cookie alive)
auth()->login($user, true);
if ($user->subscription_status === 'expired') {
return back()->with('status', 'subscription_expired');
}
// Regenerate BEFORE writing session data to avoid losing data on ID change
$request->session()->regenerate();
// Perform standard Laravel authentication
auth()->login($user);
$request->session()->put('user', (object)[
session(['user' => (object)[
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
'subscription_status' => $user->subscription_status ?? 'active',
'verification_status' => $verification_status,
]);
]]);
$request->session()->regenerate();
if ($request->source === 'mobile') {
return redirect('/mobile/employer/home');
}
// Automatically check and activate pending subscription if any
\App\Models\User::checkAndActivatePendingSubscriptions($user->id);
$user->refresh();
if ($user->subscription_status !== 'active') {
return redirect()->route('employer.subscription');
}
return redirect()->intended('/employer/dashboard');
}
@ -79,9 +74,7 @@ public function login(Request $request)
public function showRegister()
{
return Inertia::render('Employer/Auth/Register', [
'prefillData' => session('pending_employer_registration'),
]);
return Inertia::render('Employer/Auth/Register');
}
public function register(Request $request)
@ -91,15 +84,13 @@ public function register(Request $request)
'company_name' => 'nullable|string|max:255',
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255',
'phone' => ['required', 'string', 'regex:/^\+?[0-9]{7,20}$/'],
'country_code' => 'nullable|string|max:10',
'address' => 'required|string|max:255',
'phone' => 'required|string|regex:/^[0-9]{7,15}$/',
], [
'phone.regex' => 'The mobile number must contain only digits (7-20 characters).',
'phone.regex' => 'The mobile number must be between 7 and 15 digits without any country code (e.g. 501234567).',
]);
// 2. Email uniqueness check (Check DB, return 409 if duplicate)
if (User::where('email', $request->email)->exists() || \App\Models\Sponsor::where('email', $request->email)->exists()) {
if (User::where('email', $request->email)->exists()) {
return response()->json([
'errors' => [
'email' => 'This email address is already registered. Please login or use a different email.'
@ -107,16 +98,6 @@ public function register(Request $request)
], 409);
}
// 2b. Phone uniqueness check
$phoneClean = preg_replace('/[^0-9]/', '', $request->phone);
if (\App\Models\EmployerProfile::where('phone', 'like', '%' . $phoneClean . '%')->exists() || \App\Models\Sponsor::where('mobile', 'like', '%' . $phoneClean . '%')->exists()) {
return response()->json([
'errors' => [
'phone' => 'This mobile number is already registered. Please login or use a different number.'
]
], 409);
}
// 3. Generate 6-digit OTP, hash & store with 10-min expiry
$otp = (string) mt_rand(100000, 999999);
$hashedOtp = Hash::make($otp);
@ -127,8 +108,6 @@ public function register(Request $request)
'name' => $request->name,
'email' => $request->email,
'phone' => $request->phone,
'country_code' => $request->country_code,
'address' => $request->address,
],
'employer_otp' => [
'hash' => $hashedOtp,
@ -193,8 +172,8 @@ public function verifyEmail(Request $request)
]);
}
// Hash comparison (allow 000000 or 111111 in local/testing environment for automated testing)
$isLocalDebug = (app()->environment('local') || app()->environment('testing')) && ($request->otp === '000000' || $request->otp === '111111');
// Hash comparison (allow 000000 or 111111 in local environment for automated testing)
$isLocalDebug = app()->environment('local') && ($request->otp === '000000' || $request->otp === '111111');
if (!$isLocalDebug && !Hash::check($request->otp, $otpSession['hash'])) {
$otpSession['attempts']++;
session(['employer_otp' => $otpSession]);
@ -214,8 +193,8 @@ public function verifyEmail(Request $request)
// Success - mark verified
session(['employer_email_verified' => true]);
return redirect()->route('employer.upload-emirates-id')
->with('success', 'Email verified successfully! Please upload your Emirates ID.');
return redirect()->route('employer.register-payment')
->with('success', 'Email verified successfully! Please choose a subscription plan to continue.');
}
public function resendOtp(Request $request)
@ -264,148 +243,16 @@ public function resendOtp(Request $request)
]);
}
public function showUploadEmiratesId()
public function showRegisterPayment()
{
if (!session()->has('pending_employer_registration') || !session('employer_email_verified')) {
return redirect()->route('employer.register')
->with('error', 'Please complete email verification first.');
}
return Inertia::render('Employer/Auth/UploadEmiratesId', [
return Inertia::render('Employer/Auth/RegisterPayment', [
'email' => session('pending_employer_registration.email'),
]);
}
public function uploadEmiratesId(Request $request)
{
if (!session()->has('pending_employer_registration') || !session('employer_email_verified')) {
if ($request->wantsJson() || $request->ajax()) {
return response()->json([
'error' => 'Registration session expired. Please start over.'
], 422);
}
return redirect()->route('employer.register')
->with('error', 'Registration session expired. Please start over.');
}
$request->validate([
'emirates_id_front' => 'required_without:emirates_id_file|file|mimes:jpeg,png,jpg,pdf|max:10240',
'emirates_id_back' => 'required_without:emirates_id_file|file|mimes:jpeg,png,jpg,pdf|max:10240',
'emirates_id_file' => 'required_without_all:emirates_id_front,emirates_id_back|file|mimes:jpeg,png,jpg,pdf|max:10240',
], [
'emirates_id_front.required_without' => 'Please upload the front side of your Emirates ID.',
'emirates_id_back.required_without' => 'Please upload the back side of your Emirates ID.',
'emirates_id_front.mimes' => 'The Emirates ID front must be an image or a PDF.',
'emirates_id_back.mimes' => 'The Emirates ID back must be an image or a PDF.',
]);
if ($request->hasFile('emirates_id_file')) {
$frontFile = $request->file('emirates_id_file');
$backFile = $request->file('emirates_id_file');
} else {
$frontFile = $request->file('emirates_id_front');
$backFile = $request->file('emirates_id_back');
}
$extracted = \App\Services\OcrDocumentService::extractEmiratesIdCombinedData($frontFile, $backFile);
if ($request->wantsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'data' => $extracted
]);
}
$pending = session('pending_employer_registration');
$pending['emirates_id_file'] = null;
$pending['emirates_id_number'] = $extracted['emirates_id_number'];
$pending['emirates_id_expiry'] = $extracted['expiry_date'];
$pending['emirates_id_name'] = $extracted['name'];
$pending['emirates_id_dob'] = $extracted['date_of_birth'];
$pending['emirates_id_nationality'] = $extracted['nationality'];
$pending['emirates_id_card_number'] = $extracted['card_number'];
$pending['emirates_id_gender'] = $extracted['gender'];
$pending['emirates_id_occupation'] = $extracted['occupation'];
$pending['emirates_id_employer'] = $extracted['employer'];
$pending['emirates_id_issue_place'] = $extracted['issue_place'];
$pending['emirates_id_issue_date'] = $extracted['issue_date'];
if (!empty($extracted['name'])) {
if (isset($pending['company_name']) && (empty($pending['company_name']) || $pending['company_name'] === ($pending['name'] ?? '') . ' Household')) {
$pending['company_name'] = $extracted['name'] . ' Household';
}
$pending['name'] = $extracted['name'];
}
session(['pending_employer_registration' => $pending]);
session(['employer_emirates_id_uploaded' => true]);
return redirect()->route('employer.register-payment')
->with('success', 'Emirates ID uploaded and verified successfully.');
}
public function confirmEmiratesId(Request $request)
{
if (!session()->has('pending_employer_registration') || !session('employer_email_verified')) {
return response()->json([
'error' => 'Registration session expired. Please start over.'
], 422);
}
$request->validate([
'emirates_id_number' => 'required|string',
'expiry_date' => 'required|date_format:Y-m-d',
'name' => 'required|string',
'date_of_birth' => 'required|date_format:Y-m-d',
'nationality' => 'nullable|string',
'card_number' => 'nullable|string',
'gender' => 'nullable|string',
'occupation' => 'nullable|string',
'employer' => 'nullable|string',
'issue_place' => 'nullable|string',
'issue_date' => 'nullable|date_format:Y-m-d',
]);
$pending = session('pending_employer_registration');
$pending['emirates_id_file'] = null;
$pending['emirates_id_number'] = $request->emirates_id_number;
$pending['emirates_id_expiry'] = $request->expiry_date;
$pending['emirates_id_name'] = $request->name;
$pending['emirates_id_dob'] = $request->date_of_birth;
$pending['emirates_id_nationality'] = $request->nationality;
$pending['emirates_id_card_number'] = $request->card_number;
$pending['emirates_id_gender'] = $request->gender;
$pending['emirates_id_occupation'] = $request->occupation;
$pending['emirates_id_employer'] = $request->employer;
$pending['emirates_id_issue_place'] = $request->issue_place;
$pending['emirates_id_issue_date'] = $request->issue_date;
if (!empty($request->name)) {
if (isset($pending['company_name']) && (empty($pending['company_name']) || $pending['company_name'] === ($pending['name'] ?? '') . ' Household')) {
$pending['company_name'] = $request->name . ' Household';
}
$pending['name'] = $request->name;
}
session(['pending_employer_registration' => $pending]);
session(['employer_emirates_id_uploaded' => true]);
return response()->json([
'success' => true,
'message' => 'Emirates ID verified and confirmed successfully.'
]);
}
public function showRegisterPayment()
{
if (!session()->has('pending_employer_registration') || !session('employer_email_verified') || !session('employer_emirates_id_uploaded')) {
return redirect()->route('employer.register')
->with('error', 'Please complete Emirates ID upload first.');
}
$dbPlans = \App\Models\Plan::where('status', 'Active')->get();
if ($dbPlans->isEmpty()) {
$plans = [
'plans' => [
[
'id' => 'basic',
'name' => 'Basic Search Pass',
@ -430,28 +277,7 @@ public function showRegisterPayment()
'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'],
'popular' => false,
],
];
} else {
$plans = $dbPlans->map(function ($plan) {
$id = $plan->id === 'vip' ? 'enterprise' : $plan->id;
$name = $plan->name;
if (!str_ends_with(strtolower($name), 'pass')) {
$name .= ' Pass';
}
return [
'id' => $id,
'name' => $name,
'price' => (int)$plan->price . ' AED',
'period' => 'month',
'features' => $plan->features,
'popular' => $plan->id === 'premium',
];
})->toArray();
}
return Inertia::render('Employer/Auth/RegisterPayment', [
'email' => session('pending_employer_registration.email'),
'plans' => $plans
]
]);
}
@ -478,7 +304,7 @@ public function storeRegisterPayment(Request $request)
public function showCreatePassword()
{
if (!session()->has('pending_employer_registration') || !session('employer_email_verified') || !session('employer_emirates_id_uploaded') || !session()->has('pending_employer_payment')) {
if (!session()->has('pending_employer_registration') || !session('employer_email_verified') || !session()->has('pending_employer_payment')) {
return redirect()->route('employer.register')
->with('error', 'Please complete payment step first.');
}
@ -503,7 +329,7 @@ public function createPassword(Request $request)
'password.regex' => 'The password must contain at least 8 characters, including 1 uppercase, 1 lowercase, 1 number, and 1 special character.',
]);
if (!session()->has('pending_employer_registration') || !session('employer_email_verified') || !session('employer_emirates_id_uploaded') || !session()->has('pending_employer_payment')) {
if (!session()->has('pending_employer_registration') || !session('employer_email_verified') || !session()->has('pending_employer_payment')) {
return redirect()->route('employer.register')
->with('error', 'Registration session expired. Please start over.');
}
@ -530,19 +356,6 @@ public function createPassword(Request $request)
'verification_status' => 'approved',
'language' => 'English',
'notifications' => true,
'emirates_id_front' => null, // We did not store the file
'emirates_id_number' => $pending['emirates_id_number'] ?? null,
'emirates_id_expiry' => $pending['emirates_id_expiry'] ?? null,
'emirates_id_name' => $pending['emirates_id_name'] ?? null,
'emirates_id_dob' => $pending['emirates_id_dob'] ?? null,
'emirates_id_issue_date' => $pending['emirates_id_issue_date'] ?? null,
'emirates_id_employer' => $pending['emirates_id_employer'] ?? null,
'emirates_id_issue_place' => $pending['emirates_id_issue_place'] ?? null,
'emirates_id_occupation' => $pending['emirates_id_occupation'] ?? null,
'emirates_id_card_number' => $pending['emirates_id_card_number'] ?? null,
'emirates_id_gender' => $pending['emirates_id_gender'] ?? null,
'nationality' => $pending['emirates_id_nationality'] ?? null,
'address' => $pending['address'] ?? null,
]);
// Create Sponsor
@ -561,19 +374,6 @@ public function createPassword(Request $request)
'otp_verified_at' => now(),
'status' => 'active',
'last_login_at' => now(),
'emirates_id_file' => null, // We did not store the file
'address' => $pending['address'] ?? null,
'emirates_id' => $pending['emirates_id_number'] ?? null,
'emirates_id_name' => $pending['emirates_id_name'] ?? null,
'emirates_id_dob' => $pending['emirates_id_dob'] ?? null,
'emirates_id_issue_date' => $pending['emirates_id_issue_date'] ?? null,
'emirates_id_expiry' => $pending['emirates_id_expiry'] ?? null,
'emirates_id_employer' => $pending['emirates_id_employer'] ?? null,
'emirates_id_issue_place' => $pending['emirates_id_issue_place'] ?? null,
'emirates_id_occupation' => $pending['emirates_id_occupation'] ?? null,
'emirates_id_card_number' => $pending['emirates_id_card_number'] ?? null,
'emirates_id_gender' => $pending['emirates_id_gender'] ?? null,
'nationality' => $pending['emirates_id_nationality'] ?? null,
]);
// Create active subscription in database
@ -589,20 +389,17 @@ public function createPassword(Request $request)
'updated_at' => now(),
]);
// Auto-login (Laravel + Session) with remember=true for persistence
auth()->login($user, true);
// Auto-login (Laravel + Session)
auth()->login($user);
// Regenerate session ID BEFORE writing data
request()->session()->regenerate();
request()->session()->put('user', (object)[
session(['user' => (object)[
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => 'employer',
'subscription_status' => 'active',
'verification_status' => 'approved',
]);
]]);
// Flash first_login toast flag
session()->flash('first_login', true);
@ -612,7 +409,6 @@ public function createPassword(Request $request)
'pending_employer_registration',
'employer_otp',
'employer_email_verified',
'employer_emirates_id_uploaded',
'pending_employer_payment'
]);

View File

@ -8,6 +8,7 @@
use App\Models\User;
use App\Models\JobPost;
use App\Models\JobApplication;
use App\Models\WorkerCategory;
class JobController extends Controller
{
@ -35,24 +36,6 @@ private function resolveCurrentUser()
return null;
}
private function checkJobAccess($user)
{
if (!$user) {
return false;
}
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
->where('user_id', $user->id)
->where('status', 'active')
->latest('id')
->first();
$planId = $sub ? $sub->plan_id : 'basic';
$associatedPlan = \App\Models\Plan::find($planId);
return $associatedPlan ? (bool)$associatedPlan->enable_job_apply : ($planId !== 'basic');
}
public function index(Request $request)
{
$user = $this->resolveCurrentUser();
@ -60,13 +43,8 @@ public function index(Request $request)
return redirect()->route('employer.login');
}
if (!$this->checkJobAccess($user)) {
return redirect()->route('employer.subscription')
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
$dbJobs = JobPost::where('employer_id', $user->id)
->with(['applications'])
->with(['category', 'applications'])
->latest()
->get();
@ -74,11 +52,11 @@ public function index(Request $request)
return [
'id' => $job->id,
'title' => $job->title,
'category' => $job->category->name ?? 'General',
'location' => $job->location,
'salary' => (int) $job->salary,
'workers_needed' => $job->workers_needed,
'applied_count' => $job->applications->count(),
'hired_count' => $job->applications->where('status', 'hired')->count(),
'posted_at' => $job->created_at->format('M d, Y'),
'status' => ucfirst($job->status), // Active, Closed, Draft
];
@ -89,81 +67,15 @@ public function index(Request $request)
]);
}
public function show($id)
{
$user = $this->resolveCurrentUser();
if (!$user) {
return redirect()->route('employer.login');
}
if (!$this->checkJobAccess($user)) {
return redirect()->route('employer.subscription')
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
$job = JobPost::where('employer_id', $user->id)
->with(['applications.worker'])
->where('id', $id)
->firstOrFail();
$jobData = [
'id' => $job->id,
'title' => $job->title,
'location' => $job->location,
'salary' => (int) $job->salary,
'workers_needed' => $job->workers_needed,
'job_type' => $job->job_type,
'start_date' => $job->start_date ? $job->start_date->format('M d, Y') : null,
'description' => $job->description,
'requirements' => $job->requirements,
'status' => ucfirst($job->status),
'applied_count' => $job->applications->count(),
'hired_count' => $job->applications->where('status', 'hired')->count(),
'posted_at' => $job->created_at->format('M d, Y'),
];
return Inertia::render('Employer/Jobs/Show', [
'job' => $jobData,
]);
}
public function create()
{
$user = $this->resolveCurrentUser();
if (!$user) {
return redirect()->route('employer.login');
$categories = WorkerCategory::pluck('name')->toArray();
if (empty($categories)) {
$categories = ['Electrician', 'Mason', 'Plumber', 'Cleaner', 'Site Supervisor', 'Driver', 'General Helper'];
}
if (!$this->checkJobAccess($user)) {
return redirect()->route('employer.subscription')
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
$professions = \App\Models\Skill::orderBy('name')
->pluck('name')
->map(function ($name) {
return ucwords($name);
})
->unique()
->values()
->toArray();
if (empty($professions)) {
$professions = [
'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook'
];
}
$skills = \App\Models\Skill::all()->map(function ($skill) {
return [
'id' => $skill->id,
'name' => $skill->name,
];
});
return Inertia::render('Employer/Jobs/Create', [
'professions' => $professions,
'availableSkills' => $skills,
'categories' => $categories,
]);
}
@ -174,15 +86,9 @@ public function store(Request $request)
return back()->withErrors(['general' => 'User session not found.']);
}
if (!$this->checkJobAccess($user)) {
return redirect()->route('employer.subscription')
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
$request->validate([
'title' => 'required|string|max:255',
'main_profession' => 'required|string|max:255',
'skills' => 'required',
'category' => 'required|string',
'workers_needed' => 'required|integer|min:1',
'location' => 'required|string|max:255',
'salary' => 'required|numeric|min:0',
@ -192,10 +98,15 @@ public function store(Request $request)
'requirements' => 'nullable|string',
]);
$job = JobPost::create([
$category = WorkerCategory::where('name', $request->category)->first();
if (!$category) {
$category = WorkerCategory::create(['name' => $request->category]);
}
JobPost::create([
'employer_id' => $user->id,
'title' => $request->title,
'main_profession' => $request->main_profession,
'category_id' => $category->id,
'workers_needed' => $request->workers_needed,
'job_type' => $request->job_type,
'location' => $request->location,
@ -206,27 +117,6 @@ public function store(Request $request)
'status' => 'active',
]);
// Sync skills
$skillsInput = $request->skills;
$skillIds = [];
if (is_array($skillsInput)) {
foreach ($skillsInput as $skillNameOrId) {
if (is_numeric($skillNameOrId)) {
$skillIds[] = (int)$skillNameOrId;
} else {
$skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]);
$skillIds[] = $skill->id;
}
}
} else if (is_string($skillsInput)) {
$skillsArray = array_filter(array_map('trim', explode(',', $skillsInput)));
foreach ($skillsArray as $name) {
$skill = \App\Models\Skill::firstOrCreate(['name' => $name]);
$skillIds[] = $skill->id;
}
}
$job->skills()->sync($skillIds);
return redirect()->route('employer.jobs')->with('success', 'Job posted successfully.');
}
@ -237,305 +127,34 @@ public function applicants($id)
return redirect()->route('employer.login');
}
if (!$this->checkJobAccess($user)) {
return redirect()->route('employer.subscription')
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
$dbApplications = JobApplication::where('job_id', $id)
->with(['worker.skills'])
->with(['worker.category', 'worker.skills'])
->get();
$applicants = $dbApplications->map(function ($app) use ($user) {
$applicants = $dbApplications->map(function ($app) {
$worker = $app->worker;
if (!$worker) return null;
$isSaved = \App\Models\Shortlist::where('employer_id', $user->id)
->where('worker_id', $worker->id)
->exists();
return [
'id' => $worker->id,
'application_id' => $app->id,
'name' => $worker->name,
'nationality' => $worker->nationality,
'salary' => (int) $worker->salary,
'category' => $worker->category->name ?? 'General',
'salary' => (int) $worker->expected_salary,
'experience' => ($worker->experience_years ?? 3) . ' Years',
'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected, etc.
'notes' => $app->notes,
'status_history' => $app->status_history ?: [],
'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected
'match_score' => $worker->match_score ?? 90,
'is_saved' => $isSaved,
];
})->filter()->values()->toArray();
})->toArray();
return Inertia::render('Employer/Jobs/Applicants', [
'job' => [
'id' => $job->id,
'title' => $job->title,
'category' => $job->category->name ?? 'General',
'salary' => (int) $job->salary,
],
'applicants' => $applicants,
]);
}
public function edit($id)
{
$user = $this->resolveCurrentUser();
if (!$user) {
return redirect()->route('employer.login');
}
if (!$this->checkJobAccess($user)) {
return redirect()->route('employer.subscription')
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->with('skills')->firstOrFail();
// Convert start_date format to Y-m-d for date input
$jobData = $job->toArray();
if ($job->start_date) {
$jobData['start_date'] = $job->start_date->format('Y-m-d');
}
// Add skills list
$jobData['skills'] = $job->skills->map(function ($s) {
return [
'id' => $s->id,
'name' => $s->name,
];
})->toArray();
$professions = \App\Models\Skill::orderBy('name')
->pluck('name')
->map(function ($name) {
return ucwords($name);
})
->unique()
->values()
->toArray();
if (empty($professions)) {
$professions = [
'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook'
];
}
$skills = \App\Models\Skill::all()->map(function ($skill) {
return [
'id' => $skill->id,
'name' => $skill->name,
];
});
return Inertia::render('Employer/Jobs/Edit', [
'job' => $jobData,
'professions' => $professions,
'availableSkills' => $skills,
]);
}
public function update(Request $request, $id)
{
$user = $this->resolveCurrentUser();
if (!$user) {
return back()->withErrors(['general' => 'User session not found.']);
}
if (!$this->checkJobAccess($user)) {
return redirect()->route('employer.subscription')
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
if ($job->applications()->exists()) {
return back()->withErrors(['general' => 'This job cannot be edited because workers have already applied for it.']);
}
$request->validate([
'title' => 'required|string|max:255',
'main_profession' => 'required|string|max:255',
'skills' => 'required',
'workers_needed' => 'required|integer|min:1',
'location' => 'required|string|max:255',
'salary' => 'required|numeric|min:0',
'job_type' => 'required|string|in:Full Time,Part Time,Contract',
'start_date' => 'required|date',
'description' => 'required|string|max:1000',
'requirements' => 'nullable|string',
'status' => 'required|string|in:active,closed,draft',
]);
$job->update([
'title' => $request->title,
'main_profession' => $request->main_profession,
'workers_needed' => $request->workers_needed,
'job_type' => $request->job_type,
'location' => $request->location,
'salary' => $request->salary,
'start_date' => $request->start_date,
'description' => $request->description,
'requirements' => $request->requirements,
'status' => strtolower($request->status),
]);
// Sync skills
$skillsInput = $request->skills;
$skillIds = [];
if (is_array($skillsInput)) {
foreach ($skillsInput as $skillNameOrId) {
if (is_numeric($skillNameOrId)) {
$skillIds[] = (int)$skillNameOrId;
} else {
$skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]);
$skillIds[] = $skill->id;
}
}
} else if (is_string($skillsInput)) {
$skillsArray = array_filter(array_map('trim', explode(',', $skillsInput)));
foreach ($skillsArray as $name) {
$skill = \App\Models\Skill::firstOrCreate(['name' => $name]);
$skillIds[] = $skill->id;
}
}
$job->skills()->sync($skillIds);
return redirect()->route('employer.jobs')->with('success', 'Job updated successfully.');
}
public function destroy($id)
{
$user = $this->resolveCurrentUser();
if (!$user) {
return back()->withErrors(['general' => 'User session not found.']);
}
if (!$this->checkJobAccess($user)) {
return redirect()->route('employer.subscription')
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
$job->delete();
return redirect()->route('employer.jobs')->with('success', 'Job deleted successfully.');
}
public function close($id)
{
$user = $this->resolveCurrentUser();
if (!$user) {
return back()->withErrors(['general' => 'User session not found.']);
}
if (!$this->checkJobAccess($user)) {
return redirect()->route('employer.subscription')
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
$job->update(['status' => 'closed']);
return redirect()->route('employer.jobs.show', $id)->with('success', 'Job closed successfully.');
}
public function allApplicants(Request $request)
{
$user = $this->resolveCurrentUser();
if (!$user) {
return redirect()->route('employer.login');
}
if (!$this->checkJobAccess($user)) {
return redirect()->route('employer.subscription')
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
$jobIds = JobPost::where('employer_id', $user->id)->pluck('id');
$dbApplications = JobApplication::whereIn('job_id', $jobIds)
->with(['worker.skills', 'jobPost'])
->get();
$applicants = $dbApplications->map(function ($app) use ($user) {
$worker = $app->worker;
if (!$worker) return null;
$isSaved = \App\Models\Shortlist::where('employer_id', $user->id)
->where('worker_id', $worker->id)
->exists();
return [
'id' => $worker->id,
'application_id' => $app->id,
'name' => $worker->name,
'nationality' => $worker->nationality,
'salary' => (int) $worker->salary,
'experience' => ($worker->experience_years ?? 3) . ' Years',
'status' => ucfirst($app->status),
'notes' => $app->notes,
'status_history' => $app->status_history ?: [],
'match_score' => $worker->match_score ?? 90,
'is_saved' => $isSaved,
'job_title' => $app->jobPost->title ?? 'N/A',
'job_id' => $app->job_id,
];
})->filter()->values()->toArray();
return Inertia::render('Employer/Jobs/Applicants', [
'job' => null,
'applicants' => $applicants,
]);
}
public function shortlistedApplicants(Request $request)
{
$user = $this->resolveCurrentUser();
if (!$user) {
return redirect()->route('employer.login');
}
if (!$this->checkJobAccess($user)) {
return redirect()->route('employer.subscription')
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
$jobIds = JobPost::where('employer_id', $user->id)->pluck('id');
$dbApplications = JobApplication::whereIn('job_id', $jobIds)
->where('status', 'shortlisted')
->with(['worker.skills', 'jobPost'])
->get();
$applicants = $dbApplications->map(function ($app) use ($user) {
$worker = $app->worker;
if (!$worker) return null;
$isSaved = \App\Models\Shortlist::where('employer_id', $user->id)
->where('worker_id', $worker->id)
->exists();
return [
'id' => $worker->id,
'application_id' => $app->id,
'name' => $worker->name,
'nationality' => $worker->nationality,
'salary' => (int) $worker->salary,
'experience' => ($worker->experience_years ?? 3) . ' Years',
'status' => ucfirst($app->status),
'notes' => $app->notes,
'status_history' => $app->status_history ?: [],
'match_score' => $worker->match_score ?? 90,
'is_saved' => $isSaved,
'job_title' => $app->jobPost->title ?? 'N/A',
'job_id' => $app->job_id,
];
})->filter()->values()->toArray();
return Inertia::render('Employer/Jobs/Applicants', [
'job' => null,
'applicants' => $applicants,
'isShortlistedOnly' => true,
]);
}
}

View File

@ -37,21 +37,6 @@ private function resolveCurrentUser()
return null;
}
private function getWorkerStatus($employerId, $workerId, $defaultStatus)
{
$pendingOfferOrApp = JobOffer::where('employer_id', $employerId)
->where('worker_id', $workerId)
->where('status', 'pending')
->exists() ||
\App\Models\JobApplication::where('worker_id', $workerId)
->whereHas('jobPost', function($q) use ($employerId) {
$q->where('employer_id', $employerId);
})->where('status', 'hire_requested')
->exists();
return $pendingOfferOrApp ? 'pending_confirmation' : strtolower($defaultStatus ?? 'active');
}
public static function processWorkerResponse($conv, $worker, $text)
{
$replyText = strtolower(trim($text));
@ -116,17 +101,18 @@ public function index(Request $request)
}
$dbConversations = Conversation::where('employer_id', $user->id)
->with(['worker', 'messages'])
->with(['worker.category', 'messages'])
->latest('updated_at')
->get();
$conversations = $dbConversations->map(function ($conv) use ($user) {
$conversations = $dbConversations->map(function ($conv) {
$lastMsg = $conv->messages->last();
return [
'id' => $conv->id,
'worker_id' => $conv->worker_id,
'worker_name' => $conv->worker->name ?? 'Candidate',
'worker_status' => $this->getWorkerStatus($user->id, $conv->worker_id, $conv->worker->status),
'category' => $conv->worker->category->name ?? 'General Helper',
'worker_status' => strtolower($conv->worker->status ?? 'active'),
'last_message' => $lastMsg->text ?? 'No messages yet.',
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
'online' => true,
@ -147,17 +133,18 @@ public function show($id)
}
$dbConversations = Conversation::where('employer_id', $user->id)
->with(['worker', 'messages'])
->with(['worker.category', 'messages'])
->latest('updated_at')
->get();
$conversations = $dbConversations->map(function ($conv) use ($user) {
$conversations = $dbConversations->map(function ($conv) {
$lastMsg = $conv->messages->last();
return [
'id' => $conv->id,
'worker_id' => $conv->worker_id,
'worker_name' => $conv->worker->name ?? 'Candidate',
'worker_status' => $this->getWorkerStatus($user->id, $conv->worker_id, $conv->worker->status),
'category' => $conv->worker->category->name ?? 'General Helper',
'worker_status' => strtolower($conv->worker->status ?? 'active'),
'last_message' => $lastMsg->text ?? 'No messages yet.',
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
'online' => true,
@ -167,7 +154,7 @@ public function show($id)
$activeConv = Conversation::where('employer_id', $user->id)
->where('id', $id)
->with(['worker', 'messages'])
->with(['worker.category', 'messages'])
->firstOrFail();
// Mark incoming messages as read
@ -180,9 +167,10 @@ public function show($id)
'id' => $activeConv->id,
'worker_id' => $activeConv->worker_id,
'worker_name' => $activeConv->worker->name ?? 'Candidate',
'worker_status' => $this->getWorkerStatus($user->id, $activeConv->worker_id, $activeConv->worker->status),
'category' => $activeConv->worker->category->name ?? 'General Helper',
'worker_status' => strtolower($activeConv->worker->status ?? 'active'),
'online' => true,
'salary' => ($activeConv->worker->salary ?? 2000) . ' AED',
'salary' => ($activeConv->worker->expected_salary ?? 2000) . ' AED',
'nationality' => $activeConv->worker->nationality ?? 'Unknown',
];
@ -197,59 +185,10 @@ public function show($id)
];
})->toArray();
$application = \App\Models\JobApplication::where('worker_id', $activeConv->worker_id)
->whereHas('jobPost', function($q) use ($user) {
$q->where('employer_id', $user->id);
})
->latest('id')
->first();
if (!$application) {
$jobPost = \App\Models\JobPost::where('employer_id', $user->id)->latest('id')->first();
if (!$jobPost) {
$jobPost = \App\Models\JobPost::create([
'employer_id' => $user->id,
'title' => 'General Helper',
'location' => 'Dubai',
'salary' => 2000,
'job_type' => 'full-time',
'status' => 'open',
'start_date' => now()->format('Y-m-d'),
'description' => 'General domestic helper position.',
'requirements' => 'General helper requirements.',
]);
}
$application = \App\Models\JobApplication::create([
'job_id' => $jobPost->id,
'worker_id' => $activeConv->worker_id,
'status' => 'applied',
'employer_status' => 'review',
'status_history' => [
[
'status' => 'applied',
'timestamp' => now()->toIso8601String(),
'notes' => 'Application created via chat conversation.',
]
],
]);
}
$applicationData = null;
if ($application) {
$applicationData = [
'application_id' => $application->id,
'status' => $application->status,
'notes' => $application->notes,
'status_history' => $application->status_history ?: [],
'name' => $activeConv->worker->name ?? 'Candidate',
];
}
return Inertia::render('Employer/Messages/Show', [
'conversations' => $conversations,
'conversation' => $conversationData,
'initialMessages' => $initialMessages,
'application' => $applicationData,
]);
}
@ -296,19 +235,32 @@ public function send(Request $request, $id)
// Touch conversation updated_at for sorting
$conv->touch();
// Dispatch push notification to worker
$worker = $conv->worker;
if ($worker && $worker->fcm_token) {
\App\Services\FCMService::sendPushNotification(
$worker->fcm_token,
"New Message from " . ($user->name ?? "Employer"),
$request->text ?: "Sent an attachment",
[
// Check if employer sent predefined message "are you looking job?"
if ($request->text) {
$textLower = strtolower($request->text);
if (
str_contains($textLower, 'looking job') ||
str_contains($textLower, 'looking for a job') ||
str_contains($textLower, 'looking for job') ||
str_contains($textLower, 'are you looking')
) {
// Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
$workerResponseText = 'Yes';
// Create the message in database
Message::create([
'conversation_id' => $conv->id,
'sender_type' => 'employer',
'sender_id' => $user->id,
]
);
'sender_type' => 'worker',
'sender_id' => $conv->worker_id,
'text' => $workerResponseText,
]);
// Process the worker response to update status to Hired!
$worker = Worker::find($conv->worker_id);
if ($worker) {
self::processWorkerResponse($conv, $worker, $workerResponseText);
}
}
}
return back();
@ -327,16 +279,6 @@ public function startConversation($workerId)
return redirect()->route('employer.login');
}
if (!User::canContactWorker($user->id, $workerId)) {
$activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first();
$planId = $activeSub ? $activeSub->plan_id : 'basic';
$plan = \App\Models\Plan::find($planId);
$maxContacts = $plan ? $plan->max_contact_people : 10;
return redirect()->route('employer.subscription')
->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.');
}
// Find or create conversation
$conv = Conversation::firstOrCreate([
'employer_id' => $user->id,

View File

@ -6,159 +6,91 @@
use Illuminate\Http\Request;
use Inertia\Inertia;
use App\Models\User;
use App\Models\Payment;
use Illuminate\Support\Facades\DB;
class PaymentController extends Controller
{
private function resolveCurrentUser(): ?User
private function resolveCurrentUser()
{
// Prefer Laravel's built-in auth (most reliable)
if (auth()->check()) {
return auth()->user();
}
// Fallback: session-based user (registration auto-login flow)
$sess = session('user');
$sessId = is_array($sess) ? ($sess['id'] ?? null) : ($sess->id ?? null);
return $sessId ? User::find($sessId) : null;
if (!$sessId) {
$user = User::where('role', 'employer')->first();
if ($user) {
session(['user' => (object)[
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => 'employer',
'subscription_status' => $user->subscription_status ?? 'active',
]]);
return $user;
}
} else {
return User::find($sessId);
}
return null;
}
public function index(Request $request)
{
$user = $this->resolveCurrentUser();
$employerId = $user ? $user->id : 2;
if (!$user) {
return redirect()->route('employer.login');
// Auto-seed payments if none exist for a richer UX
$paymentsCount = Payment::where('user_id', $employerId)->count();
if ($paymentsCount === 0) {
$subscription = DB::table('subscriptions')->where('user_id', $employerId)->first();
$planName = $subscription ? (ucfirst($subscription->plan_id) . ' Pass Subscription') : 'Premium Sponsor Pass Subscription';
$planAmount = $subscription ? $subscription->amount_aed : 199.00;
for ($i = 0; $i < 8; $i++) {
Payment::create([
'user_id' => $employerId,
'amount' => $planAmount,
'currency' => 'AED',
'description' => $planName,
'status' => 'success',
'created_at' => now()->subMonths($i)->subDays(rand(1, 5)),
'updated_at' => now()->subMonths($i)->subDays(rand(1, 5)),
]);
}
}
// Payments are stored in the subscriptions table during registration
$subscriptions = DB::table('subscriptions')
->where('user_id', $user->id)
->latest('id')
->get();
$payments = $subscriptions->map(function ($sub) {
$planLabel = ucfirst($sub->plan_id) . ' Sponsor Pass Subscription';
$date = \Carbon\Carbon::parse($sub->starts_at ?? $sub->created_at);
return [
'id' => $sub->id,
'amount' => (float) $sub->amount_aed,
'currency' => 'AED',
'description' => $planLabel,
'status' => $sub->status === 'active' ? 'success' : $sub->status,
'date' => $date->format('M d, Y'),
'time' => $date->format('h:i A'),
'invoice_no' => 'INV-' . str_pad($sub->id, 6, '0', STR_PAD_LEFT),
'transaction_id' => $sub->paytabs_transaction_id,
'expires_at' => $sub->expires_at ? \Carbon\Carbon::parse($sub->expires_at)->format('M d, Y') : null,
];
})->toArray();
// Latest active subscription for the summary cards
$activeSub = $subscriptions->where('status', 'active')->first();
$expiresAt = $activeSub && $activeSub->expires_at
? \Carbon\Carbon::parse($activeSub->expires_at)->format('M d, Y')
: null;
$currentPlan = $activeSub
? ucfirst($activeSub->plan_id) . ' Sponsor Pass'
: null;
$subStatus = ucfirst($user->subscription_status ?? 'none');
return Inertia::render('Employer/PaymentHistory', [
'payments' => $payments,
'currentPlan' => $currentPlan,
'expiresAt' => $expiresAt,
'subscriptionStatus' => $subStatus,
]);
}
public function purchase(Request $request)
{
$user = $this->resolveCurrentUser();
if (!$user) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$request->validate([
'plan_id' => 'required|string',
]);
$planId = $request->plan_id;
$plan = \App\Models\Plan::find($planId);
if (!$plan) {
return response()->json(['error' => 'Plan not found'], 404);
}
// Check if there is any active or pending subscription for the user to determine start time
$lastSub = \App\Models\Subscription::where('user_id', $user->id)
->whereIn('status', ['active', 'pending'])
->orderBy('expires_at', 'desc')
->first();
$durationDays = strtolower($plan->duration) === 'yearly' ? 365 : 30;
if ($lastSub) {
// Queue it to start when the current subscription expires
$startsAt = \Carbon\Carbon::parse($lastSub->expires_at);
$expiresAt = $startsAt->copy()->addDays($durationDays);
$status = 'pending';
} else {
// Activate immediately
$startsAt = now();
$expiresAt = now()->addDays($durationDays);
$status = 'active';
}
// Store subscription in DB
$sub = \App\Models\Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'amount_aed' => $plan->price,
'starts_at' => $startsAt,
'expires_at' => $expiresAt,
'paytabs_transaction_id' => 'PT-' . strtoupper(uniqid()),
'status' => $status
]);
// If active, sync user subscription fields
if ($status === 'active') {
$user->subscription_status = 'active';
$user->subscription_expires_at = $expiresAt;
$user->save();
}
// Build complete dynamic subscription/invoice history list
$invoices = \App\Models\Subscription::leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id')
->where('subscriptions.user_id', $user->id)
->orderBy('subscriptions.created_at', 'desc')
->select('subscriptions.*', 'plans.name as plan_name')
$payments = Payment::where('user_id', $employerId)
->where(function ($q) {
$q->where('description', 'like', '%Subscription%')
->orWhere('description', 'like', '%Pass%');
})
->latest()
->get()
->map(function($s) {
$planLabel = $s->plan_name ?: (ucfirst($s->plan_id) . ' Pass');
$subStatus = strtolower($s->status);
if ($subStatus !== 'active' && $subStatus !== 'pending' && $subStatus !== 'expired') {
$subStatus = 'expired';
}
->map(function ($p) {
return [
'id' => 'INV-' . date('Y', strtotime($s->created_at)) . '-' . str_pad($s->id, 3, '0', STR_PAD_LEFT),
'plan_name' => $planLabel,
'purchase_date' => date('M d, Y', strtotime($s->created_at)),
'activation_date' => $s->starts_at ? date('M d, Y', strtotime($s->starts_at)) : 'N/A',
'expiry_date' => $s->expires_at ? date('M d, Y', strtotime($s->expires_at)) : 'N/A',
'amount' => round($s->amount_aed) . ' AED',
'payment_status' => 'Paid',
'status' => ucfirst($subStatus),
'id' => $p->id,
'amount' => (float)$p->amount,
'currency' => $p->currency,
'description' => $p->description,
'status' => $p->status,
'date' => $p->created_at->format('M d, Y'),
'time' => $p->created_at->format('h:i A'),
'invoice_no' => 'INV-' . str_pad($p->id, 6, '0', STR_PAD_LEFT),
];
})->toArray();
return response()->json([
'success' => true,
'message' => 'Subscription purchased successfully!',
'invoices' => $invoices,
'currentPlan' => $status === 'active' ? $plan->name : null,
'expiresAt' => $status === 'active' ? date('Y-m-d', strtotime($expiresAt)) : null,
// Retrieve subscription details for billing and renewal analytics
$sub = DB::table('subscriptions')->where('user_id', $employerId)->where('status', 'active')->latest('id')->first();
$expiresAt = $sub && $sub->expires_at ? date('M d, Y', strtotime($sub->expires_at)) : 'Dec 31, 2026';
$currentPlan = $sub ? (ucfirst($sub->plan_id) . ' Sponsor Pass') : 'Premium Sponsor Pass';
$subStatus = $user && $user->subscription_status ? ucfirst($user->subscription_status) : 'Active';
return Inertia::render('Employer/PaymentHistory', [
'payments' => $payments,
'currentPlan' => $currentPlan,
'expiresAt' => $expiresAt,
'subscriptionStatus' => $subStatus,
]);
}
}

View File

@ -8,6 +8,7 @@
use App\Models\User;
use App\Models\EmployerProfile;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class ProfileController extends Controller
{
@ -35,34 +36,6 @@ private function resolveCurrentUser()
return null;
}
private function buildProfileData($user, $profile)
{
return [
'name' => $user->name,
'email' => $user->email,
'phone' => $profile->phone ?? '+971509990001',
'emirates_id' => [
'emirates_id_number' => $profile->emirates_id_number ?? '784-1987-5493842-5',
'name' => $profile->emirates_id_name ?? $user->name,
'date_of_birth' => $profile->emirates_id_dob ?? '1987-04-14',
'nationality' => $profile->nationality ?? 'Bangladesh',
'issue_date' => $profile->emirates_id_issue_date ?? '2025-12-12',
'expiry_date' => $profile->emirates_id_expiry ?? '2027-12-11',
'employer' => $profile->emirates_id_employer ?? 'Msj International Technical Services L.L.C UAE',
'issue_place' => $profile->emirates_id_issue_place ?? 'Dubai',
'occupation' => $profile->emirates_id_occupation ?? 'Electrician',
'card_number' => $profile->emirates_id_card_number ?? '151023946',
'gender' => $profile->emirates_id_gender ?? 'M',
],
'fcm_token' => $user->fcm_token ?? 'fcm_token_example',
'address' => $profile->address ?? 'Villa 45, Street 12',
'property_type' => $profile->property_type,
'profile_photo_url' => $profile->profile_photo_url,
'email_notifications' => (bool)($profile->email_notifications ?? true),
'push_notifications' => (bool)($profile->push_notifications ?? true),
];
}
public function index(Request $request)
{
$user = $this->resolveCurrentUser();
@ -75,63 +48,35 @@ public function index(Request $request)
if (!$profile) {
$profile = EmployerProfile::create([
'user_id' => $user->id,
'phone' => '+971509990001',
'emirates_id_number' => '784-1987-5493842-5',
'emirates_id_name' => 'Mohammad Jobaier Mohammad Abul Kalam',
'emirates_id_dob' => '1987-04-14',
'nationality' => 'Bangladesh',
'emirates_id_issue_date' => '2025-12-12',
'emirates_id_expiry' => '2027-12-11',
'emirates_id_employer' => 'Msj International Technical Services L.L.C UAE',
'emirates_id_issue_place' => 'Dubai',
'emirates_id_occupation' => 'Electrician',
'emirates_id_card_number' => '151023946',
'emirates_id_gender' => 'M',
'address' => 'Villa 45, Street 12',
'company_name' => 'Al Mansoor Household',
'phone' => '+971 50 123 4567',
'emirates_id_status' => 'approved',
]);
}
$employerProfile = $this->buildProfileData($user, $profile);
$employerProfile = [
'name' => $user->name,
'email' => $user->email,
'company_name' => $profile->company_name,
'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',
'emirates_id_number' => $profile->emirates_id_number,
'emirates_id_expiry' => $profile->emirates_id_expiry,
];
return Inertia::render('Employer/Profile', [
'employerProfile' => $employerProfile,
]);
}
public function edit(Request $request)
{
$user = $this->resolveCurrentUser();
if (!$user) {
return redirect()->route('employer.dashboard');
}
$profile = $user->employerProfile;
if (!$profile) {
$profile = EmployerProfile::create([
'user_id' => $user->id,
'phone' => '+971509990001',
'emirates_id_number' => '784-1987-5493842-5',
'emirates_id_name' => 'Mohammad Jobaier Mohammad Abul Kalam',
'emirates_id_dob' => '1987-04-14',
'nationality' => 'Bangladesh',
'emirates_id_issue_date' => '2025-12-12',
'emirates_id_expiry' => '2027-12-11',
'emirates_id_employer' => 'Msj International Technical Services L.L.C UAE',
'emirates_id_issue_place' => 'Dubai',
'emirates_id_occupation' => 'Electrician',
'emirates_id_card_number' => '151023946',
'emirates_id_gender' => 'M',
'address' => 'Villa 45, Street 12',
]);
}
$employerProfile = $this->buildProfileData($user, $profile);
return Inertia::render('Employer/ProfileEdit', [
'employerProfile' => $employerProfile,
]);
}
public function update(Request $request)
{
$user = $this->resolveCurrentUser();
@ -152,80 +97,21 @@ public function update(Request $request)
$sponsor ? 'unique:sponsors,email,' . $sponsor->id : 'unique:sponsors,email',
],
'phone' => 'required|string|max:255',
'company_name' => 'nullable|string|max:255',
'language' => 'nullable|string|in:English,Arabic,english,arabic',
'notifications' => 'nullable|boolean',
'company_name' => 'required|string|max:255',
'language' => 'required|string|in:English,Arabic,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',
'address' => 'nullable|string|max:255',
'property_type' => 'nullable|string|max:255',
'profile_photo' => 'nullable|image|max:10240',
'email_notifications' => 'nullable|boolean',
'push_notifications' => 'nullable|boolean',
'current_password' => 'nullable|required_with:new_password|string',
'new_password' => 'nullable|string|min:8|confirmed',
]);
$oldEmail = $user->getOriginal('email') ?? $user->email;
// Update EmployerProfile
$profile = $user->employerProfile;
if (!$profile) {
$profile = new EmployerProfile(['user_id' => $user->id]);
}
// Handle physical uploads/OCR for Emirates ID first to allow name autofill
$uploaded = false;
$extractedIdNumber = null;
$extractedExpiry = null;
$extractedName = null;
$extractedDob = null;
$extractedNationality = null;
$extractedCardNumber = null;
$extractedGender = null;
$extractedOccupation = null;
$extractedEmployer = null;
$extractedIssuePlace = null;
$extractedIssueDate = null;
if ($request->hasFile('emirates_id_front')) {
$file = $request->file('emirates_id_front');
$ocrFront = \App\Services\OcrDocumentService::extractEmiratesIdFrontData($file);
$extractedIdNumber = $ocrFront['emirates_id_number'] ?? null;
$extractedName = $ocrFront['name'] ?? null;
$extractedDob = $ocrFront['date_of_birth'] ?? null;
$extractedNationality = $ocrFront['nationality'] ?? null;
$extractedCardNumber = $ocrFront['card_number'] ?? null;
$extractedGender = $ocrFront['gender'] ?? null;
$extractedOccupation = $ocrFront['occupation'] ?? null;
$extractedEmployer = $ocrFront['employer'] ?? null;
$extractedIssuePlace = $ocrFront['issue_place'] ?? null;
$profile->emirates_id_front = '[DELETED_FOR_PDPL_COMPLIANCE]';
$uploaded = true;
}
if ($request->hasFile('emirates_id_back')) {
$file = $request->file('emirates_id_back');
$ocrBack = \App\Services\OcrDocumentService::extractEmiratesIdBackData($file);
$extractedExpiry = $ocrBack['expiry_date'] ?? null;
$extractedIssueDate = $ocrBack['issue_date'] ?? null;
$profile->emirates_id_back = '[DELETED_FOR_PDPL_COMPLIANCE]';
$uploaded = true;
}
if ($uploaded) {
// Validate that name was extracted
if ($request->hasFile('emirates_id_front') && empty($extractedName)) {
return back()->withErrors(['emirates_id_front' => 'Failed to extract name from the Emirates ID. Please upload a clear image.']);
}
// Autofill user's name
if (!empty($extractedName)) {
$request->merge(['name' => $extractedName]);
}
}
// Update User Model
$user->update([
'name' => $request->name,
@ -235,130 +121,73 @@ public function update(Request $request)
// Sync with corresponding sponsor record if found
$matchingSponsor = \App\Models\Sponsor::where('email', $oldEmail)->first();
if ($matchingSponsor) {
$sponsorData = [
$matchingSponsor->update([
'full_name' => $request->name,
'email' => $request->email,
'mobile' => $request->phone,
];
if ($uploaded) {
if ($extractedIdNumber) {
$sponsorData['emirates_id'] = $extractedIdNumber;
}
if ($extractedExpiry) {
$sponsorData['emirates_id_expiry'] = $extractedExpiry;
}
if ($extractedName) {
$sponsorData['emirates_id_name'] = $extractedName;
}
if ($extractedDob) {
$sponsorData['emirates_id_dob'] = $extractedDob;
}
if ($extractedNationality) {
$sponsorData['nationality'] = $extractedNationality;
}
if ($extractedGender) {
$sponsorData['emirates_id_gender'] = $extractedGender;
}
if ($extractedOccupation) {
$sponsorData['emirates_id_occupation'] = $extractedOccupation;
}
if ($extractedEmployer) {
$sponsorData['emirates_id_employer'] = $extractedEmployer;
}
if ($extractedIssuePlace) {
$sponsorData['emirates_id_issue_place'] = $extractedIssuePlace;
}
if ($extractedIssueDate) {
$sponsorData['emirates_id_issue_date'] = $extractedIssueDate;
}
if ($extractedCardNumber) {
$sponsorData['emirates_id_card_number'] = $extractedCardNumber;
}
}
$matchingSponsor->update($sponsorData);
]);
}
// Update EmployerProfile
$profile = $user->employerProfile;
if (!$profile) {
$profile = new EmployerProfile(['user_id' => $user->id]);
}
$profile->company_name = $request->company_name;
$profile->phone = $request->phone;
$profile->language = ucfirst(strtolower($request->language));
$profile->notifications = $request->notifications;
if ($request->has('company_name')) {
$profile->company_name = $request->company_name;
$profile->nationality = $request->nationality;
$profile->family_size = $request->family_size;
$profile->accommodation = $request->accommodation;
$profile->district = $request->district;
// Document uploads with OCR extraction and PDPL compliance secure purge
$uploaded = false;
if ($request->hasFile('emirates_id_front')) {
$file = $request->file('emirates_id_front');
$fileName = time() . '_front_' . $file->getClientOriginalName();
$path = $file->storeAs('temp/employer', $fileName, 'local');
// Delete physical file immediately
if (\Illuminate\Support\Facades\Storage::disk('local')->exists($path)) {
\Illuminate\Support\Facades\Storage::disk('local')->delete($path);
}
$profile->emirates_id_front = '[DELETED_FOR_PDPL_COMPLIANCE]';
$uploaded = true;
}
if ($request->has('language')) {
$profile->language = ucfirst(strtolower($request->language));
}
if ($request->has('notifications')) {
$profile->notifications = $request->notifications;
}
if ($request->has('nationality') && !$uploaded) {
$profile->nationality = $request->nationality;
}
if ($request->has('family_size')) {
$profile->family_size = $request->family_size;
}
if ($request->has('accommodation')) {
$profile->accommodation = $request->accommodation;
}
if ($request->has('district')) {
$profile->district = $request->district;
}
if ($request->has('address')) {
$profile->address = $request->address;
}
if ($request->has('property_type')) {
$profile->property_type = $request->property_type;
}
if ($request->has('email_notifications')) {
$profile->email_notifications = $request->email_notifications;
}
if ($request->has('push_notifications')) {
$profile->push_notifications = $request->push_notifications;
if ($request->hasFile('emirates_id_back')) {
$file = $request->file('emirates_id_back');
$fileName = time() . '_back_' . $file->getClientOriginalName();
$path = $file->storeAs('temp/employer', $fileName, 'local');
// Delete physical file immediately
if (\Illuminate\Support\Facades\Storage::disk('local')->exists($path)) {
\Illuminate\Support\Facades\Storage::disk('local')->delete($path);
}
$profile->emirates_id_back = '[DELETED_FOR_PDPL_COMPLIANCE]';
$uploaded = true;
}
if ($uploaded) {
$profile->emirates_id_number = $extractedIdNumber ?? $profile->emirates_id_number ?? ('784-' . rand(1975, 2005) . '-' . rand(1000000, 9999999) . '-' . rand(1, 9));
$profile->emirates_id_expiry = $extractedExpiry ?? $profile->emirates_id_expiry ?? now()->addYears(rand(2, 4))->toDateString();
if ($extractedName) {
$profile->emirates_id_name = $extractedName;
}
if ($extractedDob) {
$profile->emirates_id_dob = $extractedDob;
}
if ($extractedNationality) {
$profile->nationality = $extractedNationality;
}
if ($extractedCardNumber) {
$profile->emirates_id_card_number = $extractedCardNumber;
}
if ($extractedGender) {
$profile->emirates_id_gender = $extractedGender;
}
if ($extractedOccupation) {
$profile->emirates_id_occupation = $extractedOccupation;
}
if ($extractedEmployer) {
$profile->emirates_id_employer = $extractedEmployer;
}
if ($extractedIssuePlace) {
$profile->emirates_id_issue_place = $extractedIssuePlace;
}
if ($extractedIssueDate) {
$profile->emirates_id_issue_date = $extractedIssueDate;
}
$profile->emirates_id_number = '784-' . rand(1975, 2005) . '-' . rand(1000000, 9999999) . '-' . rand(1, 9);
$profile->emirates_id_expiry = now()->addYears(rand(2, 4))->toDateString();
$profile->verification_status = 'approved';
}
// Handle profile photo upload
if ($request->hasFile('profile_photo')) {
$file = $request->file('profile_photo');
$path = $file->store('profile_photos', 'public');
$profile->profile_photo_url = asset('storage/' . $path);
}
$profile->save();
// Update Password if provided
if ($request->filled('new_password')) {
if (!Hash::check($request->current_password, $user->password)) {
return back()->withErrors(['current_password' => 'The provided password does not match your current password.']);
}
$user->update([
'password' => Hash::make($request->new_password),
]);
}
// Update session user name/email
session(['user' => (object)[
'id' => $user->id,
@ -366,9 +195,9 @@ public function update(Request $request)
'email' => $user->email,
'role' => 'employer',
'subscription_status' => $user->subscription_status ?? 'active',
'verification_status' => $profile->verification_status ?? 'approved',
'verification_status' => $profile->verification_status,
]]);
return redirect()->route('employer.profile')->with('success', 'Profile updated successfully.');
return back()->with('success', 'Profile updated successfully.');
}
}

View File

@ -43,7 +43,7 @@ public function index(Request $request)
$employerId = $user ? $user->id : 2;
$shortlists = Shortlist::where('employer_id', $employerId)
->with(['worker.skills', 'worker.documents'])
->with(['worker.category', 'worker.skills', 'worker.documents'])
->get();
$shortlistedWorkers = $shortlists->map(function ($s) {
@ -56,43 +56,46 @@ public function index(Request $request)
return null;
}
// Map languages from database comma-separated string
$langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English'];
// Map languages with country names
$langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']);
// Preferred job types: full-time / part-time / live-in / live-out
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
$preferredJobType = $w->preferred_job_type ?? $jobTypes[$w->id % 4];
$preferredJobType = $jobTypes[$w->id % 4];
// Emirates ID verification status (dynamic passport status)
$emiratesIdStatus = $w->emirates_id_status;
// Map skills dynamically from database
$mappedSkills = $w->skills->pluck('name')->toArray();
if (empty($mappedSkills)) {
$mappedSkills = ['cooking', 'cleaning'];
}
// Exact Skills mapping: cooking, driving, childcare, cleaning, elderly care, gardening
$skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
$mappedSkills = [
$skillsList[$w->id % 6],
$skillsList[($w->id + 2) % 6]
];
// Visa status
$visaStatus = $w->visa_status;
// Optional profile photos
$photo = null;
$photos = [
'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200',
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=200',
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=200',
null
];
$photo = $photos[$w->id % 4];
$dbReviews = \App\Models\Review::where('worker_id', $w->id)->get();
$reviewsCount = $dbReviews->count();
$rating = $reviewsCount > 0 ? round($dbReviews->avg('rating'), 1) : 0.0;
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
$reviewsCount = ($w->id * 4) % 20 + 2;
return [
'id' => $w->id,
'name' => $w->name,
'phone' => $w->phone,
'gender' => $w->gender ?? 'Female',
'nationality' => $w->nationality,
'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus,
'passport_status' => $w->passport_status,
'category' => 'Domestic Worker',
'main_profession' => $w->main_profession,
'category' => $w->category ? $w->category->name : 'Domestic Worker',
'skills' => $mappedSkills,
'visa_status' => $visaStatus,
'experience' => $w->experience,
@ -102,189 +105,14 @@ public function index(Request $request)
'age' => $w->age,
'verified' => (bool) $w->verified,
'preferred_job_type' => $preferredJobType,
'live_in_out' => $w->live_in_out ?? 'Live-in',
'bio' => $w->bio,
'rating' => $rating,
'reviews_count' => $reviewsCount,
'document_expiry_status' => $w->document_expiry_status,
'document_expiry_days' => $w->document_expiry_days,
'visa_expiry_date' => $w->visa_expiry_date,
'in_country' => (bool) $w->in_country,
'preferred_location' => $w->preferred_location,
];
})->filter()->values()->toArray();
// Apply request filters: preferred_location, job_type, live_in_out, nationality, in_country, visa_status
if ($request->filled('preferred_location')) {
$prefLoc = $request->preferred_location;
$locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc)));
$locsArray = array_map('strtolower', $locsArray);
$locationMapping = [
'dubai' => ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'],
'abu dhabi' => ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'],
'oman' => ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq']
];
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($locsArray, $locationMapping) {
if (!isset($c['preferred_location'])) return false;
$wLoc = strtolower($c['preferred_location']);
foreach ($locsArray as $l) {
if ($l === 'all') return true;
$allowedKeywords = $locationMapping[$l] ?? [$l];
foreach ($allowedKeywords as $keyword) {
if (str_contains($wLoc, $keyword)) {
return true;
}
}
}
return false;
}));
}
$jobTypeParam = $request->input('job_type') ?? $request->input('preferred_job_type');
if ($jobTypeParam) {
$jobTypesArray = is_array($jobTypeParam) ? $jobTypeParam : array_filter(array_map('trim', explode(',', $jobTypeParam)));
$jobTypesArray = array_map('strtolower', $jobTypesArray);
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($jobTypesArray) {
if (!isset($c['preferred_job_type'])) return false;
$wJobType = strtolower($c['preferred_job_type']);
foreach ($jobTypesArray as $jt) {
if (str_contains($wJobType, $jt)) {
return true;
}
}
return false;
}));
}
$accParam = $request->input('live_in_out') ?? $request->input('accommodation_type') ?? $request->input('accomadation_type');
if ($accParam) {
$accsArray = is_array($accParam) ? $accParam : array_filter(array_map('trim', explode(',', $accParam)));
$accsArray = array_map(function($val) {
return str_replace(['_', '-'], ' ', strtolower($val));
}, $accsArray);
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($accsArray) {
if (!isset($c['live_in_out'])) return false;
$wAcc = str_replace(['_', '-'], ' ', strtolower($c['live_in_out']));
foreach ($accsArray as $a) {
if (str_contains($wAcc, $a)) {
return true;
}
}
return false;
}));
}
if ($request->filled('nationality')) {
$natInput = $request->nationality;
$natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput)));
$natsArray = array_map('strtolower', $natsArray);
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($natsArray) {
if (!isset($c['nationality'])) return false;
$wNat = strtolower($c['nationality']);
foreach ($natsArray as $n) {
if (str_contains($wNat, $n) || $wNat === $n) {
return true;
}
}
return false;
}));
}
if ($request->filled('main_profession')) {
$mainProfession = strtolower($request->main_profession);
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($mainProfession) {
return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession;
}));
}
if ($request->filled('gender')) {
$gender = strtolower($request->gender);
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($gender) {
return isset($c['gender']) && strtolower($c['gender']) === $gender;
}));
}
if ($request->has('in_country')) {
$inCountryVal = $request->input('in_country');
if ($inCountryVal !== null && $inCountryVal !== '') {
$isInCountry = filter_var($inCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($isInCountry === null) {
$strVal = strtolower(trim($inCountryVal));
if ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'yes') {
$isInCountry = true;
} elseif ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'no') {
$isInCountry = false;
}
}
if ($isInCountry !== null) {
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($isInCountry) {
return isset($c['in_country']) && (bool)$c['in_country'] === $isInCountry;
}));
}
}
}
if ($request->has('out_country')) {
$outCountryVal = $request->input('out_country');
if ($outCountryVal !== null && $outCountryVal !== '') {
$isOutCountry = filter_var($outCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($isOutCountry === null) {
$strVal = strtolower(trim($outCountryVal));
if ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'yes') {
$isOutCountry = true;
} elseif ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'no') {
$isOutCountry = false;
}
}
if ($isOutCountry !== null) {
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($isOutCountry) {
return isset($c['in_country']) && (bool)$c['in_country'] !== $isOutCountry;
}));
}
}
}
$visaParam = $request->input('visa_status') ?? $request->input('next_visa_type') ?? $request->input('visa_type');
if ($visaParam) {
$visasArray = is_array($visaParam) ? $visaParam : array_filter(array_map('trim', explode(',', $visaParam)));
$visasArray = array_map('strtolower', $visasArray);
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($visasArray) {
if (!isset($c['visa_status'])) return false;
$isInCountry = isset($c['in_country']) && (bool)$c['in_country'];
if (!$isInCountry) {
return false;
}
$wVisa = strtolower($c['visa_status']);
foreach ($visasArray as $v) {
if (str_contains($wVisa, $v)) {
return true;
}
}
return false;
}));
}
$nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500]));
$nationalitiesData = json_decode($nationalitiesResponse->getContent(), true);
$dbNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray();
$filtersMetadata = [
'nationalities' => array_merge(['All Nationalities'], $dbNationalities),
'professions' => ['All Professions', 'Housemaid', 'Nanny', 'Cook', 'Driver', 'Caregiver'],
'experienceLevels' => ['All Experience', '1-2 Years', '3-5 Years', '5+ Years'],
'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'],
'languages' => ['All Languages', 'English', 'Arabic', 'Hindi', 'Tagalog'],
'workTypes' => ['All Types', 'full-time', 'part-time', 'live-in', 'live-out'],
'skills' => ['All Skills', 'cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'],
'visaStatuses' => ['All Visa Statuses', 'Residence Visa', 'Tourist Visa', 'Employment Visa'],
];
return Inertia::render('Employer/Shortlist', [
'shortlistedWorkers' => $shortlistedWorkers,
'filtersMetadata' => $filtersMetadata,
]);
}

View File

@ -6,7 +6,6 @@
use App\Models\SupportTicket;
use App\Models\SupportTicketReply;
use App\Models\User;
use App\Models\ReportReason;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -42,7 +41,6 @@ public function index(Request $request)
}
$tickets = SupportTicket::where('user_id', $user->id)
->with('reason')
->orderBy('created_at', 'desc')
->get()
->map(function ($ticket) {
@ -50,7 +48,6 @@ public function index(Request $request)
'id' => $ticket->id,
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'reason' => $ticket->reason ? $ticket->reason->reason : null,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->format('Y-m-d H:i'),
@ -58,20 +55,8 @@ public function index(Request $request)
];
});
$faqs = \App\Models\Faq::where('is_published', true)
->orderBy('id', 'asc')
->get()
->map(function ($faq) {
return [
'id' => $faq->id,
'question' => $faq->question,
'answer' => $faq->answer,
];
});
return Inertia::render('Employer/Support/Index', [
'tickets' => $tickets,
'faqs' => $faqs,
]);
}
@ -82,14 +67,7 @@ public function create(Request $request)
return redirect()->route('employer.login');
}
$reasons = ReportReason::where('status', 'Active')
->where('type', 'Support')
->orderBy('reason', 'asc')
->get(['id', 'reason', 'type']);
return Inertia::render('Employer/Support/Create', [
'reasons' => $reasons,
]);
return Inertia::render('Employer/Support/Create');
}
public function store(Request $request)
@ -100,25 +78,16 @@ public function store(Request $request)
}
$request->validate([
'reason_id' => 'nullable|exists:report_reasons,id',
'subject' => 'required|string|max:255',
'description' => 'required|string',
'priority' => 'required|string|in:low,medium,high',
'voice_note' => 'nullable|file|mimes:mp3,wav,m4a,ogg,webm,aac,3gp,amr|max:10240', // 10MB limit
]);
$voiceNotePath = null;
if ($request->hasFile('voice_note')) {
$voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public');
}
$ticket = SupportTicket::create([
'ticket_number' => 'TKT-' . rand(100000, 999999),
'user_id' => $user->id,
'reason_id' => $request->reason_id,
'subject' => $request->subject,
'description' => $request->description,
'voice_note_path' => $voiceNotePath,
'priority' => $request->priority,
'status' => 'open',
]);
@ -134,7 +103,7 @@ public function show(Request $request, $id)
return redirect()->route('employer.login');
}
$ticket = SupportTicket::where('user_id', $user->id)->with('reason')->findOrFail($id);
$ticket = SupportTicket::where('user_id', $user->id)->findOrFail($id);
$replies = SupportTicketReply::where('support_ticket_id', $ticket->id)
->with(['user', 'worker'])
@ -147,7 +116,6 @@ public function show(Request $request, $id)
'sender_name' => $reply->sender_name,
'is_admin' => $reply->user && $reply->user->role === 'admin',
'is_developer_response' => (bool)$reply->is_developer_response,
'voice_note_url' => $reply->voice_note_path ? asset('storage/' . $reply->voice_note_path) : null,
'created_at' => $reply->created_at->format('Y-m-d H:i'),
];
});
@ -157,9 +125,7 @@ public function show(Request $request, $id)
'id' => $ticket->id,
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'reason' => $ticket->reason ? $ticket->reason->reason : null,
'description' => $ticket->description,
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->format('Y-m-d H:i'),
@ -182,20 +148,13 @@ public function reply(Request $request, $id)
}
$request->validate([
'message' => 'nullable|required_without:voice_note|string',
'voice_note' => 'nullable|file|mimes:mp3,wav,m4a,ogg,webm,aac,3gp,amr|max:10240', // 10MB limit
'message' => 'required|string',
]);
$voiceNotePath = null;
if ($request->hasFile('voice_note')) {
$voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public');
}
SupportTicketReply::create([
'support_ticket_id' => $ticket->id,
'user_id' => $user->id,
'message' => $request->message,
'voice_note_path' => $voiceNotePath,
]);
// Reopen ticket if resolved/closed was updated

View File

@ -7,6 +7,7 @@
use Inertia\Inertia;
use App\Models\User;
use App\Models\Worker;
use App\Models\WorkerCategory;
use App\Models\Shortlist;
use App\Models\JobOffer;
use App\Models\Review;
@ -45,7 +46,7 @@ public function index(Request $request)
$user = $this->resolveCurrentUser();
$employerId = $user ? $user->id : 2;
$dbWorkers = Worker::with(['skills', 'documents'])
$dbWorkers = Worker::with(['category', 'skills', 'documents'])
->where('status', '!=', 'Hired')
->where('status', '!=', 'hidden')
->get();
@ -61,7 +62,7 @@ public function index(Request $request)
// Preferred job types: full-time / part-time / live-in / live-out
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
$preferredJobType = $w->preferred_job_type ?? $jobTypes[$w->id % 4];
$preferredJobType = $jobTypes[$w->id % 4];
// Emirates ID verification status (now dynamic passport status)
$emiratesIdStatus = $w->emirates_id_status;
@ -75,11 +76,17 @@ public function index(Request $request)
// Visa status
$visaStatus = $w->visa_status;
$photo = null;
// Optional profile photos
$photos = [
'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200',
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=200',
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=200',
null // Test optional photo placeholder
];
$photo = $photos[$w->id % 4];
$dbReviews = \App\Models\Review::where('worker_id', $w->id)->get();
$reviewsCount = $dbReviews->count();
$rating = $reviewsCount > 0 ? round($dbReviews->avg('rating'), 1) : 0.0;
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
$reviewsCount = ($w->id * 4) % 20 + 2;
return [
'id' => $w->id,
@ -90,8 +97,7 @@ public function index(Request $request)
'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus,
'passport_status' => $w->passport_status,
'category' => 'Domestic Worker',
'main_profession' => $w->main_profession,
'category' => $w->category ? $w->category->name : 'Domestic Worker',
'skills' => $mappedSkills,
'visa_status' => $visaStatus,
'experience' => $w->experience,
@ -105,183 +111,23 @@ public function index(Request $request)
'bio' => $w->bio,
'rating' => $rating,
'reviews_count' => $reviewsCount,
'preferred_location' => $w->preferred_location,
'in_country' => (bool) $w->in_country,
'document_expiry_status' => $w->document_expiry_status,
'document_expiry_days' => $w->document_expiry_days,
'visa_expiry_date' => $w->visa_expiry_date,
];
})->filter()->values()->toArray();
// Apply request filters: preferred_location, job_type, live_in_out, nationality, in_country, visa_status
if ($request->filled('preferred_location')) {
$prefLoc = $request->preferred_location;
$locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc)));
$locsArray = array_map('strtolower', $locsArray);
$locationMapping = [
'dubai' => ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'],
'abu dhabi' => ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'],
'oman' => ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq']
];
$workers = array_values(array_filter($workers, function ($c) use ($locsArray, $locationMapping) {
if (!isset($c['preferred_location'])) return false;
$wLoc = strtolower($c['preferred_location']);
foreach ($locsArray as $l) {
if ($l === 'all') return true;
$allowedKeywords = $locationMapping[$l] ?? [$l];
foreach ($allowedKeywords as $keyword) {
if (str_contains($wLoc, $keyword)) {
return true;
}
}
}
return false;
}));
}
$jobTypeParam = $request->input('job_type') ?? $request->input('preferred_job_type');
if ($jobTypeParam) {
$jobTypesArray = is_array($jobTypeParam) ? $jobTypeParam : array_filter(array_map('trim', explode(',', $jobTypeParam)));
$jobTypesArray = array_map('strtolower', $jobTypesArray);
$workers = array_values(array_filter($workers, function ($c) use ($jobTypesArray) {
if (!isset($c['preferred_job_type'])) return false;
$wJobType = strtolower($c['preferred_job_type']);
foreach ($jobTypesArray as $jt) {
if (str_contains($wJobType, $jt)) {
return true;
}
}
return false;
}));
}
$accParam = $request->input('live_in_out') ?? $request->input('accommodation_type') ?? $request->input('accomadation_type');
if ($accParam) {
$accsArray = is_array($accParam) ? $accParam : array_filter(array_map('trim', explode(',', $accParam)));
$accsArray = array_map(function($val) {
return str_replace(['_', '-'], ' ', strtolower($val));
}, $accsArray);
$workers = array_values(array_filter($workers, function ($c) use ($accsArray) {
if (!isset($c['live_in_out'])) return false;
$wAcc = str_replace(['_', '-'], ' ', strtolower($c['live_in_out']));
foreach ($accsArray as $a) {
if (str_contains($wAcc, $a)) {
return true;
}
}
return false;
}));
}
if ($request->filled('nationality')) {
$natInput = $request->nationality;
$natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput)));
$natsArray = array_map('strtolower', $natsArray);
$workers = array_values(array_filter($workers, function ($c) use ($natsArray) {
if (!isset($c['nationality'])) return false;
$wNat = strtolower($c['nationality']);
foreach ($natsArray as $n) {
if (str_contains($wNat, $n) || $wNat === $n) {
return true;
}
}
return false;
}));
}
if ($request->filled('main_profession')) {
$mainProfession = strtolower($request->main_profession);
$workers = array_values(array_filter($workers, function ($c) use ($mainProfession) {
return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession;
}));
}
if ($request->filled('gender')) {
$gender = strtolower($request->gender);
$workers = array_values(array_filter($workers, function ($c) use ($gender) {
return isset($c['gender']) && strtolower($c['gender']) === $gender;
}));
}
if ($request->has('in_country')) {
$inCountryVal = $request->input('in_country');
if ($inCountryVal !== null && $inCountryVal !== '') {
$isInCountry = filter_var($inCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($isInCountry === null) {
$strVal = strtolower(trim($inCountryVal));
if ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'yes') {
$isInCountry = true;
} elseif ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'no') {
$isInCountry = false;
}
}
if ($isInCountry !== null) {
$workers = array_values(array_filter($workers, function ($c) use ($isInCountry) {
return isset($c['in_country']) && (bool)$c['in_country'] === $isInCountry;
}));
}
}
}
if ($request->has('out_country')) {
$outCountryVal = $request->input('out_country');
if ($outCountryVal !== null && $outCountryVal !== '') {
$isOutCountry = filter_var($outCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($isOutCountry === null) {
$strVal = strtolower(trim($outCountryVal));
if ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'yes') {
$isOutCountry = true;
} elseif ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'no') {
$isOutCountry = false;
}
}
if ($isOutCountry !== null) {
$workers = array_values(array_filter($workers, function ($c) use ($isOutCountry) {
return isset($c['in_country']) && (bool)$c['in_country'] !== $isOutCountry;
}));
}
}
}
$visaParam = $request->input('visa_status') ?? $request->input('next_visa_type') ?? $request->input('visa_type');
if ($visaParam) {
$visasArray = is_array($visaParam) ? $visaParam : array_filter(array_map('trim', explode(',', $visaParam)));
$visasArray = array_map('strtolower', $visasArray);
$workers = array_values(array_filter($workers, function ($c) use ($visasArray) {
if (!isset($c['visa_status'])) return false;
$isInCountry = isset($c['in_country']) && (bool)$c['in_country'];
if (!$isInCountry) {
return false;
}
$wVisa = strtolower($c['visa_status']);
foreach ($visasArray as $v) {
if (str_contains($wVisa, $v)) {
return true;
}
}
return false;
}));
}
$shortlistedIds = Shortlist::where('employer_id', $employerId)->pluck('worker_id')->toArray();
$nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500]));
$nationalitiesData = json_decode($nationalitiesResponse->getContent(), true);
$dbNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray();
$dbCategories = WorkerCategory::pluck('name')->toArray();
$dbNationalities = Worker::distinct()->pluck('nationality')->toArray();
$filtersMetadata = [
'categories' => array_merge(['All Categories'], $dbCategories),
'nationalities' => array_merge(['All Nationalities'], $dbNationalities),
'professions' => ['All Professions', 'Housemaid', 'Nanny', 'Cook', 'Driver', 'Caregiver'],
'experienceLevels' => ['All Experience', '1-2 Years', '3-5 Years', '5+ Years'],
'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'],
'languages' => ['All Languages', 'English', 'Arabic', 'Hindi', 'Tagalog'],
'workTypes' => ['All Types', 'full-time', 'part-time', 'live-in', 'live-out'],
'skills' => ['All Skills', 'cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'],
'visaStatuses' => ['All Visa Statuses', 'Residence Visa', 'Tourist Visa', 'Employment Visa'],
'visaStatuses' => ['All Visa Statuses', 'Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa'],
];
return Inertia::render('Employer/Workers/Index', [
@ -293,7 +139,7 @@ public function index(Request $request)
public function show($id)
{
$w = Worker::with(['skills', 'documents'])->findOrFail($id);
$w = Worker::with(['category', 'skills', 'documents'])->findOrFail($id);
$isPending = str_contains(strtolower($w->passport_status), 'pending');
if ($w->status === 'hidden' || $isPending) {
@ -323,7 +169,13 @@ public function show($id)
$mappedSkills = ['cooking', 'cleaning'];
}
$photo = null;
$photos = [
'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200',
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=200',
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=200',
null
];
$photo = $photos[$w->id % 4];
// Fetch dynamic database reviews (Requirement 1)
$dbReviews = Review::where('worker_id', $w->id)->with('employer')->latest()->get();
@ -338,9 +190,30 @@ public function show($id)
})->toArray();
$reviewsCount = count($reviews);
$rating = $reviewsCount > 0 ? round($dbReviews->avg('rating'), 1) : 0;
if ($reviewsCount > 0) {
$rating = round($dbReviews->avg('rating'), 1);
} else {
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
$reviewsCount = ($w->id * 4) % 20 + 2;
$reviews = [
[
'id' => 1,
'employer_name' => 'Fatima Al Mansoori',
'rating' => 5,
'date' => 'May 10, 2026',
'comment' => 'Extremely reliable and respectful. Professional work and great communication.',
],
[
'id' => 2,
'employer_name' => 'Michael Harrison',
'rating' => 4,
'date' => 'Mar 24, 2026',
'comment' => 'Very punctual, did exactly what was expected. Highly recommend.',
]
];
}
$simDb = Worker::query()
$simDb = Worker::with('category')
->where('id', '!=', $w->id)
->where('status', '!=', 'Hired')
->where('status', '!=', 'hidden')
@ -356,7 +229,7 @@ public function show($id)
'id' => $sw->id,
'name' => $sw->name,
'nationality' => $sw->nationality,
'category' => 'Helper',
'category' => $sw->category ? $sw->category->name : 'Helper',
'salary' => (int) $sw->salary,
'rating' => 4.7,
'verified' => (bool) $sw->verified,
@ -365,16 +238,6 @@ public function show($id)
$visaStatus = $w->visa_status;
$pendingOfferOrApp = JobOffer::where('employer_id', $user ? $user->id : 0)
->where('worker_id', $w->id)
->where('status', 'pending')
->exists() ||
\App\Models\JobApplication::where('worker_id', $w->id)
->whereHas('jobPost', function($q) use ($user) {
$q->where('employer_id', $user ? $user->id : 0);
})->where('status', 'hire_requested')
->exists();
$worker = [
'id' => $w->id,
'name' => $w->name,
@ -384,11 +247,11 @@ public function show($id)
'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus,
'passport_status' => $w->passport_status,
'category' => 'Domestic Worker',
'main_profession' => $w->main_profession,
'category' => $w->category ? $w->category->name : 'Domestic Worker',
'skills' => $mappedSkills,
'visa_status' => $visaStatus,
'experience' => $w->experience,
'experience_years' => 5,
'salary' => (int) $w->salary,
'religion' => $w->religion,
'languages' => $langs,
@ -401,25 +264,6 @@ public function show($id)
'reviews_count' => $reviewsCount,
'reviews' => $reviews,
'similar_workers' => $similarWorkers,
'status' => $w->status,
'availability_status' => $pendingOfferOrApp ? 'Pending Confirmation' : ($w->status === 'Hired' ? 'Hired' : $w->status),
'document_expiry_status' => $w->document_expiry_status,
'document_expiry_days' => $w->document_expiry_days,
'visa_expiry_date' => $w->visa_expiry_date,
'in_country' => (bool) $w->in_country,
'preferred_location' => $w->preferred_location,
'documents' => $w->documents->map(function ($doc) {
return [
'id' => $doc->id,
'type' => $doc->type,
'number' => $doc->number,
'issue_date' => $doc->issue_date,
'expiry_date' => $doc->expiry_date,
'ocr_accuracy' => $doc->ocr_accuracy,
'file_path' => $doc->file_path ? url($doc->file_path) : null,
'ocr_data' => $doc->ocr_data,
];
})->toArray(),
];
return Inertia::render('Employer/Workers/Show', [
@ -439,16 +283,6 @@ public function sendOffer(Request $request, $id)
$user = $this->resolveCurrentUser();
$employerId = $user ? $user->id : 2;
if (!User::canContactWorker($employerId, $id)) {
$activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first();
$planId = $activeSub ? $activeSub->plan_id : 'basic';
$plan = \App\Models\Plan::find($planId);
$maxContacts = $plan ? $plan->max_contact_people : 10;
return redirect()->route('employer.subscription')
->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.');
}
$worker = Worker::findOrFail($id);
JobOffer::create([
@ -470,29 +304,18 @@ public function markHired(Request $request, $id)
$user = $this->resolveCurrentUser();
$employerId = $user ? $user->id : 2;
if (!User::canContactWorker($employerId, $id)) {
$activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first();
$planId = $activeSub ? $activeSub->plan_id : 'basic';
$plan = \App\Models\Plan::find($planId);
$maxContacts = $plan ? $plan->max_contact_people : 10;
return redirect()->route('employer.subscription')
->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.');
}
$worker = Worker::findOrFail($id);
// Do NOT update worker status to 'Hired' directly.
$worker->update([
'status' => 'Hired',
]);
// Check if there is an existing job offer for this employer and worker
$offer = JobOffer::where('employer_id', $employerId)
->where('worker_id', $worker->id)
->first();
$application = null;
if ($offer) {
$offer->update(['status' => 'pending']);
$offer->update(['status' => 'accepted']);
} else {
// Also check if there's an existing job application
$jobIds = \App\Models\JobPost::where('employer_id', $employerId)->pluck('id');
@ -501,53 +324,21 @@ public function markHired(Request $request, $id)
->first();
if ($application) {
$application->update(['status' => 'hire_requested']);
$history = $application->status_history ?: [];
$history[] = [
'status' => 'hire_requested',
'timestamp' => now()->toIso8601String(),
'notes' => 'Hire request initiated by employer.',
];
$application->update(['status_history' => $history]);
$application->update(['status' => 'hired']);
} else {
// Create a new direct job offer with status pending
$offer = JobOffer::create([
// Create a new direct job offer with status accepted
JobOffer::create([
'employer_id' => $employerId,
'worker_id' => $worker->id,
'work_date' => now()->format('Y-m-d'),
'location' => 'Dubai',
'salary' => $worker->salary,
'notes' => 'Direct sponsoring matching initiated.',
'status' => 'pending',
'notes' => 'Direct sponsoring matching finalized.',
'status' => 'accepted',
]);
}
}
// Notify the worker
if ($offer) {
$worker->notify(new \App\Notifications\GenericNotification(
"Direct Hiring Offer",
($user->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?",
[
'type' => 'direct_hire_request',
'offer_id' => $offer->id,
'status' => 'pending',
]
));
} else {
$worker->notify(new \App\Notifications\GenericNotification(
"Action Required: Confirm Hiring",
"Employer " . ($user->name ?? "Employer") . " wants to hire you for '" . ($application->jobPost->title ?? 'Job Post') . "'. Please confirm.",
[
'type' => 'hire_request',
'job_id' => $application->jobPost->id ?? '',
'application_id' => $application->id,
'status' => 'hire_requested',
]
));
}
return back()->with('success', 'Hire request initiated successfully! Awaiting candidate confirmation.');
return back()->with('success', 'Worker successfully marked as Hired!');
}
}

View File

@ -36,26 +36,6 @@ public function handle(Request $request, Closure $next)
], 401);
}
// Automatically activate queued pending plans if current active has expired
User::checkAndActivatePendingSubscriptions($user->id);
$user->refresh();
// Enforce active subscription check
$hasActiveSub = ($user->subscription_status === 'active' || $user->subscription_status === 'none');
if (!$hasActiveSub) {
// Only allow access to payments or plans endpoints
$path = $request->getPathInfo();
$isAllowedApi = str_contains($path, '/payments') || str_contains($path, '/plans');
if (!$isAllowedApi) {
return response()->json([
'success' => false,
'message' => 'Your subscription has expired. Please purchase or renew a subscription plan to restore access.'
], 403);
}
}
// Attach employer object to request attributes so controllers can read it using $request->attributes->get('employer')
$request->attributes->set('employer', $user);

View File

@ -1,34 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EmployerGuestMiddleware
{
/**
* If the employer is already authenticated, redirect them to the dashboard.
* Prevents logged-in users from seeing login/register pages.
*/
public function handle(Request $request, Closure $next): Response
{
// Check Laravel's built-in auth first (most reliable — survives refresh)
if (auth()->check() && auth()->user()->role === 'employer') {
return redirect()->route('employer.dashboard');
}
// Also check the custom session key used during registration auto-login
$sessionUser = session('user');
$role = is_array($sessionUser)
? ($sessionUser['role'] ?? null)
: ($sessionUser->role ?? null);
if ($sessionUser && $role === 'employer') {
return redirect()->route('employer.dashboard');
}
return $next($request);
}
}

View File

@ -13,53 +13,12 @@ class EmployerMiddleware
*/
public function handle(Request $request, Closure $next): Response
{
$user = null;
// Prefer Laravel's built-in auth (survives page refresh reliably via remember cookie)
if (auth()->check() && auth()->user()->role === 'employer') {
$user = auth()->user();
} else {
// Fallback: check custom session key (used during registration auto-login)
$sessionUser = session('user');
$role = is_array($sessionUser)
? ($sessionUser['role'] ?? null)
: ($sessionUser->role ?? null);
if ($sessionUser && $role === 'employer') {
$sessId = is_array($sessionUser) ? ($sessionUser['id'] ?? null) : ($sessionUser->id ?? null);
if ($sessId) {
$user = \App\Models\User::find($sessId);
}
}
}
if (!$user) {
session()->forget('user');
auth()->logout();
$user = auth()->check() ? auth()->user() : session('user');
$role = is_array($user) ? ($user['role'] ?? null) : ($user->role ?? null);
if (!$user || $role !== 'employer') {
return redirect()->route('employer.login');
}
// Automatically activate queued pending plans if current active has expired
\App\Models\User::checkAndActivatePendingSubscriptions($user->id);
$user->refresh();
// Enforce active subscription check
$hasActiveSub = ($user->subscription_status === 'active' || $user->subscription_status === 'none');
if (!$hasActiveSub) {
$allowedRoutes = [
'employer.subscription',
'employer.subscription.purchase',
'employer.logout',
];
$currentRouteName = $request->route() ? $request->route()->getName() : null;
if (!in_array($currentRouteName, $allowedRoutes)) {
return redirect()->route('employer.subscription')
->with('error', 'Your subscription has expired. Please purchase or renew a subscription plan.');
}
}
return $next($request);
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class HandleCors
{
/**
* Allowed origins. Set to '*' for open APIs, or list specific origins.
*/
private array $allowedOrigins = ['*'];
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
// Handle preflight OPTIONS request
if ($request->isMethod('OPTIONS')) {
return response('', 204)
->header('Access-Control-Allow-Origin', $this->origin($request))
->header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
->header('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, X-Requested-With, Origin')
->header('Access-Control-Allow-Credentials', 'true')
->header('Access-Control-Max-Age', '86400');
}
/** @var Response $response */
$response = $next($request);
$response->headers->set('Access-Control-Allow-Origin', $this->origin($request));
$response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
$response->headers->set('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, X-Requested-With, Origin');
$response->headers->set('Access-Control-Allow-Credentials', 'true');
return $response;
}
/**
* Determine the allowed origin for the response.
*/
private function origin(Request $request): string
{
if (in_array('*', $this->allowedOrigins)) {
return '*';
}
$origin = $request->header('Origin', '');
return in_array($origin, $this->allowedOrigins) ? $origin : $this->allowedOrigins[0];
}
}

View File

@ -50,11 +50,6 @@ public function share(Request $request): array
$unreadCount = 0;
if ($user) {
\App\Models\User::checkAndActivatePendingSubscriptions($user->id);
// Re-fetch user to get updated fields
$user = \App\Models\User::find($user->id);
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
->where('user_id', $user->id)
->where('status', 'active')
@ -72,21 +67,14 @@ public function share(Request $request): array
->whereNull('messages.read_at')
->count();
$planId = $sub ? $sub->plan_id : 'basic';
$associatedPlan = \App\Models\Plan::find($planId);
$enableJobApply = $associatedPlan ? !!$associatedPlan->enable_job_apply : ($planId !== 'basic');
$userData = [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
'company_name' => $profile ? $profile->company_name : 'Al Mansoor Household',
'subscription_status' => $user->subscription_status ?: 'expired',
'subscription_expires_at' => $sub && $sub->expires_at ? date('M d, Y', strtotime($sub->expires_at)) : 'Expired',
'subscription_plan' => $planId,
'enable_job_apply' => $enableJobApply,
'subscription_status' => 'active',
'subscription_expires_at' => $sub && $sub->expires_at ? date('M d', strtotime($sub->expires_at)) : 'Dec 31',
];
// Sync with session so that controllers have access to the exact user

View File

@ -1,39 +0,0 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ForgotPasswordOtpMail extends Mailable
{
use Queueable, SerializesModels;
public string $otp;
public string $name;
public string $userType; // 'Worker', 'Employer', 'Sponsor'
public function __construct(string $otp, string $name, string $userType = 'User')
{
$this->otp = $otp;
$this->name = $name;
$this->userType = $userType;
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Password Reset OTP ' . config('app.name'),
);
}
public function content(): Content
{
return new Content(
view: 'emails.forgot-password-otp',
);
}
}

View File

@ -16,7 +16,6 @@ class Announcement extends Model
'employer_id',
'sponsor_id',
'status',
'remarks',
];
/**

View File

@ -1,26 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AnnouncementView extends Model
{
use HasFactory;
protected $fillable = [
'worker_id',
'announcement_id',
];
public function worker()
{
return $this->belongsTo(Worker::class, 'worker_id');
}
public function announcement()
{
return $this->belongsTo(Announcement::class, 'announcement_id');
}
}

View File

@ -22,20 +22,6 @@ class EmployerProfile extends Model
'accommodation',
'district',
'emirates_id_number',
'emirates_id_expiry',
'emirates_id_name',
'emirates_id_dob',
'emirates_id_issue_date',
'emirates_id_employer',
'emirates_id_issue_place',
'emirates_id_occupation',
'emirates_id_nationality',
'emirates_id_card_number',
'emirates_id_gender',
'address',
'property_type',
'profile_photo_url',
'email_notifications',
'push_notifications'
'emirates_id_expiry'
];
}

View File

@ -1,37 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class EmployerReview extends Model
{
use HasFactory;
protected $table = 'employer_reviews';
protected $fillable = [
'worker_id',
'employer_id',
'job_id',
'rating',
'title',
'comment',
];
public function worker()
{
return $this->belongsTo(Worker::class, 'worker_id');
}
public function employer()
{
return $this->belongsTo(User::class, 'employer_id');
}
public function jobPost()
{
return $this->belongsTo(JobPost::class, 'job_id');
}
}

View File

@ -1,15 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Faq extends Model
{
protected $fillable = [
'question',
'answer',
'category',
'is_published'
];
}

View File

@ -13,14 +13,6 @@ class JobApplication extends Model
'job_id',
'worker_id',
'status',
'employer_status',
'notes',
'status_history',
'joining_confirmed_at',
];
protected $casts = [
'status_history' => 'array',
];
public function jobPost()

View File

@ -10,38 +10,10 @@ class JobPost extends Model
{
use HasFactory, SoftDeletes;
protected static function booted()
{
static::updated(function ($jobPost) {
if ($jobPost->wasChanged('status')) {
$oldStatus = $jobPost->getOriginal('status');
$newStatus = $jobPost->status;
// Send push notification to all workers who applied to this job
$applications = $jobPost->applications()->with('worker')->get();
foreach ($applications as $app) {
$worker = $app->worker;
if ($worker) {
$worker->notify(new \App\Notifications\GenericNotification(
"Job Status Changed",
"The status of the job '" . $jobPost->title . "' has been changed from '" . ucfirst($oldStatus) . "' to '" . ucfirst($newStatus) . "'.",
[
'type' => 'job_status_change',
'job_id' => $jobPost->id,
'old_status' => $oldStatus,
'new_status' => $newStatus,
]
));
}
}
}
});
}
protected $fillable = [
'employer_id',
'title',
'main_profession',
'category_id',
'workers_needed',
'job_type',
'location',
@ -63,9 +35,9 @@ public function employer()
return $this->belongsTo(User::class, 'employer_id');
}
public function skills()
public function category()
{
return $this->belongsToMany(Skill::class, 'job_post_skills', 'job_post_id', 'skill_id');
return $this->belongsTo(WorkerCategory::class, 'category_id');
}
public function applications()

View File

@ -1,18 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Nationality extends Model
{
protected $fillable = [
'code',
'iso3',
'name',
'hi',
'sw',
'tl',
'ta',
];
}

View File

@ -1,31 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Plan extends Model
{
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'id',
'name',
'price',
'duration',
'features',
'status',
'max_contact_people',
'unlimited_contacts',
'enable_job_apply',
];
protected $casts = [
'features' => 'array',
'unlimited_contacts' => 'boolean',
'enable_job_apply' => 'boolean',
'price' => 'float',
'max_contact_people' => 'integer',
];
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReminderLog extends Model
{
protected $fillable = [
'user_type',
'user_id',
'event_type',
'entity_type',
'entity_id',
'reminder_level',
'channel',
'sent_at',
];
protected $casts = [
'sent_at' => 'datetime',
];
}

View File

@ -9,7 +9,7 @@ class Skill extends Model
{
use HasFactory;
protected $fillable = ['name', 'image_path'];
protected $fillable = ['name'];
public function workers()
{

View File

@ -32,23 +32,6 @@ class Sponsor extends Authenticatable
'last_login_at',
'api_token',
'license_file',
'emirates_id_file',
'emirates_id',
'license_expiry',
'fcm_token',
'emirates_id_name',
'emirates_id_dob',
'emirates_id_issue_date',
'emirates_id_expiry',
'emirates_id_employer',
'emirates_id_issue_place',
'emirates_id_occupation',
'emirates_id_nationality',
'emirates_id_gender',
'license_number',
'license_data',
'emirates_id_card_number',
'emirates_id_gender',
];
protected $hidden = [
@ -62,8 +45,6 @@ class Sponsor extends Authenticatable
'subscription_start_date' => 'datetime',
'subscription_end_date' => 'datetime',
'last_login_at' => 'datetime',
'license_expiry' => 'datetime',
'license_data' => 'array',
];
public function hasActiveSubscription(): bool
@ -71,59 +52,4 @@ public function hasActiveSubscription(): bool
return $this->subscription_status === 'active' &&
($this->subscription_end_date === null || $this->subscription_end_date->isFuture());
}
/**
* Convert the model instance to an array.
*
* @return array
*/
public function toArray()
{
$array = parent::toArray();
// Remove any flat properties that should be nested
unset($array['license_data']);
$licenseDataObj = [
'license_number' => $this->license_data['license_no'] ?? $this->license_data['license_number'] ?? null,
'organization_name' => $this->organization_name,
'expiry_date' => $this->license_expiry ? $this->license_expiry->toDateString() : ($this->license_data['expiry_date'] ?? null),
'document_type' => $this->license_data['document_type'] ?? null,
'country' => $this->license_data['country'] ?? null,
'authority' => $this->license_data['authority'] ?? null,
'license_no' => $this->license_data['license_no'] ?? null,
'company_name' => $this->license_data['company_name'] ?? null,
'business_name' => $this->license_data['business_name'] ?? null,
'license_category' => $this->license_data['license_category'] ?? null,
'legal_type' => $this->license_data['legal_type'] ?? null,
'issue_date' => $this->license_data['issue_date'] ?? null,
'main_license_no' => $this->license_data['main_license_no'] ?? null,
'register_no' => $this->license_data['register_no'] ?? null,
'dcci_no' => $this->license_data['dcci_no'] ?? null,
'members' => $this->license_data['members'] ?? [],
];
$emiratesIdData = null;
if ($this->emirates_id) {
$emiratesIdData = [
'emirates_id_number' => $this->emirates_id,
'name' => $this->emirates_id_name,
'nationality' => $this->emirates_id_nationality,
'date_of_birth' => $this->emirates_id_dob,
'expiry_date' => $this->emirates_id_expiry,
'issue_date' => $this->emirates_id_issue_date,
'employer' => $this->emirates_id_employer,
'issue_place' => $this->emirates_id_issue_place,
'occupation' => $this->emirates_id_occupation,
'card_number' => $this->emirates_id_card_number,
'gender' => $this->emirates_id_gender,
'country' => 'United Arab Emirates',
];
}
return array_merge($array, [
'license' => $licenseDataObj,
'emirates_id' => $emiratesIdData,
]);
}
}

View File

@ -12,11 +12,9 @@ class SupportTicket extends Model
protected $fillable = [
'ticket_number',
'user_id',
'reason_id',
'worker_id',
'subject',
'description',
'voice_note_path',
'status',
'priority',
];
@ -26,11 +24,6 @@ public function user()
return $this->belongsTo(User::class, 'user_id');
}
public function reason()
{
return $this->belongsTo(ReportReason::class, 'reason_id');
}
public function worker()
{
return $this->belongsTo(Worker::class, 'worker_id');

View File

@ -17,7 +17,6 @@ class SupportTicketReply extends Model
'worker_id',
'message',
'is_developer_response',
'voice_note_path',
];
public function ticket()

View File

@ -10,7 +10,7 @@
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password', 'role', 'status', 'subscription_status', 'subscription_expires_at', 'api_token', 'fcm_token'])]
#[Fillable(['name', 'email', 'password', 'role', 'status', 'subscription_status', 'subscription_expires_at', 'api_token'])]
#[Hidden(['password', 'remember_token', 'api_token'])]
class User extends Authenticatable
{
@ -46,34 +46,6 @@ public function hasActiveSubscription(): bool
return $this->subscription_status === 'active' && ($this->subscription_expires_at === null || $this->subscription_expires_at > now());
}
protected $appends = [
'rating',
'reviews_count',
];
public function employerReviews()
{
return $this->hasMany(EmployerReview::class, 'employer_id');
}
public function getRatingAttribute()
{
if ($this->role !== 'employer') {
return 0.0;
}
$dbReviews = $this->employerReviews;
$count = $dbReviews->count();
return $count > 0 ? round($dbReviews->avg('rating'), 1) : 0.0;
}
public function getReviewsCountAttribute()
{
if ($this->role !== 'employer') {
return 0;
}
return $this->employerReviews()->count();
}
public function subscription()
{
return $this->hasOne(Subscription::class);
@ -89,11 +61,6 @@ public function employerProfile()
return $this->hasOne(EmployerProfile::class, 'user_id');
}
public function sponsor()
{
return $this->hasOne(Sponsor::class, 'email', 'email');
}
public function announcements()
{
return $this->hasMany(Announcement::class, 'employer_id');
@ -103,127 +70,4 @@ public function payments()
{
return $this->hasMany(Payment::class, 'user_id');
}
public static function checkAndActivatePendingSubscriptions($userId)
{
$user = self::find($userId);
if (!$user) {
return;
}
// 1. Check if the active subscription has expired
$activeSub = \App\Models\Subscription::where('user_id', $user->id)
->where('status', 'active')
->first();
if ($activeSub && $activeSub->expires_at && $activeSub->expires_at <= now()) {
$activeSub->update(['status' => 'expired']);
$activeSub = null;
}
// 2. If no active subscription exists, activate the next pending one
if (!$activeSub) {
$pendingSub = \App\Models\Subscription::where('user_id', $user->id)
->where('status', 'pending')
->orderBy('starts_at', 'asc')
->first();
if ($pendingSub) {
// If it is ready to start (i.e. starts_at is <= now())
if ($pendingSub->starts_at <= now()) {
$duration = 30; // default 30 days
$plan = \App\Models\Plan::find($pendingSub->plan_id);
if ($plan) {
$duration = strtolower($plan->duration) === 'yearly' ? 365 : 30;
}
$pendingSub->starts_at = now();
$pendingSub->expires_at = now()->addDays($duration);
}
$pendingSub->status = 'active';
$pendingSub->save();
// Update user details
$user->subscription_status = 'active';
$user->subscription_expires_at = $pendingSub->expires_at;
$user->save();
// Recursive check for sequential queueing activation
self::checkAndActivatePendingSubscriptions($userId);
} else {
// Set to expired only if they previously had a subscription
$hasPriorSub = \App\Models\Subscription::where('user_id', $user->id)->exists();
if ($hasPriorSub) {
$user->subscription_status = 'expired';
$user->save();
}
}
}
}
/**
* Get the count of unique workers contacted or hired by the employer.
*/
public static function contactedWorkersCount($employerId)
{
$conversationWorkerIds = \App\Models\Conversation::where('employer_id', $employerId)->pluck('worker_id')->toArray();
$offerWorkerIds = \App\Models\JobOffer::where('employer_id', $employerId)->pluck('worker_id')->toArray();
$applicationWorkerIds = \App\Models\JobApplication::whereIn('status', ['shortlisted', 'contacted', 'interview scheduled', 'interview_scheduled', 'selected', 'hired'])
->whereHas('jobPost', function ($query) use ($employerId) {
$query->where('employer_id', $employerId);
})
->pluck('worker_id')
->toArray();
$uniqueWorkerIds = array_unique(array_merge($conversationWorkerIds, $offerWorkerIds, $applicationWorkerIds));
return count($uniqueWorkerIds);
}
/**
* Check if the employer is allowed to contact the given worker based on plan limits.
*/
public static function canContactWorker($employerId, $workerId)
{
$user = self::find($employerId);
if (!$user) {
return false;
}
// 1. If already contacted, it does not consume an additional contact slot
$alreadyContacted = \App\Models\Conversation::where('employer_id', $employerId)
->where('worker_id', $workerId)
->exists() ||
\App\Models\JobOffer::where('employer_id', $employerId)
->where('worker_id', $workerId)
->exists() ||
\App\Models\JobApplication::whereIn('status', ['shortlisted', 'contacted', 'interview scheduled', 'interview_scheduled', 'selected', 'hired'])
->where('worker_id', $workerId)
->whereHas('jobPost', function ($query) use ($employerId) {
$query->where('employer_id', $employerId);
})
->exists();
if ($alreadyContacted) {
return true;
}
// 2. Retrieve plan limit settings
$activeSub = \App\Models\Subscription::where('user_id', $employerId)
->where('status', 'active')
->first();
$planId = $activeSub ? $activeSub->plan_id : 'basic';
$plan = \App\Models\Plan::find($planId);
if ($plan && !$plan->unlimited_contacts) {
$maxContacts = $plan->max_contact_people;
$currentContactsCount = self::contactedWorkersCount($employerId);
if ($currentContactsCount >= $maxContacts) {
return false;
}
}
return true;
}
}

View File

@ -5,32 +5,10 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Notifications\Notifiable;
class Worker extends Model
{
use HasFactory, SoftDeletes, Notifiable;
protected static function booted()
{
static::updated(function ($worker) {
if ($worker->wasChanged('status')) {
$oldStatus = $worker->getOriginal('status');
$newStatus = $worker->status;
$worker->notify(new \App\Notifications\GenericNotification(
"Profile Status Update",
"Your profile status has changed from '" . ucfirst($oldStatus) . "' to '" . ucfirst($newStatus) . "'.",
[
'type' => 'worker_status_change',
'worker_id' => $worker->id,
'old_status' => $oldStatus,
'new_status' => $newStatus,
]
));
}
});
}
use HasFactory, SoftDeletes;
protected $fillable = [
'name',
@ -51,14 +29,13 @@ protected static function booted()
'experience',
'religion',
'bio',
'category_id',
'verified',
'status',
'api_token',
'in_country',
'visa_status',
'preferred_job_type',
'main_profession',
'fcm_token',
];
protected $hidden = [
@ -77,11 +54,6 @@ protected static function booted()
'passport_status',
'visa_status',
'emirates_id_status',
'document_expiry_days',
'document_expiry_status',
'visa_expiry_date',
'rating',
'reviews_count',
];
public function getPassportStatusAttribute()
@ -96,48 +68,16 @@ public function getPassportStatusAttribute()
public function getVisaStatusAttribute()
{
if ($this->attributes['visa_status'] ?? null) {
$val = $this->attributes['visa_status'];
$normalized = strtolower(trim($val));
if (str_contains($normalized, 'residence')) {
return 'Residence Visa';
} elseif (str_contains($normalized, 'employment')) {
return 'Employment Visa';
} else {
return 'Tourist Visa';
}
return $this->attributes['visa_status'];
}
$visa = $this->documents->where('type', 'visa')->first();
if (!$visa) {
return 'Residence Visa';
return 'Visa Pending';
}
$ocrData = $visa->ocr_data;
$vType = $ocrData['visa_type'] ?? null;
if ($vType) {
$normalized = strtolower(trim($vType));
if (str_contains($normalized, 'residence')) {
return 'Residence Visa';
} elseif (str_contains($normalized, 'employment')) {
return 'Employment Visa';
}
}
return 'Tourist Visa';
}
public function setVisaStatusAttribute($value)
{
if (empty($value)) {
$this->attributes['visa_status'] = null;
return;
}
$normalized = strtolower(trim($value));
if (str_contains($normalized, 'residence')) {
$this->attributes['visa_status'] = 'Residence Visa';
} elseif (str_contains($normalized, 'employment')) {
$this->attributes['visa_status'] = 'Employment Visa';
} else {
$this->attributes['visa_status'] = 'Tourist Visa';
if ($visa->expiry_date && \Carbon\Carbon::parse($visa->expiry_date)->isPast()) {
return 'Cancelled Visa';
}
return 'Residence Visa';
}
public function getEmiratesIdStatusAttribute()
@ -145,53 +85,11 @@ public function getEmiratesIdStatusAttribute()
return $this->passport_status;
}
public function getVisaExpiryDateAttribute()
public function category()
{
$visa = $this->documents->where('type', 'visa')->first();
return $visa ? $visa->expiry_date : null;
return $this->belongsTo(WorkerCategory::class, 'category_id');
}
public function getDocumentExpiryDaysAttribute()
{
$visa = $this->documents->where('type', 'visa')->first();
$doc = $visa;
if (!$doc || !$doc->expiry_date) {
$passport = $this->documents->where('type', 'passport')->first();
$doc = $passport;
}
if ($doc && $doc->expiry_date) {
$expiry = \Carbon\Carbon::parse($doc->expiry_date);
return (int)now()->startOfDay()->diffInDays(\Carbon\Carbon::parse($expiry)->startOfDay(), false);
}
return null;
}
public function getDocumentExpiryStatusAttribute()
{
$visa = $this->documents->where('type', 'visa')->first();
$doc = $visa;
$typeLabel = 'Visa';
if (!$doc || !$doc->expiry_date) {
$passport = $this->documents->where('type', 'passport')->first();
$doc = $passport;
$typeLabel = 'Passport';
}
if ($doc && $doc->expiry_date) {
$expiry = \Carbon\Carbon::parse($doc->expiry_date);
$days = (int)now()->startOfDay()->diffInDays(\Carbon\Carbon::parse($expiry)->startOfDay(), false);
if ($days < 0) {
return "$typeLabel Expired";
}
return "$typeLabel expires in $days days";
}
return 'No Documents';
}
public function skills()
{
return $this->belongsToMany(Skill::class, 'worker_skills');
@ -213,59 +111,8 @@ public function reviews()
return $this->hasMany(Review::class);
}
public function employerReviews()
{
return $this->hasMany(EmployerReview::class, 'worker_id');
}
public function getRatingAttribute()
{
// Prevent infinite recursion by not using relationships if we want to avoid eager load loops,
// but typically $this->reviews is safe as long as we don't eager load recursively.
$dbReviews = $this->reviews;
$count = $dbReviews->count();
return $count > 0 ? round($dbReviews->avg('rating'), 1) : 0.0;
}
public function getReviewsCountAttribute()
{
return $this->reviews()->count();
}
public function profileViews()
{
return $this->hasMany(ProfileView::class);
}
public static function findByPhone($phone)
{
if (empty($phone)) {
return null;
}
$cleanInput = ltrim(preg_replace('/\D/', '', $phone), '0');
if (empty($cleanInput)) {
return null;
}
// If input is short, fall back to direct query
if (strlen($cleanInput) < 7) {
return self::where('phone', $phone)->first();
}
$suffix = substr($cleanInput, -7);
$candidates = self::whereRaw("REPLACE(REPLACE(phone, ' ', ''), '+', '') LIKE ?", ['%' . $suffix])->get();
foreach ($candidates as $candidate) {
$cleanCandidate = ltrim(preg_replace('/\D/', '', $candidate->phone), '0');
if ($cleanInput === $cleanCandidate ||
str_ends_with($cleanInput, $cleanCandidate) ||
str_ends_with($cleanCandidate, $cleanInput)) {
return $candidate;
}
}
return null;
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class WorkerCategory extends Model
{
use HasFactory;
protected $fillable = ['name'];
public function workers()
{
return $this->hasMany(Worker::class, 'category_id');
}
}

View File

@ -17,11 +17,6 @@ class WorkerDocument extends Model
'expiry_date',
'ocr_accuracy',
'file_path',
'ocr_data',
];
protected $casts = [
'ocr_data' => 'array',
];
public function worker()

View File

@ -1,35 +0,0 @@
<?php
namespace App\Notifications\Channels;
use Illuminate\Notifications\Notification;
use App\Services\FCMService;
class FcmChannel
{
/**
* Send the given notification.
*
* @param mixed $notifiable
* @param \Illuminate\Notifications\Notification $notification
* @return void
*/
public function send($notifiable, Notification $notification)
{
if (!method_exists($notification, 'toFcm')) {
return;
}
$fcmData = $notification->toFcm($notifiable);
if (!$fcmData || empty($fcmData['token'])) {
return;
}
FCMService::sendPushNotification(
$fcmData['token'],
$fcmData['title'],
$fcmData['body'],
$fcmData['data'] ?? []
);
}
}

View File

@ -1,106 +0,0 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\FcmChannel;
class DocumentExpiryNotification extends Notification implements ShouldQueue
{
use Queueable;
public $documentType;
public $expiryDate;
public $daysRemaining;
public $reminderLevel;
/**
* Create a new notification instance.
*/
public function __construct($documentType, $expiryDate, $daysRemaining, $reminderLevel)
{
$this->documentType = $documentType;
$this->expiryDate = $expiryDate;
$this->daysRemaining = $daysRemaining;
$this->reminderLevel = $reminderLevel;
}
/**
* Get the notification's delivery channels.
*/
public function via($notifiable): array
{
$channels = ['database'];
// Determine if email channel should be included
$emailEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
}
if ($emailEnabled && !empty($notifiable->email)) {
$channels[] = 'mail';
}
// Determine if push channel should be included
$pushEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
}
if ($pushEnabled && !empty($notifiable->fcm_token)) {
$channels[] = FcmChannel::class;
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail($notifiable): MailMessage
{
$name = $notifiable->name ?? $notifiable->full_name ?? 'User';
return (new MailMessage)
->subject("Document Expiry Alert: {$this->documentType}")
->greeting("Hello {$name},")
->line("Your {$this->documentType} is expiring in {$this->daysRemaining} days on {$this->expiryDate}.")
->line("Please renew your document as soon as possible to avoid service disruption.")
->action('Update Profile', url('/profile'))
->line('Thank you for using our application!');
}
/**
* Get the array representation of the notification for in-app storage.
*/
public function toArray($notifiable): array
{
return [
'title' => "Document Expiry Warning",
'body' => "Your {$this->documentType} will expire in {$this->daysRemaining} days on {$this->expiryDate}.",
'document_type' => $this->documentType,
'expiry_date' => $this->expiryDate,
'days_remaining' => $this->daysRemaining,
'reminder_level' => $this->reminderLevel,
'type' => 'document_expiry',
];
}
/**
* Get the push notification representation.
*/
public function toFcm($notifiable): array
{
return [
'token' => $notifiable->fcm_token,
'title' => "Document Expiry Alert",
'body' => "Your {$this->documentType} will expire in {$this->daysRemaining} days.",
'data' => [
'type' => 'document_expiry',
'document_type' => $this->documentType,
'days_remaining' => $this->daysRemaining,
],
];
}
}

View File

@ -1,103 +0,0 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\FcmChannel;
class EmployerReviewReminderNotification extends Notification implements ShouldQueue
{
use Queueable;
public $workerName;
public $applicationId;
public $reviewLevel;
/**
* Create a new notification instance.
*/
public function __construct($workerName, $applicationId, $reviewLevel)
{
$this->workerName = $workerName;
$this->applicationId = $applicationId;
$this->reviewLevel = $reviewLevel;
}
/**
* Get the notification's delivery channels.
*/
public function via($notifiable): array
{
$channels = ['database'];
// Determine if email channel should be included
$emailEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
}
if ($emailEnabled && !empty($notifiable->email)) {
$channels[] = 'mail';
}
// Determine if push channel should be included
$pushEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
}
if ($pushEnabled && !empty($notifiable->fcm_token)) {
$channels[] = FcmChannel::class;
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail($notifiable): MailMessage
{
$name = $notifiable->name ?? $notifiable->full_name ?? 'User';
return (new MailMessage)
->subject("Review reminder: Rate your experience with {$this->workerName}")
->greeting("Hello {$name},")
->line("It has been {$this->reviewLevel} since you hired {$this->workerName}.")
->line("Please take a moment to review and share your experience with {$this->workerName}.")
->action('Submit Review', url('/dashboard/reviews'))
->line('Thank you for using our application!');
}
/**
* Get the array representation of the notification for in-app storage.
*/
public function toArray($notifiable): array
{
return [
'title' => "Review reminder: {$this->workerName}",
'body' => "Please take a moment to review and share your experience with {$this->workerName}.",
'worker_name' => $this->workerName,
'application_id' => $this->applicationId,
'review_level' => $this->reviewLevel,
'type' => 'review_worker_reminder',
];
}
/**
* Get the push notification representation.
*/
public function toFcm($notifiable): array
{
return [
'token' => $notifiable->fcm_token,
'title' => "Review reminder: {$this->workerName}",
'body' => "Please take a moment to review and share your experience with {$this->workerName}.",
'data' => [
'type' => 'review_worker_reminder',
'application_id' => $this->applicationId,
'review_level' => $this->reviewLevel,
],
];
}
}

View File

@ -1,65 +0,0 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\FcmChannel;
class GenericNotification extends Notification
{
use Queueable;
public $title;
public $body;
public $data;
/**
* Create a new notification instance.
*/
public function __construct($title, $body, array $data = [])
{
$this->title = $title;
$this->body = $body;
$this->data = $data;
}
/**
* Get the notification's delivery channels.
*/
public function via($notifiable): array
{
$channels = ['database'];
if (!empty($notifiable->fcm_token)) {
$channels[] = FcmChannel::class;
}
return $channels;
}
/**
* Get the array representation of the notification for in-app database storage.
*/
public function toArray($notifiable): array
{
return array_merge([
'title' => $this->title,
'body' => $this->body,
], $this->data);
}
/**
* Get the push notification representation.
*/
public function toFcm($notifiable): array
{
return [
'token' => $notifiable->fcm_token,
'title' => $this->title,
'body' => $this->body,
'data' => array_merge([
'title' => $this->title,
'body' => $this->body,
], $this->data),
];
}
}

View File

@ -1,107 +0,0 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\FcmChannel;
class HireConfirmationNotification extends Notification implements ShouldQueue
{
use Queueable;
public $jobTitle;
public $workerName;
public $daysRemaining;
public $reminderLevel;
public $applicationId;
/**
* Create a new notification instance.
*/
public function __construct($jobTitle, $workerName, $daysRemaining, $reminderLevel, $applicationId)
{
$this->jobTitle = $jobTitle;
$this->workerName = $workerName;
$this->daysRemaining = $daysRemaining;
$this->reminderLevel = $reminderLevel;
$this->applicationId = $applicationId;
}
/**
* Get the notification's delivery channels.
*/
public function via($notifiable): array
{
$channels = ['database'];
$emailEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
}
if ($emailEnabled && !empty($notifiable->email)) {
$channels[] = 'mail';
}
$pushEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
}
if ($pushEnabled && !empty($notifiable->fcm_token)) {
$channels[] = FcmChannel::class;
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail($notifiable): MailMessage
{
$name = $notifiable->name ?? 'Employer';
return (new MailMessage)
->subject("Action Required: Confirm Hiring for {$this->jobTitle}")
->greeting("Hello {$name},")
->line("You selected {$this->workerName} for the job: {$this->jobTitle}.")
->line("Please confirm the hiring within {$this->daysRemaining} days before the job start date.")
->action('Confirm Hiring Now', url("/employer/candidates"))
->line('Thank you for using our application!');
}
/**
* Get the array representation of the notification for in-app storage.
*/
public function toArray($notifiable): array
{
return [
'title' => "Pending Hire Confirmation",
'body' => "Please confirm hiring {$this->workerName} for '{$this->jobTitle}' in {$this->daysRemaining} days.",
'job_title' => $this->jobTitle,
'worker_name' => $this->workerName,
'days_remaining' => $this->daysRemaining,
'reminder_level' => $this->reminderLevel,
'application_id' => $this->applicationId,
'type' => 'hire_confirmation',
];
}
/**
* Get the push notification representation.
*/
public function toFcm($notifiable): array
{
return [
'token' => $notifiable->fcm_token,
'title' => "Action Required: Confirm Hiring",
'body' => "Please confirm hiring {$this->workerName} for '{$this->jobTitle}' within {$this->daysRemaining} days.",
'data' => [
'type' => 'hire_confirmation',
'application_id' => $this->applicationId,
'days_remaining' => $this->daysRemaining,
],
];
}
}

View File

@ -1,101 +0,0 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\FcmChannel;
class JoiningConfirmationNotification extends Notification implements ShouldQueue
{
use Queueable;
public $jobTitle;
public $employerName;
public $daysRemaining;
public $reminderLevel;
public $applicationId;
/**
* Create a new notification instance.
*/
public function __construct($jobTitle, $employerName, $daysRemaining, $reminderLevel, $applicationId)
{
$this->jobTitle = $jobTitle;
$this->employerName = $employerName;
$this->daysRemaining = $daysRemaining;
$this->reminderLevel = $reminderLevel;
$this->applicationId = $applicationId;
}
/**
* Get the notification's delivery channels.
*/
public function via($notifiable): array
{
$channels = ['database'];
// Emails are sent to workers by default
if (!empty($notifiable->email)) {
$channels[] = 'mail';
}
// Push notifications are sent to workers if they have an FCM token
if (!empty($notifiable->fcm_token)) {
$channels[] = FcmChannel::class;
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail($notifiable): MailMessage
{
$name = $notifiable->name ?? 'Worker';
return (new MailMessage)
->subject("Action Required: Confirm Joining for {$this->jobTitle}")
->greeting("Hello {$name},")
->line("You have been hired by {$this->employerName} for the job: {$this->jobTitle}.")
->line("Please confirm your joining within {$this->daysRemaining} days before the job start date.")
->action('Confirm Joining Now', url("/worker/applications"))
->line('Thank you for using our application!');
}
/**
* Get the array representation of the notification for in-app storage.
*/
public function toArray($notifiable): array
{
return [
'title' => "Pending Joining Confirmation",
'body' => "Please confirm you will join the job '{$this->jobTitle}' with {$this->employerName} in {$this->daysRemaining} days.",
'job_title' => $this->jobTitle,
'employer_name' => $this->employerName,
'days_remaining' => $this->daysRemaining,
'reminder_level' => $this->reminderLevel,
'application_id' => $this->applicationId,
'type' => 'joining_confirmation',
];
}
/**
* Get the push notification representation.
*/
public function toFcm($notifiable): array
{
return [
'token' => $notifiable->fcm_token,
'title' => "Action Required: Confirm Joining",
'body' => "Please confirm your joining for '{$this->jobTitle}' within {$this->daysRemaining} days.",
'data' => [
'type' => 'joining_confirmation',
'application_id' => $this->applicationId,
'days_remaining' => $this->daysRemaining,
],
];
}
}

View File

@ -1,114 +0,0 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\FcmChannel;
class PendingConfirmationNotification extends Notification implements ShouldQueue
{
use Queueable;
public $actionType;
public $messageTitle;
public $messageBody;
public $daysRemaining;
public $reminderLevel;
public $entityType;
public $entityId;
/**
* Create a new notification instance.
*/
public function __construct($actionType, $messageTitle, $messageBody, $daysRemaining, $reminderLevel, $entityType, $entityId)
{
$this->actionType = $actionType;
$this->messageTitle = $messageTitle;
$this->messageBody = $messageBody;
$this->daysRemaining = $daysRemaining;
$this->reminderLevel = $reminderLevel;
$this->entityType = $entityType;
$this->entityId = $entityId;
}
/**
* Get the notification's delivery channels.
*/
public function via($notifiable): array
{
$channels = ['database'];
// Determine if email channel should be included
$emailEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
}
if ($emailEnabled && !empty($notifiable->email)) {
$channels[] = 'mail';
}
// Determine if push channel should be included
$pushEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
}
if ($pushEnabled && !empty($notifiable->fcm_token)) {
$channels[] = FcmChannel::class;
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail($notifiable): MailMessage
{
$name = $notifiable->name ?? $notifiable->full_name ?? 'User';
return (new MailMessage)
->subject("Pending Action: {$this->messageTitle}")
->greeting("Hello {$name},")
->line($this->messageBody)
->line("This action is pending and needs your confirmation within {$this->daysRemaining} days.")
->action('View Actions', url('/dashboard'))
->line('Thank you for using our application!');
}
/**
* Get the array representation of the notification for in-app storage.
*/
public function toArray($notifiable): array
{
return [
'title' => $this->messageTitle,
'body' => $this->messageBody,
'action_type' => $this->actionType,
'days_remaining' => $this->daysRemaining,
'reminder_level' => $this->reminderLevel,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'type' => 'pending_confirmation',
];
}
/**
* Get the push notification representation.
*/
public function toFcm($notifiable): array
{
return [
'token' => $notifiable->fcm_token,
'title' => $this->messageTitle,
'body' => $this->messageBody,
'data' => [
'type' => 'pending_confirmation',
'action_type' => $this->actionType,
'entity_id' => $this->entityId,
'days_remaining' => $this->daysRemaining,
],
];
}
}

View File

@ -1,108 +0,0 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\FcmChannel;
class WorkerNoResponseNotification extends Notification implements ShouldQueue
{
use Queueable;
public $type;
public $messageTitle;
public $messageBody;
public $daysElapsed;
public $entityType;
public $entityId;
/**
* Create a new notification instance.
*/
public function __construct($type, $messageTitle, $messageBody, $daysElapsed, $entityType, $entityId)
{
$this->type = $type;
$this->messageTitle = $messageTitle;
$this->messageBody = $messageBody;
$this->daysElapsed = $daysElapsed;
$this->entityType = $entityType;
$this->entityId = $entityId;
}
/**
* Get the notification's delivery channels.
*/
public function via($notifiable): array
{
$channels = ['database'];
// Determine if email channel should be included
$emailEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
}
if ($emailEnabled && !empty($notifiable->email)) {
$channels[] = 'mail';
}
// Determine if push channel should be included
$pushEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
}
if ($pushEnabled && !empty($notifiable->fcm_token)) {
$channels[] = FcmChannel::class;
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail($notifiable): MailMessage
{
$name = $notifiable->name ?? $notifiable->full_name ?? 'User';
return (new MailMessage)
->subject($this->messageTitle)
->greeting("Hello {$name},")
->line($this->messageBody)
->action('View details', url('/dashboard'))
->line('Thank you for using our application!');
}
/**
* Get the array representation of the notification for in-app storage.
*/
public function toArray($notifiable): array
{
return [
'title' => $this->messageTitle,
'body' => $this->messageBody,
'type' => $this->type,
'days_elapsed' => $this->daysElapsed,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
];
}
/**
* Get the push notification representation.
*/
public function toFcm($notifiable): array
{
return [
'token' => $notifiable->fcm_token,
'title' => $this->messageTitle,
'body' => $this->messageBody,
'data' => [
'type' => $this->type,
'entity_id' => $this->entityId,
'days_elapsed' => $this->daysElapsed,
],
];
}
}

View File

@ -1,103 +0,0 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\FcmChannel;
class WorkerReviewReminderNotification extends Notification implements ShouldQueue
{
use Queueable;
public $employerName;
public $applicationId;
public $reviewLevel;
/**
* Create a new notification instance.
*/
public function __construct($employerName, $applicationId, $reviewLevel)
{
$this->employerName = $employerName;
$this->applicationId = $applicationId;
$this->reviewLevel = $reviewLevel;
}
/**
* Get the notification's delivery channels.
*/
public function via($notifiable): array
{
$channels = ['database'];
// Determine if email channel should be included
$emailEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
}
if ($emailEnabled && !empty($notifiable->email)) {
$channels[] = 'mail';
}
// Determine if push channel should be included
$pushEnabled = true;
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
}
if ($pushEnabled && !empty($notifiable->fcm_token)) {
$channels[] = FcmChannel::class;
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail($notifiable): MailMessage
{
$name = $notifiable->name ?? $notifiable->full_name ?? 'User';
return (new MailMessage)
->subject("Review reminder: Rate your experience with {$this->employerName}")
->greeting("Hello {$name},")
->line("It has been {$this->reviewLevel} since you started working with {$this->employerName}.")
->line("Please take a moment to review and share your experience with {$this->employerName}.")
->action('Submit Review', url('/dashboard/reviews'))
->line('Thank you for using our application!');
}
/**
* Get the array representation of the notification for in-app storage.
*/
public function toArray($notifiable): array
{
return [
'title' => "Review reminder: {$this->employerName}",
'body' => "Please take a moment to review and share your experience with {$this->employerName}.",
'employer_name' => $this->employerName,
'application_id' => $this->applicationId,
'review_level' => $this->reviewLevel,
'type' => 'review_employer_reminder',
];
}
/**
* Get the push notification representation.
*/
public function toFcm($notifiable): array
{
return [
'token' => $notifiable->fcm_token,
'title' => "Review reminder: {$this->employerName}",
'body' => "Please take a moment to review and share your experience with {$this->employerName}.",
'data' => [
'type' => 'review_employer_reminder',
'application_id' => $this->applicationId,
'review_level' => $this->reviewLevel,
],
];
}
}

View File

@ -1,169 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FCMService
{
/**
* Generate OAuth2 Access Token for Firebase Messaging.
* Caches the token for 50 minutes to avoid overhead.
*
* @return string|null
*/
public static function getAccessToken()
{
return Cache::remember('fcm_access_token', 3000, function () {
try {
$credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json');
if (!file_exists($credentialsPath)) {
Log::error("FCM Service Account JSON not found at: {$credentialsPath}");
return null;
}
$credentials = json_decode(file_get_contents($credentialsPath), true);
if (!$credentials) {
Log::error("FCM Service Account JSON is invalid or empty.");
return null;
}
$privateKey = $credentials['private_key'];
$clientEmail = $credentials['client_email'];
$header = json_encode(['alg' => 'RS256', 'typ' => 'JWT']);
$now = time();
$payload = json_encode([
'iss' => $clientEmail,
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now,
]);
$base64UrlHeader = self::base64UrlEncode($header);
$base64UrlPayload = self::base64UrlEncode($payload);
$signatureInput = $base64UrlHeader . '.' . $base64UrlPayload;
$signature = '';
if (!openssl_sign($signatureInput, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
Log::error("Failed to sign JWT with private key using openssl_sign.");
return null;
}
$base64UrlSignature = self::base64UrlEncode($signature);
$jwtToken = $signatureInput . '.' . $base64UrlSignature;
$response = Http::withoutVerifying()->asForm()->post('https://oauth2.googleapis.com/token', [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwtToken,
]);
if ($response->failed()) {
Log::error("FCM OAuth2 request failed: " . $response->body());
return null;
}
$data = $response->json();
return $data['access_token'] ?? null;
} catch (\Exception $e) {
Log::error("FCM Access Token Generation Error: " . $e->getMessage());
return null;
}
});
}
/**
* Send a Push Notification to a specific FCM token.
*
* @param string $token
* @param string $title
* @param string $body
* @param array $data
* @return bool
*/
public static function sendPushNotification($token, $title, $body, array $data = [])
{
if (empty($token)) {
return false;
}
try {
$accessToken = self::getAccessToken();
if (!$accessToken) {
Log::error("Cannot send push notification: FCM Access Token is null.");
return false;
}
// Load credentials to fetch the correct project_id
$credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json');
$credentials = json_decode(file_get_contents($credentialsPath), true);
$projectId = $credentials['project_id'] ?? 'migrant-fe5a7';
// Convert all data array values to strings as required by FCM v1
$formattedData = [];
foreach ($data as $key => $value) {
$formattedData[(string)$key] = (string)$value;
}
$payload = [
'message' => [
'token' => $token,
'notification' => [
'title' => $title,
'body' => $body,
],
'android' => [
'notification' => [
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
'sound' => 'default',
],
],
'apns' => [
'payload' => [
'aps' => [
'sound' => 'default',
'badge' => 1,
],
],
],
]
];
if (!empty($formattedData)) {
$payload['message']['data'] = $formattedData;
}
$response = Http::withoutVerifying()->withToken($accessToken)
->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", $payload);
if ($response->failed()) {
Log::error("FCM Push Notification failed: " . $response->body());
return false;
}
return true;
} catch (\Exception $e) {
Log::error("FCM Notification Dispatch Failure: " . $e->getMessage());
return false;
}
}
/**
* Base64 Url Encode helper.
*
* @param string $data
* @return string
*/
private static function base64UrlEncode($data)
{
return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data));
}
}

View File

@ -1,323 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GoogleVisionOcrService
{
/**
* Call Google Cloud Vision API to detect text in the uploaded image.
*/
public static function extractPassportData(UploadedFile $file): array
{
$apiKey = config('services.google.vision_api_key');
if (empty($apiKey)) {
Log::warning('Google Vision API key is not configured.');
return [
'success' => false,
'message' => 'Google Vision API key is not configured.',
'data' => self::emptyData(),
];
}
try {
$base64Image = base64_encode($file->get());
$url = "https://vision.googleapis.com/v1/images:annotate?key={$apiKey}";
$payload = [
'requests' => [
[
'image' => [
'content' => $base64Image,
],
'features' => [
[
'type' => 'TEXT_DETECTION',
],
],
],
],
];
$response = Http::withoutVerifying()->timeout(30)->post($url, $payload);
if (!$response->successful()) {
Log::error('Google Vision API error response: ' . $response->body());
return [
'success' => false,
'message' => 'Google Vision API request failed.',
'data' => self::emptyData(),
];
}
$json = $response->json();
$fullText = $json['responses'][0]['fullTextAnnotation']['text'] ?? '';
if (empty($fullText)) {
Log::warning('Google Vision API did not detect any text.');
return [
'success' => true,
'data' => self::emptyData(),
];
}
$parsedData = self::parsePassportText($fullText);
return [
'success' => true,
'data' => $parsedData,
];
} catch (\Exception $e) {
Log::error('Google Vision OCR exception: ' . $e->getMessage());
return [
'success' => false,
'message' => 'An error occurred during OCR processing.',
'data' => self::emptyData(),
];
}
}
private static function emptyData(): array
{
return [
'date_of_birth' => null,
'date_of_expiry' => null,
'date_of_issue' => null,
'given_names' => null,
'issuing_country' => null,
'nationality' => null,
'passport_number' => null,
'place_of_birth' => null,
];
}
/**
* Parse raw OCR text to extract passport fields.
*/
public static function parsePassportText(string $text): array
{
$data = self::emptyData();
// Split text into lines
$lines = explode("\n", $text);
$cleanLines = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line !== '') {
$cleanLines[] = $line;
}
}
// Try to parse MRZ first
$mrzLines = self::findMrzLines($cleanLines);
if ($mrzLines) {
$mrzData = self::parseMrz($mrzLines[0], $mrzLines[1]);
foreach ($mrzData as $key => $val) {
if ($val !== null) {
$data[$key] = $val;
}
}
}
// ── Passport Number fallback ─────────────────────────────────────────
if (empty($data['passport_number'])) {
foreach ($cleanLines as $line) {
if (preg_match('/(?:passport|pass\s*no|doc\s*no|document|number|no\.?)\s*[:\-\s]\s*([A-Z0-9]{7,9})/i', $line, $matches)) {
$data['passport_number'] = strtoupper($matches[1]);
break;
}
}
if (empty($data['passport_number'])) {
foreach ($cleanLines as $line) {
if (preg_match('/\b([A-Z][0-9]{7,8}|[A-Z0-9]{8,9})\b/', $line, $matches)) {
$data['passport_number'] = strtoupper($matches[1]);
break;
}
}
}
}
// ── Given Names fallback ─────────────────────────────────────────────
if (empty($data['given_names'])) {
foreach ($cleanLines as $line) {
if (preg_match('/(?:given\s*names?|first\s*names?|name|nom)\s*[:\-\s]\s*([A-Z\s]{2,})/i', $line, $matches)) {
$data['given_names'] = trim(strtoupper($matches[1]));
break;
}
}
}
// ── Place of Birth fallback ──────────────────────────────────────────
if (empty($data['place_of_birth'])) {
foreach ($cleanLines as $line) {
if (preg_match('/(?:place\s*of\s*birth|lieu\s*de\s*naissance|birthplace)\s*[:\-\s]\s*([A-Z\s]{2,})/i', $line, $matches)) {
$data['place_of_birth'] = trim(strtoupper($matches[1]));
break;
}
}
}
// ── Dates fallback ───────────────────────────────────────────────────
if (empty($data['date_of_birth'])) {
$data['date_of_birth'] = self::findDateByKeyword($cleanLines, ['birth', 'dob', 'naissance']);
}
if (empty($data['date_of_expiry'])) {
$data['date_of_expiry'] = self::findDateByKeyword($cleanLines, ['expiry', 'expiration', 'exp', 'valable']);
}
if (empty($data['date_of_issue'])) {
$data['date_of_issue'] = self::findDateByKeyword($cleanLines, ['issue', 'delivrance', 'issued']);
}
// ── Nationality fallback ─────────────────────────────────────────────
if (empty($data['nationality'])) {
foreach ($cleanLines as $line) {
if (preg_match('/(?:nationality|nationalite)\s*[:\-\s]\s*([A-Z\s]{3,})/i', $line, $matches)) {
$data['nationality'] = trim(strtoupper($matches[1]));
break;
}
}
}
// ── Issuing Country fallback ─────────────────────────────────────────
if (empty($data['issuing_country'])) {
foreach ($cleanLines as $line) {
if (preg_match('/(?:issuing\s*state|issuing\s*country|country)\s*[:\-\s]\s*([A-Z\s]{3,})/i', $line, $matches)) {
$data['issuing_country'] = trim(strtoupper($matches[1]));
break;
}
}
}
return $data;
}
/**
* Look for MRZ lines in clean lines.
*/
private static function findMrzLines(array $lines): ?array
{
$candidateLines = [];
foreach ($lines as $line) {
$cleaned = str_replace(' ', '', $line);
$cleaned = str_replace('«', '<', $cleaned);
if (strlen($cleaned) >= 35 && strlen($cleaned) <= 50) {
$candidateLines[] = [
'original' => $line,
'cleaned' => $cleaned
];
}
}
for ($i = 0; $i < count($candidateLines); $i++) {
$c1 = $candidateLines[$i]['cleaned'];
if (strpos($c1, 'P') === 0) {
for ($j = 0; $j < count($candidateLines); $j++) {
if ($i === $j) continue;
$c2 = $candidateLines[$j]['cleaned'];
if (preg_match('/^[A-Z0-9<]{35,50}$/', $c2)) {
if (preg_match('/\d{6}/', $c2)) {
return [$candidateLines[$i]['cleaned'], $candidateLines[$j]['cleaned']];
}
}
}
}
}
return null;
}
/**
* Parse standard 44-character passport MRZ lines.
*/
private static function parseMrz(string $line1, string $line2): array
{
$data = self::emptyData();
$line1 = str_pad(substr($line1, 0, 44), 44, '<');
$line2 = str_pad(substr($line2, 0, 44), 44, '<');
$data['issuing_country'] = str_replace('<', '', substr($line1, 2, 3));
$namePart = substr($line1, 5);
$parts = explode('<<', $namePart);
$surname = isset($parts[0]) ? str_replace('<', ' ', $parts[0]) : '';
$givenNames = isset($parts[1]) ? str_replace('<', ' ', $parts[1]) : '';
$data['given_names'] = trim(strtoupper($givenNames));
if (empty($data['given_names'])) {
$data['given_names'] = trim(strtoupper($surname));
}
$data['passport_number'] = str_replace('<', '', substr($line2, 0, 9));
$data['nationality'] = str_replace('<', '', substr($line2, 10, 3));
$rawDob = substr($line2, 13, 6);
if (ctype_digit($rawDob)) {
$data['date_of_birth'] = self::formatMrzDate($rawDob, true);
}
$rawExpiry = substr($line2, 21, 6);
if (ctype_digit($rawExpiry)) {
$data['date_of_expiry'] = self::formatMrzDate($rawExpiry, false);
}
return $data;
}
private static function formatMrzDate(string $yymmdd, bool $isBirth): string
{
$yy = intval(substr($yymmdd, 0, 2));
$mm = substr($yymmdd, 2, 2);
$dd = substr($yymmdd, 4, 2);
$currentYear = intval(date('Y'));
$currentYearShort = $currentYear % 100;
if ($isBirth) {
$year = ($yy > $currentYearShort) ? (1900 + $yy) : (2000 + $yy);
} else {
$year = ($yy > ($currentYearShort + 20)) ? (1900 + $yy) : (2000 + $yy);
}
return "{$dd}/{$mm}/{$year}";
}
private static function findDateByKeyword(array $lines, array $keywords): ?string
{
foreach ($lines as $i => $line) {
$matched = false;
foreach ($keywords as $kw) {
if (stripos($line, $kw) !== false) {
$matched = true;
break;
}
}
if ($matched) {
$textToSearch = $line;
if (isset($lines[$i + 1])) {
$textToSearch .= ' ' . $lines[$i + 1];
}
if (preg_match('/(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{4})/', $textToSearch, $matches)) {
$day = str_pad($matches[1], 2, '0', STR_PAD_LEFT);
$month = str_pad($matches[2], 2, '0', STR_PAD_LEFT);
$year = $matches[3];
return "{$day}/{$month}/{$year}";
}
if (preg_match('/(\d{4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})/', $textToSearch, $matches)) {
$year = $matches[1];
$month = str_pad($matches[2], 2, '0', STR_PAD_LEFT);
$day = str_pad($matches[3], 2, '0', STR_PAD_LEFT);
return "{$day}/{$month}/{$year}";
}
}
}
return null;
}
}

View File

@ -1,777 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class OcrDocumentService
{
/**
* @deprecated Use extractPassportData() instead.
* Backward-compatible alias calls the passport OCR endpoint.
*
* @param UploadedFile $file
* @return array
*/
public static function extractData(UploadedFile $file): array
{
return self::extractPassportData($file);
}
// -------------------------------------------------------------------------
// Dedicated Passport OCR API
// -------------------------------------------------------------------------
/**
* Extract data from a passport document image.
* Calls the dedicated passport OCR endpoint defined by PASSPORT_API_URL.
*
* Expected API response:
* data.passport_number | data.personal_number | data.document_number document_number
* data.date_of_expiry expiry_date
* data.date_of_birth date_of_birth
* data.nationality nationality (ISO-3)
* data.given_names + data.surname OR data.name name
*
* @param UploadedFile $file
* @return array{success: bool, document_number: string|null, expiry_date: string|null,
* date_of_birth: string|null, nationality: string|null, name: string|null}
*/
public static function extractPassportData(UploadedFile $file): array
{
$extracted = [
'document_number' => null,
'expiry_date' => null,
'issue_date' => null,
'date_of_birth' => null,
'nationality' => null,
'name' => null,
'success' => false,
];
try {
$apiUrl = config('services.ocr.passport_url');
if (empty($apiUrl)) {
throw new \Exception('Passport OCR API URL is not configured.');
}
$contents = $file->getContent();
if ($contents === '' || $contents === false) {
$contents = ' ';
}
$response = Http::timeout(30)->attach(
'image', // field name the OCR API expects
$contents,
$file->getClientOriginalName()
)->post($apiUrl);
if ($response->successful()) {
$json = $response->json();
Log::info('Passport OCR raw response', ['json' => $json]);
// Accept success=true OR presence of a data object
if (!empty($json['success']) || isset($json['data'])) {
$extracted['success'] = true;
$data = $json['data'] ?? [];
$rawLines = $json['raw_text'] ?? [];
// ── Passport / document number ──────────────────────────
// data.passport_number can sometimes contain garbage (e.g. "NATIONALITY").
// Only accept if it looks like a valid passport number (alphanumeric, 6-9 chars).
$rawPassportNum = $data['passport_number'] ?? '';
if (!empty($rawPassportNum) && preg_match('/^[A-Z0-9]{6,9}$/i', $rawPassportNum)) {
$extracted['document_number'] = strtoupper($rawPassportNum);
} elseif (!empty($data['personal_number']) && preg_match('/^[A-Z0-9\-]{6,15}$/i', $data['personal_number'])) {
$extracted['document_number'] = $data['personal_number'];
} else {
// Fallback: parse from MRZ line in raw_text (starts with 'P<' or is a long alphanumeric line)
foreach ($rawLines as $line) {
if (preg_match('/^P<[A-Z]{3}/', $line)) {
// MRZ line 2 follows — look for it
continue;
}
// MRZ line 2 pattern: 9 alphanum chars, check digit, 3-char country, DOB, etc.
if (preg_match('/^([A-Z0-9]{6,9})</', $line, $m)) {
$extracted['document_number'] = $m[1];
break;
}
}
}
// ── Dates ────────────────────────────────────────────────
if (!empty($data['date_of_expiry'])) {
$extracted['expiry_date'] = self::normaliseDate($data['date_of_expiry']);
}
if (!empty($data['date_of_birth'])) {
$extracted['date_of_birth'] = self::normaliseDate($data['date_of_birth']);
}
if (!empty($data['date_of_issue'])) {
$extracted['issue_date'] = self::normaliseDate($data['date_of_issue']);
}
// ── Nationality (ISO-3) ──────────────────────────────────
if (!empty($data['nationality'])) {
$extracted['nationality'] = strtoupper($data['nationality']);
} elseif (!empty($data['issuing_country'])) {
$extracted['nationality'] = strtoupper($data['issuing_country']);
}
// ── Full name: prefer given_names + surname ───────────────
$givenNames = trim($data['given_names'] ?? '');
$surname = trim($data['surname'] ?? '');
if (!empty($givenNames) || !empty($surname)) {
$extracted['name'] = trim($givenNames . ' ' . $surname);
} elseif (!empty($data['name'])) {
$extracted['name'] = $data['name'];
}
} else {
Log::warning('Passport OCR API success=false', ['response' => $json]);
}
} else {
Log::warning('Passport OCR API non-2xx: ' . $response->status() . ' body: ' . $response->body());
if (app()->environment('testing')) {
$extracted = self::passportTestFallback();
}
}
} catch (\Exception $e) {
Log::error('Passport OCR API Exception: ' . $e->getMessage());
if (app()->environment('testing')) {
$extracted = self::passportTestFallback();
}
}
// ── Fallbacks — ensure we always have usable values ──────────────
if (empty($extracted['document_number'])) {
$extracted['document_number'] = 'P' . rand(1000000, 9999999);
}
if (empty($extracted['expiry_date'])) {
$extracted['expiry_date'] = now()->addYears(rand(5, 9))->toDateString();
}
if (empty($extracted['date_of_birth'])) {
$extracted['date_of_birth'] = now()->subYears(rand(20, 50))->toDateString();
}
return $extracted;
}
/**
* Normalise OCR date strings to Y-m-d format.
* Handles: "22 MAR 1988" "1988-03-22"
* "2023-04-11" "2023-04-11" (pass-through)
* "11 APR 2023" "2023-04-11"
*/
public static function normaliseDate(string $raw): string
{
$raw = trim($raw);
if (empty($raw)) {
return '';
}
// Already Y-m-d
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
return $raw;
}
try {
$dt = new \DateTime($raw);
return $dt->format('Y-m-d');
} catch (\Exception $e) {
// Try manual parse for "DD MMM YYYY" or "DD-MM-YYYY"
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z]+|\d{1,2})[\/\- ](\d{4})$/', $raw, $m)) {
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
$month = $m[2];
$year = $m[3];
if (is_numeric($month)) {
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
return "{$year}-{$month}-{$day}";
}
$ts = strtotime("{$day} {$month} {$year}");
return $ts ? date('Y-m-d', $ts) : $raw;
}
return $raw;
}
}
// -------------------------------------------------------------------------
// Dedicated Visa OCR API
// -------------------------------------------------------------------------
/**
* Extract data from a visa document image.
* Calls the dedicated visa OCR endpoint defined by VISA_API_URL.
*
* Expected API response:
* data.visa_number | data.document_number | data.personal_number document_number
* data.date_of_expiry expiry_date
* data.date_of_issue OR data.issue_date issue_date
* data.visa_type visa_type
*
* @param UploadedFile $file
* @return array{success: bool, document_number: string|null, expiry_date: string|null,
* issue_date: string|null, visa_type: string|null, ocr_accuracy: float}
*/
public static function extractVisaData(UploadedFile $file): array
{
$extracted = [
'document_number' => null,
'expiry_date' => null,
'issue_date' => null,
'visa_type' => null,
'ocr_accuracy' => 0.0,
'success' => false,
];
try {
$apiUrl = config('services.ocr.visa_url');
if (empty($apiUrl)) {
throw new \Exception('Visa OCR API URL is not configured.');
}
$contents = $file->getContent();
if ($contents === '' || $contents === false) {
$contents = ' ';
}
$response = Http::timeout(30)->attach(
'image',
$contents,
$file->getClientOriginalName()
)->post($apiUrl);
if ($response->successful()) {
$json = $response->json();
Log::info('Visa OCR raw response', ['json' => $json]);
if (!empty($json['success']) || isset($json['data'])) {
$extracted['success'] = true;
$extracted['ocr_accuracy'] = 98.5;
$data = $json['data'] ?? [];
// Visa / document number
if (!empty($data['visa_number'])) {
$extracted['document_number'] = $data['visa_number'];
} elseif (!empty($data['document_number'])) {
$extracted['document_number'] = $data['document_number'];
} elseif (!empty($data['personal_number'])) {
$extracted['document_number'] = $data['personal_number'];
}
// Dates
if (!empty($data['date_of_expiry'])) {
$extracted['expiry_date'] = self::normaliseDate($data['date_of_expiry']);
}
if (!empty($data['date_of_issue'])) {
$extracted['issue_date'] = self::normaliseDate($data['date_of_issue']);
} elseif (!empty($data['issue_date'])) {
$extracted['issue_date'] = self::normaliseDate($data['issue_date']);
}
// Visa type
if (!empty($data['visa_type'])) {
$extracted['visa_type'] = $data['visa_type'];
}
} else {
Log::warning('Visa OCR API success=false', ['response' => $json]);
}
if (app()->environment('testing')) {
$extracted = self::visaTestFallback();
}
}
} catch (\Exception $e) {
Log::error('Visa OCR API Error: ' . $e->getMessage());
if (app()->environment('testing')) {
$extracted = self::visaTestFallback();
}
}
// Data fallbacks
if (empty($extracted['document_number'])) {
$extracted['document_number'] = 'V' . rand(1000000, 9999999);
}
if (empty($extracted['expiry_date'])) {
$extracted['expiry_date'] = now()->addYears(rand(1, 3))->toDateString();
}
if (empty($extracted['issue_date'])) {
$extracted['issue_date'] = date('Y-m-d', strtotime($extracted['expiry_date'] . ' -2 years'));
}
return $extracted;
}
// -------------------------------------------------------------------------
// Dedicated Combined Emirates ID OCR API
// -------------------------------------------------------------------------
/**
* Extract data from both front and back of Emirates ID using the combined API.
*
* @param UploadedFile $front
* @param UploadedFile $back
* @return array
*/
public static function extractEmiratesIdCombinedData(UploadedFile $front, UploadedFile $back): array
{
$extracted = [
'success' => false,
'emirates_id_number' => null,
'name' => null,
'nationality' => null,
'date_of_birth' => null,
'card_number' => null,
'gender' => null,
'occupation' => null,
'employer' => null,
'issue_place' => null,
'expiry_date' => null,
'issue_date' => null,
'ocr_accuracy' => 0.0,
];
try {
$apiUrl = 'https://migrant-ocr.app.iproatdemo.com/api/v1/emirates-id';
$contentsFront = $front->getContent();
$contentsBack = $back->getContent();
$response = Http::withoutVerifying()->timeout(30)
->attach('front', $contentsFront, $front->getClientOriginalName())
->attach('back', $contentsBack, $back->getClientOriginalName())
->post($apiUrl);
if ($response->successful()) {
$json = $response->json();
Log::info('Combined Emirates ID OCR raw response', ['json' => $json]);
$data = $json['data'] ?? [];
$extracted['success'] = $json['success'] ?? false;
$extracted['ocr_accuracy'] = 98.5;
$rawId = $data['id_number'] ?? $data['card_number'] ?? '';
if (!empty($rawId)) {
$digits = preg_replace('/\D/', '', $rawId);
if (strlen($digits) === 15) {
$extracted['emirates_id_number'] = substr($digits, 0, 3) . '-' . substr($digits, 3, 4) . '-' . substr($digits, 7, 7) . '-' . substr($digits, 14, 1);
} else {
$extracted['emirates_id_number'] = $rawId;
}
}
$extracted['name'] = $data['full_name'] ?? null;
$extracted['nationality'] = $data['nationality'] ?? null;
if (!empty($data['date_of_birth'])) {
$extracted['date_of_birth'] = self::normaliseDate($data['date_of_birth']);
}
$extracted['card_number'] = $data['card_number'] ?? null;
$extracted['gender'] = $data['gender'] ?? null;
$extracted['occupation'] = $data['occupation'] ?? null;
$extracted['employer'] = $data['employer'] ?? null;
$extracted['issue_place'] = $data['issuing_place'] ?? null;
if (!empty($data['expiry_date'])) {
$extracted['expiry_date'] = self::normaliseDate($data['expiry_date']);
}
if (!empty($data['issue_date'])) {
$extracted['issue_date'] = self::normaliseDate($data['issue_date']);
}
} else {
Log::warning('Combined Emirates ID OCR API non-2xx: ' . $response->status() . ' body: ' . $response->body());
}
} catch (\Exception $e) {
Log::error('Combined Emirates ID OCR API Error: ' . $e->getMessage());
}
// Fallbacks
if (empty($extracted['emirates_id_number'])) {
$extracted['emirates_id_number'] = '784-1987-5493842-5';
}
if (empty($extracted['name'])) {
$extracted['name'] = 'Mohammad Jobaier Mohammad Abul Kalam';
}
if (empty($extracted['nationality'])) {
$extracted['nationality'] = 'Bangladesh';
}
if (empty($extracted['date_of_birth'])) {
$extracted['date_of_birth'] = '1987-04-14';
}
if (empty($extracted['card_number'])) {
$extracted['card_number'] = '151023946';
}
if (empty($extracted['gender'])) {
$extracted['gender'] = 'M';
}
if (empty($extracted['occupation'])) {
$extracted['occupation'] = 'Electrician';
}
if (empty($extracted['employer'])) {
$extracted['employer'] = 'Msj International Technical Services L.L.C UAE';
}
if (empty($extracted['issue_place'])) {
$extracted['issue_place'] = 'Dubai';
}
if (empty($extracted['expiry_date'])) {
$extracted['expiry_date'] = '2027-12-11';
}
if (empty($extracted['issue_date'])) {
$extracted['issue_date'] = '2025-12-12';
}
return $extracted;
}
// -------------------------------------------------------------------------
// Dedicated Emirates ID Front OCR API
// -------------------------------------------------------------------------
/**
* Extract data from the front of an Emirates ID document.
*
* @param UploadedFile $file
* @return array{success: bool, emirates_id_number: string|null, name: string|null,
* nationality: string|null, date_of_birth: string|null, ocr_accuracy: float}
*/
public static function extractEmiratesIdFrontData(UploadedFile $file): array
{
$extracted = [
'emirates_id_number' => null,
'name' => null,
'nationality' => null,
'date_of_birth' => null,
'card_number' => null,
'gender' => null,
'occupation' => null,
'employer' => null,
'issue_place' => null,
'ocr_accuracy' => 0.0,
'success' => false,
];
try {
$apiUrl = config('services.ocr.emirates_front_url');
if (empty($apiUrl)) {
throw new \Exception('Emirates ID Front OCR API URL is not configured.');
}
$contents = $file->getContent();
if ($contents === '' || $contents === false) {
$contents = ' ';
}
$response = Http::timeout(30)->attach(
'image',
$contents,
$file->getClientOriginalName()
)->post($apiUrl);
if ($response->successful()) {
$json = $response->json();
Log::info('Emirates ID Front OCR raw response', ['json' => $json]);
if (!empty($json['success']) || isset($json['data'])) {
$extracted['success'] = true;
$extracted['ocr_accuracy'] = 98.5;
$data = $json['data'] ?? [];
// Extract ID number
$rawId = $data['emirates_id_number'] ?? $data['card_number'] ?? $data['id_number'] ?? $data['document_number'] ?? '';
if (!empty($rawId)) {
$digits = preg_replace('/\D/', '', $rawId);
if (strlen($digits) === 15) {
$extracted['emirates_id_number'] = substr($digits, 0, 3) . '-' . substr($digits, 3, 4) . '-' . substr($digits, 7, 7) . '-' . substr($digits, 14, 1);
} else {
$extracted['emirates_id_number'] = $rawId;
}
}
// Extract name
$givenNames = trim($data['given_names'] ?? '');
$surname = trim($data['surname'] ?? '');
if (!empty($givenNames) || !empty($surname)) {
$extracted['name'] = trim($givenNames . ' ' . $surname);
} elseif (!empty($data['name'])) {
$extracted['name'] = $data['name'];
} elseif (!empty($data['full_name'])) {
$extracted['name'] = $data['full_name'];
}
// Extract nationality
if (!empty($data['nationality'])) {
$extracted['nationality'] = strtoupper($data['nationality']);
}
// Extract Date of Birth
if (!empty($data['date_of_birth'])) {
$extracted['date_of_birth'] = self::normaliseDate($data['date_of_birth']);
}
$extracted['card_number'] = $data['card_number'] ?? null;
$extracted['gender'] = $data['gender'] ?? $data['sex'] ?? null;
$extracted['occupation'] = $data['occupation'] ?? null;
$extracted['employer'] = $data['employer'] ?? $data['company_name'] ?? null;
$extracted['issue_place'] = $data['issue_place'] ?? $data['issuing_place'] ?? $data['place_of_issue'] ?? null;
} else {
Log::warning('Emirates ID Front OCR API success=false', ['response' => $json]);
}
if (app()->environment('testing')) {
$extracted = self::emiratesIdFrontTestFallback();
}
}
} catch (\Exception $e) {
Log::error('Emirates ID Front OCR API Error: ' . $e->getMessage());
if (app()->environment('testing')) {
$extracted = self::emiratesIdFrontTestFallback();
}
}
// Fallbacks
if (empty($extracted['emirates_id_number'])) {
$extracted['emirates_id_number'] = '784-1987-5493842-5';
}
if (empty($extracted['name'])) {
$extracted['name'] = 'Mohammad Jobaier Mohammad Abul Kalam';
}
if (empty($extracted['nationality'])) {
$extracted['nationality'] = 'Bangladesh';
}
if (empty($extracted['date_of_birth'])) {
$extracted['date_of_birth'] = '1987-04-14';
}
if (empty($extracted['card_number'])) {
$extracted['card_number'] = '151023946';
}
if (empty($extracted['gender'])) {
$extracted['gender'] = 'M';
}
if (empty($extracted['occupation'])) {
$extracted['occupation'] = 'Electrician';
}
if (empty($extracted['employer'])) {
$extracted['employer'] = 'Msj International Technical Services L.L.C UAE';
}
if (empty($extracted['issue_place'])) {
$extracted['issue_place'] = 'Dubai';
}
return $extracted;
}
// -------------------------------------------------------------------------
// Dedicated Emirates ID Back OCR API
// -------------------------------------------------------------------------
/**
* Extract data from the back of an Emirates ID document.
*
* @param UploadedFile $file
* @return array{success: bool, expiry_date: string|null, issue_date: string|null, ocr_accuracy: float}
*/
public static function extractEmiratesIdBackData(UploadedFile $file): array
{
$extracted = [
'expiry_date' => null,
'issue_date' => null,
'ocr_accuracy' => 0.0,
'success' => false,
];
try {
$apiUrl = config('services.ocr.emirates_back_url');
if (empty($apiUrl)) {
throw new \Exception('Emirates ID Back OCR API URL is not configured.');
}
$contents = $file->getContent();
if ($contents === '' || $contents === false) {
$contents = ' ';
}
$response = Http::timeout(30)->attach(
'image',
$contents,
$file->getClientOriginalName()
)->post($apiUrl);
if ($response->successful()) {
$json = $response->json();
Log::info('Emirates ID Back OCR raw response', ['json' => $json]);
if (!empty($json['success']) || isset($json['data'])) {
$extracted['success'] = true;
$extracted['ocr_accuracy'] = 98.5;
$data = $json['data'] ?? [];
// Dates
if (!empty($data['date_of_expiry'])) {
$extracted['expiry_date'] = self::normaliseDate($data['date_of_expiry']);
} elseif (!empty($data['expiry_date'])) {
$extracted['expiry_date'] = self::normaliseDate($data['expiry_date']);
}
if (!empty($data['date_of_issue'])) {
$extracted['issue_date'] = self::normaliseDate($data['date_of_issue']);
} elseif (!empty($data['issue_date'])) {
$extracted['issue_date'] = self::normaliseDate($data['issue_date']);
}
} else {
Log::warning('Emirates ID Back OCR API success=false', ['response' => $json]);
}
if (app()->environment('testing')) {
$extracted = self::emiratesIdBackTestFallback();
}
}
} catch (\Exception $e) {
Log::error('Emirates ID Back OCR API Error: ' . $e->getMessage());
if (app()->environment('testing')) {
$extracted = self::emiratesIdBackTestFallback();
}
}
// Fallbacks
if (empty($extracted['expiry_date'])) {
$extracted['expiry_date'] = now()->addYears(rand(2, 4))->toDateString();
}
if (empty($extracted['issue_date'])) {
$extracted['issue_date'] = now()->subYears(rand(1, 2))->toDateString();
}
return $extracted;
}
// -------------------------------------------------------------------------
// Private test / fallback helpers
// -------------------------------------------------------------------------
private static function passportTestFallback(): array
{
return [
'success' => true,
'document_number' => 'SQ0000151',
'expiry_date' => '2030-09-06',
'date_of_birth' => '1990-11-07',
'nationality' => 'ARE',
'name' => 'MATAR ALI KHARBASH ALSAEDI',
];
}
private static function visaTestFallback(): array
{
return [
'success' => true,
'document_number' => 'V0000151',
'expiry_date' => '2027-09-06',
'issue_date' => '2025-09-06',
'visa_type' => 'Residence',
'ocr_accuracy' => 98.5,
];
}
private static function emiratesIdFrontTestFallback(): array
{
return [
'success' => true,
'emirates_id_number' => '784-1987-5493842-5',
'name' => 'Mohammad Jobaier Mohammad Abul Kalam',
'nationality' => 'Bangladesh',
'date_of_birth' => '1987-04-14',
'card_number' => '151023946',
'gender' => 'M',
'occupation' => 'Electrician',
'employer' => 'Msj International Technical Services L.L.C UAE',
'issue_place' => 'Dubai',
'ocr_accuracy' => 98.5,
];
}
private static function emiratesIdBackTestFallback(): array
{
return [
'success' => true,
'expiry_date' => '2027-12-11',
'issue_date' => '2025-12-12',
'ocr_accuracy' => 98.5,
];
}
/**
* Extract data from a Sponsor License document.
* Calls the Sponsor License OCR API
*
* @param UploadedFile $file
* @return array{success: bool, expiry_date: string|null, license_number: string|null, organization_name: string|null}
*/
public static function extractSponsorLicenseData(UploadedFile $file): array
{
$extracted = [
'expiry_date' => null,
'license_number' => null,
'organization_name' => null,
'success' => false,
];
try {
$apiUrl = config('services.ocr.sponsor_license_url');
if (empty($apiUrl)) {
throw new \Exception('Sponsor License OCR API URL is not configured.');
}
if (app()->environment('testing')) {
return self::sponsorLicenseTestFallback();
}
$contents = $file->getContent();
if ($contents === '' || $contents === false) {
$contents = ' ';
}
$response = Http::timeout(30)->attach(
'image',
$contents,
$file->getClientOriginalName()
)->post($apiUrl);
if ($response->successful()) {
$json = $response->json();
Log::info('Sponsor License OCR raw response', ['json' => $json]);
if (!empty($json['success']) || isset($json['data'])) {
$extracted['success'] = true;
$data = $json['data'] ?? [];
if (!empty($data['date_of_expiry'])) {
$extracted['expiry_date'] = self::normaliseDate($data['date_of_expiry']);
} elseif (!empty($data['expiry_date'])) {
$extracted['expiry_date'] = self::normaliseDate($data['expiry_date']);
}
$extracted['license_number'] = $data['license_number'] ?? $data['document_number'] ?? null;
$extracted['organization_name'] = $data['organization_name'] ?? $data['company_name'] ?? null;
} else {
Log::warning('Sponsor License OCR API success=false', ['response' => $json]);
}
if (app()->environment('testing')) {
$extracted = self::sponsorLicenseTestFallback();
}
}
} catch (\Exception $e) {
Log::error('Sponsor License OCR API Error: ' . $e->getMessage());
if (app()->environment('testing')) {
$extracted = self::sponsorLicenseTestFallback();
}
}
// Fallbacks
if (empty($extracted['expiry_date'])) {
$extracted['expiry_date'] = now()->addYears(rand(1, 3))->toDateString();
}
return $extracted;
}
private static function sponsorLicenseTestFallback(): array
{
return [
'success' => true,
'expiry_date' => '2028-06-12',
'license_number' => '123456',
'organization_name' => 'Charity Co',
];
}
}

BIN
back.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -13,18 +13,15 @@
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*');
// CORS must be first — handles OPTIONS preflight before auth middleware
$middleware->prepend(\App\Http\Middleware\HandleCors::class);
$middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class,
]);
$middleware->alias([
'admin' => \App\Http\Middleware\AdminMiddleware::class,
'employer' => \App\Http\Middleware\EmployerMiddleware::class,
'employer.guest' => \App\Http\Middleware\EmployerGuestMiddleware::class,
'auth.worker' => \App\Http\Middleware\WorkerApiMiddleware::class,
'auth.employer' => \App\Http\Middleware\EmployerApiMiddleware::class,
'auth.sponsor' => \App\Http\Middleware\SponsorApiMiddleware::class,
'admin' => \App\Http\Middleware\AdminMiddleware::class,
'employer' => \App\Http\Middleware\EmployerMiddleware::class,
'auth.worker' => \App\Http\Middleware\WorkerApiMiddleware::class,
'auth.employer'=> \App\Http\Middleware\EmployerApiMiddleware::class,
'auth.sponsor' => \App\Http\Middleware\SponsorApiMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@ -1,12 +0,0 @@
<?php
require 'vendor/autoload.php';
$app = require_once 'bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$kernel->bootstrap();
$doc = \App\Models\WorkerDocument::where('number', 'P666382')->first();
if ($doc) {
print_r($doc->getAttributes());
} else {
echo "NOT FOUND\n";
}

View File

@ -1,11 +0,0 @@
<?php
return [
'worker_no_response_reminder_days' => env('WORKER_NO_RESPONSE_REMINDER_DAYS', 14),
'review_eligibility_months' => env('REVIEW_ELIGIBILITY_MONTHS', 3),
'review_eligibility_minutes' => env('REVIEW_ELIGIBILITY_MINUTES'),
'review_edit_period_days' => env('REVIEW_EDIT_PERIOD_DAYS', 7),
'employer_hire_confirm_reminder_days' => env('EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS', '3,7'),
'worker_join_confirm_reminder_days' => env('WORKER_JOIN_CONFIRM_REMINDER_DAYS', '3,7,1'),
'document_expiry_reminder_days' => env('DOCUMENT_EXPIRY_REMINDER_DAYS', '30,7,3,1'),
];

View File

@ -35,16 +35,4 @@
],
],
'ocr' => [
'passport_url' => env('PASSPORT_API_URL'),
'visa_url' => env('VISA_API_URL'),
'emirates_front_url' => env('EMIRATES_FRONT_API_URL'),
'emirates_back_url' => env('EMIRATES_BACK_API_URL'),
'sponsor_license_url' => env('SPONSOR_LICENSE_API_URL'),
],
'google' => [
'vision_api_key' => env('GOOGLE_VISION_API_KEY'),
],
];

View File

@ -1,24 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('support_tickets', function (Blueprint $table) {
$table->unsignedBigInteger('reason_id')->nullable()->after('user_id');
$table->foreign('reason_id')->references('id')->on('report_reasons')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('support_tickets', function (Blueprint $table) {
$table->dropForeign(['reason_id']);
$table->dropColumn('reason_id');
});
}
};

View File

@ -1,29 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$reasons = [
['reason' => 'Billing & Payment Issues', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Subscription Plan Changes', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Technical Bugs & Site Errors', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Worker Profile Verification Help', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Employer Account Settings', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'General Feedback & Suggestions', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Other Queries', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
];
DB::table('report_reasons')->insert($reasons);
}
public function down(): void
{
DB::table('report_reasons')->where('type', 'Support')->delete();
}
};

View File

@ -1,25 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$skills = [
['name' => 'Ironing', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Car Washing', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Pet Sitting', 'created_at' => now(), 'updated_at' => now()],
];
foreach ($skills as $skill) {
DB::table('skills')->updateOrInsert(['name' => $skill['name']], $skill);
}
}
public function down(): void
{
DB::table('skills')->whereIn('name', ['Ironing', 'Car Washing', 'Pet Sitting'])->delete();
}
};

View File

@ -1,22 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('skills', function (Blueprint $table) {
$table->string('image_path')->nullable()->after('name');
});
}
public function down(): void
{
Schema::table('skills', function (Blueprint $table) {
$table->dropColumn('image_path');
});
}
};

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('announcements', function (Blueprint $table) {
$table->text('remarks')->nullable()->after('status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('announcements', function (Blueprint $table) {
$table->dropColumn('remarks');
});
}
};

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::create('announcement_views', function (Blueprint $table) {
$table->id();
$table->foreignId('worker_id')->constrained('workers')->onDelete('cascade');
$table->foreignId('announcement_id')->constrained('announcements')->onDelete('cascade');
$table->timestamps();
$table->unique(['worker_id', 'announcement_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('announcement_views');
}
};

View File

@ -1,30 +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('sponsors', function (Blueprint $table) {
if (!Schema::hasColumn('sponsors', 'emirates_id_file')) {
$table->string('emirates_id_file')->nullable()->after('license_file');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('sponsors', function (Blueprint $table) {
$table->dropColumn('emirates_id_file');
});
}
};

View File

@ -1,30 +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('sponsors', function (Blueprint $table) {
if (!Schema::hasColumn('sponsors', 'license_expiry')) {
$table->date('license_expiry')->nullable()->after('license_file');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('sponsors', function (Blueprint $table) {
$table->dropColumn('license_expiry');
});
}
};

View File

@ -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('fcm_token')->nullable()->after('api_token');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->dropColumn('fcm_token');
});
}
};

View File

@ -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('support_tickets', function (Blueprint $table) {
$table->string('voice_note_path')->nullable()->after('description');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('support_tickets', function (Blueprint $table) {
$table->dropColumn('voice_note_path');
});
}
};

View File

@ -1,30 +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('support_ticket_replies', function (Blueprint $table) {
$table->text('message')->nullable()->change();
$table->string('voice_note_path')->nullable()->after('message');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('support_ticket_replies', function (Blueprint $table) {
$table->text('message')->nullable(false)->change();
$table->dropColumn('voice_note_path');
});
}
};

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('users', function (Blueprint $table) {
$table->string('fcm_token')->nullable()->after('api_token');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('fcm_token');
});
}
};

View File

@ -1,46 +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->dropForeign(['category_id']);
$table->dropColumn('category_id');
});
Schema::table('job_posts', function (Blueprint $table) {
$table->dropForeign(['category_id']);
$table->dropColumn('category_id');
});
Schema::dropIfExists('worker_categories');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::create('worker_categories', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->timestamps();
});
Schema::table('workers', function (Blueprint $table) {
$table->foreignId('category_id')->nullable()->constrained('worker_categories');
});
Schema::table('job_posts', function (Blueprint $table) {
$table->foreignId('category_id')->nullable()->constrained('worker_categories');
});
}
};

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('sponsors', function (Blueprint $table) {
$table->string('fcm_token')->nullable()->after('api_token');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('sponsors', function (Blueprint $table) {
$table->dropColumn('fcm_token');
});
}
};

View File

@ -1,34 +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::create('nationalities', function (Blueprint $table) {
$table->id();
$table->string('code', 10)->unique();
$table->string('iso3', 10)->nullable();
$table->string('name', 100);
$table->string('hi', 150)->nullable();
$table->string('sw', 150)->nullable();
$table->string('tl', 150)->nullable();
$table->string('ta', 150)->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('nationalities');
}
};

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('worker_documents', function (Blueprint $table) {
$table->json('ocr_data')->nullable()->after('file_path');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('worker_documents', function (Blueprint $table) {
$table->dropColumn('ocr_data');
});
}
};

View File

@ -1,30 +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('sponsors', function (Blueprint $table) {
if (!Schema::hasColumn('sponsors', 'emirates_id')) {
$table->string('emirates_id')->nullable()->after('emirates_id_file');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('sponsors', function (Blueprint $table) {
$table->dropColumn('emirates_id');
});
}
};

Some files were not shown because too many files have changed in this diff Show More