From 8b8335eb325489fa3f9ef3431c8550e90b14209f Mon Sep 17 00:00:00 2001 From: mohanmd Date: Sun, 14 Jun 2026 03:06:58 +0530 Subject: [PATCH] worker register emirates id --- .../Api/EmployerWorkerController.php | 2 + .../Api/SupportTicketController.php | 21 ++++- app/Models/SupportTicketReply.php | 1 + app/Models/Worker.php | 42 +++++++++ ...e_path_to_support_ticket_replies_table.php | 30 +++++++ public/swagger.json | 20 +++-- resources/js/Pages/Employer/Workers/Index.jsx | 10 +++ .../Feature/EmployerSupportTicketApiTest.php | 85 +++++++++++++++++++ tests/Feature/EmployerWorkerFilterApiTest.php | 53 ++++++++++++ tests/Feature/WorkerSupportTicketApiTest.php | 52 ++++++++++++ 10 files changed, 306 insertions(+), 10 deletions(-) create mode 100644 database/migrations/2026_06_14_013119_add_voice_note_path_to_support_ticket_replies_table.php create mode 100644 tests/Feature/EmployerSupportTicketApiTest.php diff --git a/app/Http/Controllers/Api/EmployerWorkerController.php b/app/Http/Controllers/Api/EmployerWorkerController.php index 1f65a44..7c90f90 100644 --- a/app/Http/Controllers/Api/EmployerWorkerController.php +++ b/app/Http/Controllers/Api/EmployerWorkerController.php @@ -79,6 +79,8 @@ private function formatWorker(Worker $w) 'area' => $w->area, 'live_in_out' => $w->live_in_out, 'in_country' => (bool)$w->in_country, + 'document_expiry_days' => $w->document_expiry_days, + 'document_expiry_status' => $w->document_expiry_status, ]; } diff --git a/app/Http/Controllers/Api/SupportTicketController.php b/app/Http/Controllers/Api/SupportTicketController.php index 8a11e7b..153d476 100644 --- a/app/Http/Controllers/Api/SupportTicketController.php +++ b/app/Http/Controllers/Api/SupportTicketController.php @@ -122,7 +122,8 @@ public function replyToTicketFromWorker(Request $request, $id) } $validator = Validator::make($request->all(), [ - 'message' => 'required|string', + 'message' => 'required_without:voice_note|nullable|string', + 'voice_note' => 'nullable|file|mimes:mp3,wav,m4a,ogg,webm,aac,3gp,amr|max:10240', // 10MB limit ]); if ($validator->fails()) { @@ -133,10 +134,16 @@ 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') { @@ -150,6 +157,7 @@ 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); @@ -250,7 +258,8 @@ public function replyToTicketFromEmployer(Request $request, $id) } $validator = Validator::make($request->all(), [ - 'message' => 'required|string', + 'message' => 'required_without:voice_note|nullable|string', + 'voice_note' => 'nullable|file|mimes:mp3,wav,m4a,ogg,webm,aac,3gp,amr|max:10240', // 10MB limit ]); if ($validator->fails()) { @@ -261,10 +270,16 @@ 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') { @@ -278,6 +293,7 @@ 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); @@ -320,6 +336,7 @@ 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(), ]; }); diff --git a/app/Models/SupportTicketReply.php b/app/Models/SupportTicketReply.php index 35b546c..e9a73bb 100644 --- a/app/Models/SupportTicketReply.php +++ b/app/Models/SupportTicketReply.php @@ -17,6 +17,7 @@ class SupportTicketReply extends Model 'worker_id', 'message', 'is_developer_response', + 'voice_note_path', ]; public function ticket() diff --git a/app/Models/Worker.php b/app/Models/Worker.php index 28cbb7d..a6b8fec 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -55,6 +55,8 @@ class Worker extends Model 'passport_status', 'visa_status', 'emirates_id_status', + 'document_expiry_days', + 'document_expiry_status', ]; public function getPassportStatusAttribute() @@ -86,6 +88,46 @@ public function getEmiratesIdStatusAttribute() return $this->passport_status; } + 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 category() { return $this->belongsTo(WorkerCategory::class, 'category_id'); diff --git a/database/migrations/2026_06_14_013119_add_voice_note_path_to_support_ticket_replies_table.php b/database/migrations/2026_06_14_013119_add_voice_note_path_to_support_ticket_replies_table.php new file mode 100644 index 0000000..475bb3d --- /dev/null +++ b/database/migrations/2026_06_14_013119_add_voice_note_path_to_support_ticket_replies_table.php @@ -0,0 +1,30 @@ +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'); + }); + } +}; diff --git a/public/swagger.json b/public/swagger.json index 5ec367b..104b2b0 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -2324,16 +2324,18 @@ "requestBody": { "required": true, "content": { - "application/json": { + "multipart/form-data": { "schema": { "type": "object", - "required": [ - "message" - ], "properties": { "message": { "type": "string", "example": "I tried clearing cache and it still crashes." + }, + "voice_note": { + "type": "string", + "format": "binary", + "description": "Optional audio/voice note file attachment." } } } @@ -4484,16 +4486,18 @@ "requestBody": { "required": true, "content": { - "application/json": { + "multipart/form-data": { "schema": { "type": "object", - "required": [ - "message" - ], "properties": { "message": { "type": "string", "example": "I tried a different card and it still fails." + }, + "voice_note": { + "type": "string", + "format": "binary", + "description": "Optional audio/voice note file attachment." } } } diff --git a/resources/js/Pages/Employer/Workers/Index.jsx b/resources/js/Pages/Employer/Workers/Index.jsx index 513fd5c..6c4ea61 100644 --- a/resources/js/Pages/Employer/Workers/Index.jsx +++ b/resources/js/Pages/Employer/Workers/Index.jsx @@ -705,6 +705,16 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
{worker.visa_status}
+ {worker.document_expiry_status && ( +
+ 📅 {worker.document_expiry_status} +
)} diff --git a/tests/Feature/EmployerSupportTicketApiTest.php b/tests/Feature/EmployerSupportTicketApiTest.php new file mode 100644 index 0000000..9d7b97e --- /dev/null +++ b/tests/Feature/EmployerSupportTicketApiTest.php @@ -0,0 +1,85 @@ +token = 'employer-test-token-12345'; + $this->employer = User::create([ + 'name' => 'John Employer', + 'email' => 'employer_test@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + 'api_token' => $this->token, + ]); + } + + public function test_employer_can_reply_to_ticket_with_voice_note() + { + Storage::fake('public'); + + // Create a ticket + $ticket = SupportTicket::create([ + 'ticket_number' => 'TKT-654321', + 'user_id' => $this->employer->id, + 'subject' => 'Billing issue', + 'description' => 'I was double charged.', + 'status' => 'open', + 'priority' => 'high', + ]); + + $voiceFile = UploadedFile::fake()->create('reply_note.mp3', 400, 'audio/mpeg'); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + ])->postJson("/api/employers/tickets/{$ticket->id}/reply", [ + 'voice_note' => $voiceFile, + ]); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'success', + 'message', + 'reply' => [ + 'id', + 'message', + 'sender_name', + 'voice_note_url', + 'created_at', + ] + ]); + + $reply = SupportTicketReply::first(); + $this->assertNotNull($reply); + $this->assertNull($reply->message); + $this->assertNotNull($reply->voice_note_path); + Storage::disk('public')->assertExists($reply->voice_note_path); + + // Test details endpoint returns the voice note url for the reply + $detailResponse = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + ])->getJson("/api/employers/tickets/{$ticket->id}"); + + $detailResponse->assertStatus(200) + ->assertJsonFragment([ + 'voice_note_url' => asset('storage/' . $reply->voice_note_path) + ]); + } +} diff --git a/tests/Feature/EmployerWorkerFilterApiTest.php b/tests/Feature/EmployerWorkerFilterApiTest.php index 71391be..b23a216 100644 --- a/tests/Feature/EmployerWorkerFilterApiTest.php +++ b/tests/Feature/EmployerWorkerFilterApiTest.php @@ -399,4 +399,57 @@ public function test_get_candidates_area_filter() $this->assertCount(1, $candidates); $this->assertEquals('Worker India In Dubai', $candidates[0]['name']); } + + public function test_worker_list_includes_document_expiry_status() + { + $worker = Worker::create([ + 'name' => 'Worker With Expiry', + 'email' => 'worker_expiry@example.com', + 'phone' => '+971509999999', + 'password' => bcrypt('password'), + 'nationality' => 'Kenya', + 'category_id' => $this->category->id, + 'verified' => true, + 'status' => 'active', + 'preferred_location' => 'Dubai', + 'in_country' => true, + 'age' => 28, + 'salary' => 1500, + 'availability' => 'Immediate', + 'experience' => '3 Years', + 'preferred_job_type' => 'full-time', + 'live_in_out' => 'live_in', + 'gender' => 'female', + 'religion' => 'Christian', + 'bio' => 'experienced', + ]); + + $expiryDate = now()->addDays(45)->format('Y-m-d'); + WorkerDocument::create([ + 'worker_id' => $worker->id, + 'type' => 'visa', + 'number' => 'V123456', + 'expiry_date' => $expiryDate, + ]); + + WorkerDocument::create([ + 'worker_id' => $worker->id, + 'type' => 'passport', + 'number' => 'P123456', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + ])->getJson('/api/employers/workers'); + + $response->assertStatus(200); + $workers = $response->json('data.workers'); + + // Find the worker we just created in the response + $matchingWorker = collect($workers)->firstWhere('id', $worker->id); + + $this->assertNotNull($matchingWorker); + $this->assertEquals(45, $matchingWorker['document_expiry_days']); + $this->assertEquals('Visa expires in 45 days', $matchingWorker['document_expiry_status']); + } } diff --git a/tests/Feature/WorkerSupportTicketApiTest.php b/tests/Feature/WorkerSupportTicketApiTest.php index 1a80c44..dda0975 100644 --- a/tests/Feature/WorkerSupportTicketApiTest.php +++ b/tests/Feature/WorkerSupportTicketApiTest.php @@ -94,4 +94,56 @@ public function test_worker_can_create_ticket_with_reason_and_voice_note() Storage::disk('public')->assertExists($ticket->voice_note_path); } + + public function test_worker_can_reply_to_ticket_with_voice_note() + { + Storage::fake('public'); + + // Create a ticket + $ticket = SupportTicket::create([ + 'ticket_number' => 'TKT-123456', + 'worker_id' => $this->worker->id, + 'subject' => 'Need help', + 'description' => 'Help me please.', + 'status' => 'open', + 'priority' => 'medium', + ]); + + $voiceFile = UploadedFile::fake()->create('reply_note.wav', 300, 'audio/wav'); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + ])->postJson("/api/workers/tickets/{$ticket->id}/reply", [ + 'voice_note' => $voiceFile, + ]); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'success', + 'message', + 'reply' => [ + 'id', + 'message', + 'sender_name', + 'voice_note_url', + 'created_at', + ] + ]); + + $reply = \App\Models\SupportTicketReply::first(); + $this->assertNotNull($reply); + $this->assertNull($reply->message); + $this->assertNotNull($reply->voice_note_path); + Storage::disk('public')->assertExists($reply->voice_note_path); + + // Test details endpoint returns the voice note url + $detailResponse = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + ])->getJson("/api/workers/tickets/{$ticket->id}"); + + $detailResponse->assertStatus(200) + ->assertJsonFragment([ + 'voice_note_url' => asset('storage/' . $reply->voice_note_path) + ]); + } }