diff --git a/.env.example b/.env.example
index f7d5f88..d51e328 100644
--- a/.env.example
+++ b/.env.example
@@ -73,6 +73,7 @@ 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
diff --git a/app/Console/Commands/SendReminders.php b/app/Console/Commands/SendReminders.php
index 692ad8c..879bc01 100644
--- a/app/Console/Commands/SendReminders.php
+++ b/app/Console/Commands/SendReminders.php
@@ -374,7 +374,9 @@ private function checkWorkerNoResponse()
private function checkReviewReminders()
{
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
- $level = "{$eligibilityMonths}_months";
+ $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')
@@ -390,9 +392,16 @@ private function checkReviewReminders()
if (!$employer || !$worker) continue;
$joiningDate = Carbon::parse($app->joining_confirmed_at);
- $months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
+
+ $eligible = false;
+ if ($isMinutes) {
+ $eligible = $joiningDate->diffInMinutes(now(), false) >= $eligibilityMinutes;
+ } else {
+ $months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
+ $eligible = $months >= $eligibilityMonths;
+ }
- if ($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}.");
@@ -428,9 +437,16 @@ private function checkReviewReminders()
if (!$worker || !$employer) continue;
$joiningDate = $offer->work_date ? Carbon::parse($offer->work_date) : $offer->updated_at;
- $months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
+
+ $eligible = false;
+ if ($isMinutes) {
+ $eligible = $joiningDate->diffInMinutes(now(), false) >= $eligibilityMinutes;
+ } else {
+ $months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
+ $eligible = $months >= $eligibilityMonths;
+ }
- if ($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).");
diff --git a/app/Http/Controllers/Api/EmployerAnnouncementController.php b/app/Http/Controllers/Api/EmployerAnnouncementController.php
index 751ec5b..97c9375 100644
--- a/app/Http/Controllers/Api/EmployerAnnouncementController.php
+++ b/app/Http/Controllers/Api/EmployerAnnouncementController.php
@@ -108,15 +108,26 @@ 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' => $announcement->body,
+ 'body' => $content,
'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,
];
});
@@ -155,12 +166,33 @@ public function createAnnouncement(Request $request)
/** @var User $employer */
$employer = $request->attributes->get('employer');
- $validator = Validator::make($request->all(), [
+ $isCharity = $request->type === 'charity' ||
+ $request->has('event_date') ||
+ $request->has('location_details') ||
+ $request->has('provided_items') ||
+ $request->has('contact_person_name');
+
+ $rules = [
'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',
- ]);
+ '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);
if ($validator->fails()) {
return response()->json([
@@ -172,27 +204,45 @@ public function createAnnouncement(Request $request)
try {
$bodyText = $request->body ?? $request->content;
-
- // 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);
+
+ 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,
+ ]);
}
$announcement = Announcement::create([
'title' => $request->title,
'body' => $bodyText,
- 'type' => $request->type ?? 'info',
+ 'type' => $request->type ?? ($isCharity ? 'charity' : '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.',
@@ -200,12 +250,13 @@ public function createAnnouncement(Request $request)
'announcement' => [
'id' => $announcement->id,
'title' => $announcement->title,
- 'body' => $announcement->body,
+ 'body' => $content,
'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);
diff --git a/app/Http/Controllers/Api/EmployerProfileController.php b/app/Http/Controllers/Api/EmployerProfileController.php
index fa38aa6..258d6d2 100644
--- a/app/Http/Controllers/Api/EmployerProfileController.php
+++ b/app/Http/Controllers/Api/EmployerProfileController.php
@@ -400,12 +400,7 @@ public function getDashboard(Request $request)
// Recent Announcements / Events
$dbAnnouncements = \App\Models\Announcement::where('status', 'approved')->latest()->limit(2)->get();
$recentAnnouncements = $dbAnnouncements->map(function ($ann) {
- $body = $ann->body;
- $eventDate = null;
- $eventTime = null;
- $locationDetails = null;
- $locationPin = null;
- $providedItems = null;
+ $charityDetails = null;
if (strpos($ann->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($ann->body, true);
@@ -416,6 +411,7 @@ 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;
@@ -438,6 +434,7 @@ public function getDashboard(Request $request)
'provided_items' => $providedItems,
'created_at' => $ann->created_at->toISOString(),
'time_ago' => $ann->created_at->diffForHumans(),
+ 'charity_details' => $charityDetails,
];
});
diff --git a/app/Http/Controllers/Api/EmployerReviewController.php b/app/Http/Controllers/Api/EmployerReviewController.php
index 4ac983d..079c91c 100644
--- a/app/Http/Controllers/Api/EmployerReviewController.php
+++ b/app/Http/Controllers/Api/EmployerReviewController.php
@@ -71,12 +71,23 @@ public function addReview(Request $request)
], 403);
}
- $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);
+ // 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
diff --git a/app/Http/Controllers/Api/WorkerReviewController.php b/app/Http/Controllers/Api/WorkerReviewController.php
index 9d6eda9..a047c74 100644
--- a/app/Http/Controllers/Api/WorkerReviewController.php
+++ b/app/Http/Controllers/Api/WorkerReviewController.php
@@ -90,13 +90,23 @@ public function addReview(Request $request)
], 403);
}
- // Verify if completed configured months from joining date
- $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);
+ // 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
diff --git a/config/reminders.php b/config/reminders.php
index cdd7dc1..b06754a 100644
--- a/config/reminders.php
+++ b/config/reminders.php
@@ -3,6 +3,7 @@
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'),
diff --git a/phpunit.xml b/phpunit.xml
index f0048c7..abb2081 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -36,6 +36,7 @@
+
diff --git a/public/swagger.json b/public/swagger.json
index 7279854..ac10af7 100644
--- a/public/swagger.json
+++ b/public/swagger.json
@@ -4730,7 +4730,10 @@
"event_date",
"event_time",
"location_details",
- "location_pin"
+ "location_pin",
+ "contact_person_name",
+ "contact_number",
+ "country_code"
],
"properties": {
"title": {
@@ -4762,6 +4765,18 @@
"type": "string",
"format": "uri",
"example": "https://maps.app.goo.gl/xyz"
+ },
+ "contact_person_name": {
+ "type": "string",
+ "example": "Jane Doe"
+ },
+ "contact_number": {
+ "type": "string",
+ "example": "551234567"
+ },
+ "country_code": {
+ "type": "string",
+ "example": "+971"
}
}
}
diff --git a/tests/Feature/AnnouncementApiTest.php b/tests/Feature/AnnouncementApiTest.php
index 4f18472..0a3e0ec 100644
--- a/tests/Feature/AnnouncementApiTest.php
+++ b/tests/Feature/AnnouncementApiTest.php
@@ -347,4 +347,90 @@ public function test_admin_can_reject_announcement_with_remarks()
'remarks' => 'Inappropriate content or location details.',
]);
}
+
+ /**
+ * Test employer can post and get charity announcements with contact details.
+ */
+ public function test_employer_can_post_and_get_charity_announcement_with_contact_details()
+ {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer ' . $this->employerToken,
+ ])->postJson('/api/employers/announcements', [
+ 'title' => 'Free Dental Checkup Camp',
+ 'content' => 'Emirates Charity is providing free professional dental screening, cleanings, and educational packages for domestic helpers.',
+ 'provided_items' => 'Free Dental screening, cleanings, and wellness kits',
+ 'event_date' => '2026-06-15',
+ 'event_time' => '9:00 AM - 4:00 PM',
+ 'location_details' => 'Al Quoz Community Hall, Dubai',
+ 'location_pin' => 'https://maps.app.goo.gl/xyz',
+ 'contact_person_name' => 'Jane Doe',
+ 'contact_number' => '551234567',
+ 'country_code' => '+971',
+ ]);
+
+ $response->assertStatus(201)
+ ->assertJson([
+ 'success' => true,
+ 'message' => 'Announcement posted successfully.',
+ ])
+ ->assertJsonStructure([
+ 'data' => [
+ 'announcement' => [
+ 'id',
+ 'title',
+ 'body',
+ 'type',
+ 'status',
+ 'charity_details' => [
+ 'type',
+ 'provided_items',
+ 'event_date',
+ 'event_time',
+ 'location_details',
+ 'location_pin',
+ 'content',
+ 'contact_person_name',
+ 'contact_number',
+ 'country_code',
+ ]
+ ]
+ ]
+ ]);
+
+ $announcementId = $response->json('data.announcement.id');
+
+ // Approve it so it shows up in global get endpoint
+ Announcement::find($announcementId)->update(['status' => 'approved']);
+
+ // Test GET /api/employers/announcements
+ $getResponse = $this->withHeaders([
+ 'Authorization' => 'Bearer ' . $this->employerToken,
+ ])->getJson('/api/employers/announcements');
+
+ $getResponse->assertStatus(200);
+ $announcements = $getResponse->json('data.announcements');
+ $postedAnn = collect($announcements)->firstWhere('id', $announcementId);
+
+ $this->assertNotNull($postedAnn);
+ $this->assertEquals('Free Dental Checkup Camp', $postedAnn['title']);
+ $this->assertEquals('Jane Doe', $postedAnn['charity_details']['contact_person_name']);
+ $this->assertEquals('551234567', $postedAnn['charity_details']['contact_number']);
+ $this->assertEquals('+971', $postedAnn['charity_details']['country_code']);
+
+ // Test GET /api/employers/my-announcements
+ $getMyResponse = $this->withHeaders([
+ 'Authorization' => 'Bearer ' . $this->employerToken,
+ ])->getJson('/api/employers/my-announcements');
+
+ $getMyResponse->assertStatus(200);
+ $myAnnouncements = $getMyResponse->json('data.announcements');
+ $myPostedAnn = collect($myAnnouncements)->firstWhere('id', $announcementId);
+
+ $this->assertNotNull($myPostedAnn);
+ $this->assertEquals('Free Dental Checkup Camp', $myPostedAnn['title']);
+ $this->assertEquals('Jane Doe', $myPostedAnn['charity_details']['contact_person_name']);
+ $this->assertEquals('551234567', $myPostedAnn['charity_details']['contact_number']);
+ $this->assertEquals('+971', $myPostedAnn['charity_details']['country_code']);
+ }
}
+
diff --git a/tests/Feature/NewRemindersAndSettingsTest.php b/tests/Feature/NewRemindersAndSettingsTest.php
index d143764..86f439d 100644
--- a/tests/Feature/NewRemindersAndSettingsTest.php
+++ b/tests/Feature/NewRemindersAndSettingsTest.php
@@ -366,4 +366,119 @@ public function test_employer_review_controller_eligibility_and_edit_window()
$response->assertJsonPath('success', false);
$response->assertJsonPath('message', 'The 7-day edit window for this review has expired.');
}
+
+ public function test_minute_based_review_eligibility()
+ {
+ config(['reminders.review_eligibility_minutes' => 10]);
+
+ $worker = $this->createTestWorker();
+ $employer = $this->createTestEmployer();
+
+ $job = JobPost::create([
+ 'employer_id' => $employer->id,
+ 'title' => 'Nanny',
+ 'location' => 'Dubai',
+ 'salary' => 2500,
+ 'workers_needed' => 1,
+ 'job_type' => 'Full Time',
+ 'start_date' => now()->subDays(5)->toDateString(),
+ 'description' => 'Test job',
+ 'status' => 'active',
+ ]);
+
+ // JobApplication with joining_confirmed_at exactly 9 minutes ago
+ $app = JobApplication::create([
+ 'job_id' => $job->id,
+ 'worker_id' => $worker->id,
+ 'status' => 'hired',
+ 'joining_confirmed_at' => now()->subMinutes(9),
+ ]);
+
+ // 1. Worker review submission fails at 9 minutes
+ $response = $this->postJson('/api/workers/reviews', [
+ 'employer_id' => $employer->id,
+ 'job_id' => $job->id,
+ 'rating' => 5,
+ 'comment' => 'Great employer!',
+ ], [
+ 'Authorization' => 'Bearer worker_api_token'
+ ]);
+ $response->assertStatus(403);
+ $response->assertJsonPath('message', 'You can write a review only after 10 minutes from the joining date.');
+
+ // 2. Employer review submission fails at 9 minutes
+ $response = $this->postJson('/api/employers/reviews', [
+ 'worker_id' => $worker->id,
+ 'rating' => 5,
+ 'comment' => 'Great worker!',
+ ], [
+ 'Authorization' => 'Bearer employer_api_token'
+ ]);
+ $response->assertStatus(403);
+ $response->assertJsonPath('message', 'You can write a review only after 10 minutes from the joining date.');
+
+ // 3. Scheduler does NOT send reminder at 9 minutes
+ Notification::fake();
+ Artisan::call('app:send-reminders');
+ Notification::assertNothingSent();
+
+ // 4. Update joining date to 10 minutes ago
+ $app->joining_confirmed_at = now()->subMinutes(10);
+ $app->save();
+
+ // 5. Worker review submission succeeds at 10 minutes
+ $response = $this->postJson('/api/workers/reviews', [
+ 'employer_id' => $employer->id,
+ 'job_id' => $job->id,
+ 'rating' => 5,
+ 'comment' => 'Great employer!',
+ ], [
+ 'Authorization' => 'Bearer worker_api_token'
+ ]);
+ $response->assertStatus(201);
+
+ // 6. Employer review submission succeeds at 10 minutes
+ $response = $this->postJson('/api/employers/reviews', [
+ 'worker_id' => $worker->id,
+ 'rating' => 5,
+ 'comment' => 'Great worker!',
+ ], [
+ 'Authorization' => 'Bearer employer_api_token'
+ ]);
+ $response->assertStatus(201);
+
+ // 7. Scheduler sends reminders when updated to 10 minutes ago
+ $worker2 = $this->createTestWorker([
+ 'email' => 'worker2@example.com',
+ 'phone' => '971500000002',
+ 'api_token' => 'worker2_api_token'
+ ]);
+ $app2 = JobApplication::create([
+ 'job_id' => $job->id,
+ 'worker_id' => $worker2->id,
+ 'status' => 'hired',
+ 'joining_confirmed_at' => now()->subMinutes(10),
+ ]);
+
+ Notification::fake();
+ Artisan::call('app:send-reminders');
+
+ Notification::assertSentTo(
+ $worker2,
+ WorkerReviewReminderNotification::class,
+ function ($notification) use ($app2) {
+ return $notification->applicationId === $app2->id &&
+ $notification->reviewLevel === '10_minutes';
+ }
+ );
+
+ Notification::assertSentTo(
+ $employer,
+ EmployerReviewReminderNotification::class,
+ function ($notification) use ($app2) {
+ return $notification->applicationId === $app2->id &&
+ $notification->reviewLevel === '10_minutes';
+ }
+ );
+ }
}