diff --git a/app/Console/Commands/SendReminders.php b/app/Console/Commands/SendReminders.php new file mode 100644 index 0000000..519e4ab --- /dev/null +++ b/app/Console/Commands/SendReminders.php @@ -0,0 +1,291 @@ +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('Automated reminder system run complete!'); + } + + private function getReminderLevel(int $days): ?string + { + if ($days === 7) { + return '7_days'; + } elseif ($days === 3) { + return '3_days'; + } elseif ($days === 1) { + return '1_day'; + } + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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'); + } + } +} diff --git a/app/Http/Controllers/Api/WorkerReviewController.php b/app/Http/Controllers/Api/WorkerReviewController.php new file mode 100644 index 0000000..89bfd6b --- /dev/null +++ b/app/Http/Controllers/Api/WorkerReviewController.php @@ -0,0 +1,321 @@ +attributes->get('worker'); + + $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 3 months from joining date + if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < 3) { + return response()->json([ + 'success' => false, + 'message' => 'You can write a review only after 3 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 7-day edit window has expired + if ($review->created_at->addDays(7)->isPast()) { + return response()->json([ + 'success' => false, + 'message' => 'The 7-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); + } + + $editable = !$review->created_at->addDays(7)->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) { + $editable = !$rev->created_at->addDays(7)->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); + } + } +} diff --git a/app/Models/EmployerReview.php b/app/Models/EmployerReview.php new file mode 100644 index 0000000..03ebcc2 --- /dev/null +++ b/app/Models/EmployerReview.php @@ -0,0 +1,37 @@ +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'); + } +} diff --git a/app/Models/JobApplication.php b/app/Models/JobApplication.php index fc5f9ae..dd67e81 100644 --- a/app/Models/JobApplication.php +++ b/app/Models/JobApplication.php @@ -15,6 +15,7 @@ class JobApplication extends Model 'status', 'notes', 'status_history', + 'joining_confirmed_at', ]; protected $casts = [ diff --git a/app/Models/ReminderLog.php b/app/Models/ReminderLog.php new file mode 100644 index 0000000..69286a7 --- /dev/null +++ b/app/Models/ReminderLog.php @@ -0,0 +1,23 @@ + 'datetime', + ]; +} diff --git a/app/Models/Worker.php b/app/Models/Worker.php index 35b8ec6..3275e6f 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -5,10 +5,11 @@ 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; + use HasFactory, SoftDeletes, Notifiable; protected $fillable = [ 'name', diff --git a/app/Notifications/Channels/FcmChannel.php b/app/Notifications/Channels/FcmChannel.php new file mode 100644 index 0000000..9e72bf8 --- /dev/null +++ b/app/Notifications/Channels/FcmChannel.php @@ -0,0 +1,35 @@ +toFcm($notifiable); + if (!$fcmData || empty($fcmData['token'])) { + return; + } + + FCMService::sendPushNotification( + $fcmData['token'], + $fcmData['title'], + $fcmData['body'], + $fcmData['data'] ?? [] + ); + } +} diff --git a/app/Notifications/DocumentExpiryNotification.php b/app/Notifications/DocumentExpiryNotification.php new file mode 100644 index 0000000..3a64d75 --- /dev/null +++ b/app/Notifications/DocumentExpiryNotification.php @@ -0,0 +1,106 @@ +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, + ], + ]; + } +} diff --git a/app/Notifications/HireConfirmationNotification.php b/app/Notifications/HireConfirmationNotification.php new file mode 100644 index 0000000..21af1e4 --- /dev/null +++ b/app/Notifications/HireConfirmationNotification.php @@ -0,0 +1,107 @@ +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, + ], + ]; + } +} diff --git a/app/Notifications/JoiningConfirmationNotification.php b/app/Notifications/JoiningConfirmationNotification.php new file mode 100644 index 0000000..36aabc9 --- /dev/null +++ b/app/Notifications/JoiningConfirmationNotification.php @@ -0,0 +1,101 @@ +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, + ], + ]; + } +} diff --git a/app/Notifications/PendingConfirmationNotification.php b/app/Notifications/PendingConfirmationNotification.php new file mode 100644 index 0000000..e837264 --- /dev/null +++ b/app/Notifications/PendingConfirmationNotification.php @@ -0,0 +1,114 @@ +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, + ], + ]; + } +} diff --git a/database/migrations/2026_07_02_153000_create_notifications_and_reminder_logs.php b/database/migrations/2026_07_02_153000_create_notifications_and_reminder_logs.php new file mode 100644 index 0000000..e814e97 --- /dev/null +++ b/database/migrations/2026_07_02_153000_create_notifications_and_reminder_logs.php @@ -0,0 +1,65 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + // 2. Add joining_confirmed_at to job_applications + if (Schema::hasTable('job_applications') && !Schema::hasColumn('job_applications', 'joining_confirmed_at')) { + Schema::table('job_applications', function (Blueprint $table) { + $table->timestamp('joining_confirmed_at')->nullable(); + }); + } + + // 3. Create reminder_logs table to track and prevent duplicate reminders + if (!Schema::hasTable('reminder_logs')) { + Schema::create('reminder_logs', function (Blueprint $table) { + $table->id(); + $table->string('user_type'); + $table->unsignedBigInteger('user_id'); + $table->string('event_type'); + $table->string('entity_type'); + $table->unsignedBigInteger('entity_id'); + $table->string('reminder_level'); + $table->string('channel'); + $table->timestamp('sent_at')->useCurrent(); + $table->timestamps(); + + $table->index(['entity_type', 'entity_id', 'event_type', 'reminder_level'], 'idx_reminder_uniqueness'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('reminder_logs'); + if (Schema::hasTable('job_applications') && Schema::hasColumn('job_applications', 'joining_confirmed_at')) { + Schema::table('job_applications', function (Blueprint $table) { + $table->dropColumn('joining_confirmed_at'); + }); + } + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_07_02_155000_create_employer_reviews_table.php b/database/migrations/2026_07_02_155000_create_employer_reviews_table.php new file mode 100644 index 0000000..c69fe66 --- /dev/null +++ b/database/migrations/2026_07_02_155000_create_employer_reviews_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('worker_id')->constrained('workers')->onDelete('cascade'); + $table->foreignId('employer_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('job_id')->nullable()->constrained('job_posts')->onDelete('set null'); + $table->integer('rating'); + $table->string('title')->nullable(); + $table->text('comment')->nullable(); + $table->timestamps(); + + // Unique index to prevent duplicate reviews for the same job/employer combination + $table->unique(['worker_id', 'employer_id', 'job_id'], 'idx_worker_employer_job_unique'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('employer_reviews'); + } +}; diff --git a/public/swagger.json b/public/swagger.json index cabb43f..bc9338d 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -3312,6 +3312,185 @@ } } }, + "/workers/reviews": { + "get": { + "tags": [ + "Worker/Reviews" + ], + "summary": "List Worker Reviews", + "description": "Retrieves a paginated list of all reviews submitted by the authenticated worker.", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": "integer", + "default": 15 + } + } + ], + "responses": { + "200": { + "description": "Reviews retrieved successfully." + } + } + }, + "post": { + "tags": [ + "Worker/Reviews" + ], + "summary": "Submit Review for Employer", + "description": "Submits a review for an employer. Eligible only after 3 months from joining, for a confirmed hire.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "employer_id", + "rating", + "comment" + ], + "properties": { + "employer_id": { + "type": "integer", + "example": 5, + "description": "The ID of the employer (User ID)." + }, + "job_id": { + "type": "integer", + "example": 2, + "description": "Optional Job ID associated with the review." + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "example": 5, + "description": "Rating out of 5 stars." + }, + "title": { + "type": "string", + "example": "Great workplace", + "description": "Optional review title." + }, + "comment": { + "type": "string", + "example": "Highly supportive team and on-time salary payments.", + "description": "Review comment." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Review submitted successfully." + }, + "403": { + "description": "Eligibility check failed (no confirmed hire or under 3 months since joining)." + }, + "422": { + "description": "Validation error or duplicate review." + } + } + } + }, + "/workers/reviews/{id}": { + "get": { + "tags": [ + "Worker/Reviews" + ], + "summary": "Get Review Details", + "description": "Retrieves the details of a specific review submitted by the worker.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Review details retrieved successfully." + }, + "404": { + "description": "Review not found." + } + } + }, + "put": { + "tags": [ + "Worker/Reviews" + ], + "summary": "Edit Submitted Review", + "description": "Updates a review. Permitted only within a 7-day edit window from the initial submission date.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "rating", + "comment" + ], + "properties": { + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "example": 4 + }, + "title": { + "type": "string", + "example": "Good workplace" + }, + "comment": { + "type": "string", + "example": "Updating my comment with details." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Review edited successfully." + }, + "403": { + "description": "Edit option disabled because the 7-day window expired." + }, + "404": { + "description": "Review not found." + } + } + } + }, "/employers/register": { "post": { "tags": [ diff --git a/routes/api.php b/routes/api.php index 546b46d..b500737 100644 --- a/routes/api.php +++ b/routes/api.php @@ -111,6 +111,12 @@ Route::get('/workers/applied-jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobs']); Route::get('/workers/applied-jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobDetail']); Route::post('/workers/applied-jobs/{id}/withdraw', [\App\Http\Controllers\Api\WorkerJobController::class, 'withdrawApplication']); + + // Review Management (Worker reviewing Employer) + Route::get('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'getReviews']); + Route::post('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'addReview']); + Route::get('/workers/reviews/{id}', [\App\Http\Controllers\Api\WorkerReviewController::class, 'viewReview']); + Route::put('/workers/reviews/{id}', [\App\Http\Controllers\Api\WorkerReviewController::class, 'editReview']); }); // Protected Employer Mobile Endpoints (Token Authenticated via Bearer Token) diff --git a/routes/console.php b/routes/console.php index 3c9adf1..a86dc65 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('app:send-reminders')->daily(); diff --git a/tests/Feature/AutomatedReminderTest.php b/tests/Feature/AutomatedReminderTest.php new file mode 100644 index 0000000..f61180d --- /dev/null +++ b/tests/Feature/AutomatedReminderTest.php @@ -0,0 +1,281 @@ + 'John Worker', + 'email' => 'worker@example.com', + 'password' => bcrypt('password'), + 'phone' => '971500000001', + 'nationality' => 'Indian', + 'status' => 'active', + 'age' => 25, + 'salary' => 2000, + 'availability' => 'Immediate', + 'experience' => '2 Years', + 'religion' => 'Christian', + 'bio' => 'Helper info', + 'passport_status' => 'valid', + ], $overrides)); + } + + public function test_document_expiry_reminders() + { + Notification::fake(); + + // 1. Create a Worker with a document expiring in exactly 7 days + $worker = $this->createTestWorker(); + + $doc = WorkerDocument::create([ + 'worker_id' => $worker->id, + 'type' => 'passport', + 'number' => 'PASS123', + 'expiry_date' => now()->addDays(7)->toDateString(), + ]); + + // 2. Create an Employer (User) with Emirates ID expiring in exactly 3 days + $employer = User::create([ + 'name' => 'Alice Employer', + 'email' => 'employer@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + ]); + + $employerProfile = EmployerProfile::create([ + 'user_id' => $employer->id, + 'phone' => '971500000002', + 'address' => 'Dubai', + 'emirates_id_expiry' => now()->addDays(3)->toDateString(), + ]); + + // 3. Create a Sponsor with Emirates ID expiring in exactly 1 day + $sponsor = Sponsor::create([ + 'full_name' => 'Sponsor Bob', + 'email' => 'sponsor@example.com', + 'mobile' => '971500000003', + 'password' => bcrypt('password'), + 'emirates_id_expiry' => now()->addDays(1)->toDateString(), + ]); + + // Run the command + Artisan::call('app:send-reminders'); + + // Assert Worker got Passport expiry 7 days notification + Notification::assertSentTo( + $worker, + DocumentExpiryNotification::class, + function ($notification) { + return $notification->documentType === 'Passport' && + $notification->daysRemaining === 7 && + $notification->reminderLevel === '7_days'; + } + ); + + // Assert Employer got Emirates ID expiry 3 days notification + Notification::assertSentTo( + $employer, + DocumentExpiryNotification::class, + function ($notification) { + return $notification->documentType === 'Emirates ID' && + $notification->daysRemaining === 3 && + $notification->reminderLevel === '3_days'; + } + ); + + // Assert Sponsor got Emirates ID expiry 1 day notification + Notification::assertSentTo( + $sponsor, + DocumentExpiryNotification::class, + function ($notification) { + return $notification->documentType === 'Emirates ID' && + $notification->daysRemaining === 1 && + $notification->reminderLevel === '1_day'; + } + ); + + // Assert reminder logs were created + $this->assertDatabaseHas('reminder_logs', [ + 'user_type' => get_class($worker), + 'user_id' => $worker->id, + 'event_type' => 'document_expiry', + 'reminder_level' => '7_days', + ]); + + $this->assertDatabaseHas('reminder_logs', [ + 'user_type' => get_class($employer), + 'user_id' => $employer->id, + 'event_type' => 'document_expiry', + 'reminder_level' => '3_days', + ]); + + $this->assertDatabaseHas('reminder_logs', [ + 'user_type' => get_class($sponsor), + 'user_id' => $sponsor->id, + 'event_type' => 'document_expiry_emirates', + 'reminder_level' => '1_day', + ]); + + // Run the command again to ensure duplicate is prevented + Notification::fake(); + Artisan::call('app:send-reminders'); + + Notification::assertNothingSent(); + } + + public function test_hire_and_joining_confirmations_reminders() + { + Notification::fake(); + + // Create Employer + $employer = User::create([ + 'name' => 'Alice Employer', + 'email' => 'employer@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + ]); + + // Create Worker + $worker = $this->createTestWorker(); + + // Create a Job Post starting in 7 days + $job = JobPost::create([ + 'employer_id' => $employer->id, + 'title' => 'Housekeeper', + 'location' => 'Dubai', + 'salary' => 2000, + 'workers_needed' => 1, + 'job_type' => 'Full Time', + 'start_date' => now()->addDays(7)->toDateString(), + 'description' => 'Test job', + 'status' => 'active', + ]); + + // Create a Job Application with status 'selected' (needs Hire Confirmation) + $appSelected = JobApplication::create([ + 'job_id' => $job->id, + 'worker_id' => $worker->id, + 'status' => 'selected', + ]); + + // Create another Job Post starting in 3 days + $jobHired = JobPost::create([ + 'employer_id' => $employer->id, + 'title' => 'Nanny', + 'location' => 'Dubai', + 'salary' => 2500, + 'workers_needed' => 1, + 'job_type' => 'Full Time', + 'start_date' => now()->addDays(3)->toDateString(), + 'description' => 'Test job hired', + 'status' => 'active', + ]); + + // Create a Job Application with status 'hired' (needs Joining Confirmation) + $appHired = JobApplication::create([ + 'job_id' => $jobHired->id, + 'worker_id' => $worker->id, + 'status' => 'hired', + ]); + + // Run the command + Artisan::call('app:send-reminders'); + + // Assert Employer got Hire Confirmation 7 days notification + Notification::assertSentTo( + $employer, + HireConfirmationNotification::class, + function ($notification) use ($appSelected) { + return $notification->jobTitle === 'Housekeeper' && + $notification->workerName === 'John Worker' && + $notification->daysRemaining === 7 && + $notification->applicationId === $appSelected->id; + } + ); + + // Assert Worker got Joining Confirmation 3 days notification + Notification::assertSentTo( + $worker, + JoiningConfirmationNotification::class, + function ($notification) use ($appHired) { + return $notification->jobTitle === 'Nanny' && + $notification->employerName === 'Alice Employer' && + $notification->daysRemaining === 3 && + $notification->applicationId === $appHired->id; + } + ); + + // Verify duplicate prevention + Notification::fake(); + Artisan::call('app:send-reminders'); + Notification::assertNothingSent(); + } + + public function test_pending_direct_job_offer_reminders() + { + Notification::fake(); + + // Create Employer + $employer = User::create([ + 'name' => 'Alice Employer', + 'email' => 'employer@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + ]); + + // Create Worker + $worker = $this->createTestWorker(); + + // Create pending direct Job Offer starting in 1 day + $offer = JobOffer::create([ + 'employer_id' => $employer->id, + 'worker_id' => $worker->id, + 'work_date' => now()->addDays(1)->toDateString(), + 'location' => 'Dubai Marina', + 'salary' => 3000, + 'status' => 'pending', + ]); + + // Run the command + Artisan::call('app:send-reminders'); + + // Assert Worker got Pending Direct Job Offer 1 day notification + Notification::assertSentTo( + $worker, + PendingConfirmationNotification::class, + function ($notification) use ($offer) { + return $notification->actionType === 'accept_offer' && + $notification->daysRemaining === 1 && + $notification->entityId === $offer->id; + } + ); + + // Verify duplicate prevention + Notification::fake(); + Artisan::call('app:send-reminders'); + Notification::assertNothingSent(); + } +} diff --git a/tests/Feature/WorkerReviewTest.php b/tests/Feature/WorkerReviewTest.php new file mode 100644 index 0000000..b38d3c8 --- /dev/null +++ b/tests/Feature/WorkerReviewTest.php @@ -0,0 +1,271 @@ +employer = User::create([ + 'name' => 'Test Employer', + 'email' => 'employer@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + ]); + + // Create worker + $this->worker = Worker::create([ + 'name' => 'Worker User', + 'email' => 'worker@example.com', + 'password' => bcrypt('password'), + 'phone' => '971501234567', + 'nationality' => 'Indian', + 'status' => 'active', + 'api_token' => 'worker_api_token', + 'age' => 25, + 'salary' => 2000, + 'availability' => 'Immediate', + 'experience' => '2 Years', + 'religion' => 'Christian', + 'bio' => 'Helper info', + 'passport_status' => 'valid', + ]); + + // Create job post + $this->job = JobPost::create([ + 'employer_id' => $this->employer->id, + 'title' => 'Test Job', + 'location' => 'Dubai', + 'salary' => 2500, + 'workers_needed' => 1, + 'job_type' => 'Full Time', + 'start_date' => now()->subMonths(4)->toDateString(), + 'description' => 'Test Job Description', + 'status' => 'active', + ]); + } + + public function test_cannot_review_without_confirmed_hire_and_joining() + { + // Act: Try to review before any job application is made + $response = $this->postJson('/api/workers/reviews', [ + 'employer_id' => $this->employer->id, + 'job_id' => $this->job->id, + 'rating' => 5, + 'comment' => 'Great employer!', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(403); + $response->assertJsonPath('success', false); + $response->assertJsonPath('message', 'You can only review employers with a confirmed hire and completed joining.'); + } + + public function test_cannot_review_before_3_months_since_joining() + { + // Setup job application with hired status but joining_confirmed_at is only 2 months ago + JobApplication::create([ + 'job_id' => $this->job->id, + 'worker_id' => $this->worker->id, + 'status' => 'hired', + 'joining_confirmed_at' => now()->subMonths(2)->toDateString(), + ]); + + // Act: Try to review + $response = $this->postJson('/api/workers/reviews', [ + 'employer_id' => $this->employer->id, + 'job_id' => $this->job->id, + 'rating' => 5, + 'comment' => 'Great employer!', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(403); + $response->assertJsonPath('success', false); + $response->assertJsonPath('message', 'You can write a review only after 3 months from the joining date.'); + } + + public function test_can_review_after_3_months_since_joining() + { + // Setup job application with hired status and joining_confirmed_at 3.5 months ago + JobApplication::create([ + 'job_id' => $this->job->id, + 'worker_id' => $this->worker->id, + 'status' => 'hired', + 'joining_confirmed_at' => now()->subMonths(3)->subDays(15)->toDateString(), + ]); + + // Act: Post review + $response = $this->postJson('/api/workers/reviews', [ + 'employer_id' => $this->employer->id, + 'job_id' => $this->job->id, + 'rating' => 4, + 'title' => 'Fair Employer', + 'comment' => 'Very good experience overall.', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(201); + $response->assertJsonPath('success', true); + $response->assertJsonPath('data.review.rating', 4); + $response->assertJsonPath('data.review.title', 'Fair Employer'); + $response->assertJsonPath('data.review.comment', 'Very good experience overall.'); + + $this->assertDatabaseHas('employer_reviews', [ + 'worker_id' => $this->worker->id, + 'employer_id' => $this->employer->id, + 'job_id' => $this->job->id, + 'rating' => 4, + 'title' => 'Fair Employer', + ]); + } + + public function test_cannot_duplicate_reviews() + { + // Setup job application + JobApplication::create([ + 'job_id' => $this->job->id, + 'worker_id' => $this->worker->id, + 'status' => 'hired', + 'joining_confirmed_at' => now()->subMonths(4)->toDateString(), + ]); + + // Submit first review + EmployerReview::create([ + 'worker_id' => $this->worker->id, + 'employer_id' => $this->employer->id, + 'job_id' => $this->job->id, + 'rating' => 5, + 'comment' => 'Nice', + ]); + + // Act: Try to submit duplicate review + $response = $this->postJson('/api/workers/reviews', [ + 'employer_id' => $this->employer->id, + 'job_id' => $this->job->id, + 'rating' => 4, + 'comment' => 'Another review', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(422); + $response->assertJsonPath('success', false); + $response->assertJsonPath('message', 'You have already submitted a review for this job/employer.'); + } + + public function test_edit_review_within_7_days() + { + // Setup existing review created now + $review = EmployerReview::create([ + 'worker_id' => $this->worker->id, + 'employer_id' => $this->employer->id, + 'job_id' => $this->job->id, + 'rating' => 3, + 'title' => 'Initial Title', + 'comment' => 'Initial Comment', + ]); + + // Act: Edit the review + $response = $this->putJson("/api/workers/reviews/{$review->id}", [ + 'rating' => 5, + 'title' => 'Updated Title', + 'comment' => 'Updated Comment Details', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $response->assertJsonPath('data.review.rating', 5); + $response->assertJsonPath('data.review.title', 'Updated Title'); + $response->assertJsonPath('data.review.comment', 'Updated Comment Details'); + + $this->assertDatabaseHas('employer_reviews', [ + 'id' => $review->id, + 'rating' => 5, + 'title' => 'Updated Title', + ]); + } + + public function test_cannot_edit_review_after_7_days() + { + // Setup existing review created 8 days ago + $review = EmployerReview::create([ + 'worker_id' => $this->worker->id, + 'employer_id' => $this->employer->id, + 'job_id' => $this->job->id, + 'rating' => 3, + 'comment' => 'Initial Comment', + ]); + + // Force creation date to be 8 days ago + $review->created_at = now()->subDays(8); + $review->save(); + + // Act: Attempt to edit + $response = $this->putJson("/api/workers/reviews/{$review->id}", [ + 'rating' => 5, + 'comment' => 'Attempted Edit', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(403); + $response->assertJsonPath('success', false); + $response->assertJsonPath('message', 'The 7-day edit window for this review has expired.'); + } + + public function test_view_and_list_reviews() + { + // Create a review + $review = EmployerReview::create([ + 'worker_id' => $this->worker->id, + 'employer_id' => $this->employer->id, + 'job_id' => $this->job->id, + 'rating' => 5, + 'title' => 'Awesome place', + 'comment' => 'Had a wonderful time.', + ]); + + // 1. Get Single Review + $response = $this->getJson("/api/workers/reviews/{$review->id}", [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $response->assertJsonPath('data.review.title', 'Awesome place'); + $response->assertJsonPath('data.review.rating', 5); + + // 2. List Reviews + $response = $this->getJson('/api/workers/reviews', [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $response->assertJsonCount(1, 'data.reviews'); + $response->assertJsonPath('data.reviews.0.title', 'Awesome place'); + } +}