worker register emirates id

This commit is contained in:
mohanmd 2026-06-14 03:06:58 +05:30
parent 071e3a316b
commit 8b8335eb32
10 changed files with 306 additions and 10 deletions

View File

@ -79,6 +79,8 @@ private function formatWorker(Worker $w)
'area' => $w->area, 'area' => $w->area,
'live_in_out' => $w->live_in_out, 'live_in_out' => $w->live_in_out,
'in_country' => (bool)$w->in_country, 'in_country' => (bool)$w->in_country,
'document_expiry_days' => $w->document_expiry_days,
'document_expiry_status' => $w->document_expiry_status,
]; ];
} }

View File

@ -122,7 +122,8 @@ public function replyToTicketFromWorker(Request $request, $id)
} }
$validator = Validator::make($request->all(), [ $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()) { if ($validator->fails()) {
@ -133,10 +134,16 @@ public function replyToTicketFromWorker(Request $request, $id)
], 422); ], 422);
} }
$voiceNotePath = null;
if ($request->hasFile('voice_note')) {
$voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public');
}
$reply = SupportTicketReply::create([ $reply = SupportTicketReply::create([
'support_ticket_id' => $ticket->id, 'support_ticket_id' => $ticket->id,
'worker_id' => $worker->id, 'worker_id' => $worker->id,
'message' => $request->message, 'message' => $request->message,
'voice_note_path' => $voiceNotePath,
]); ]);
if ($ticket->status === 'resolved') { if ($ticket->status === 'resolved') {
@ -150,6 +157,7 @@ public function replyToTicketFromWorker(Request $request, $id)
'id' => $reply->id, 'id' => $reply->id,
'message' => $reply->message, 'message' => $reply->message,
'sender_name' => $reply->sender_name, '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(), 'created_at' => $reply->created_at->toIso8601String(),
] ]
], 201); ], 201);
@ -250,7 +258,8 @@ public function replyToTicketFromEmployer(Request $request, $id)
} }
$validator = Validator::make($request->all(), [ $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()) { if ($validator->fails()) {
@ -261,10 +270,16 @@ public function replyToTicketFromEmployer(Request $request, $id)
], 422); ], 422);
} }
$voiceNotePath = null;
if ($request->hasFile('voice_note')) {
$voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public');
}
$reply = SupportTicketReply::create([ $reply = SupportTicketReply::create([
'support_ticket_id' => $ticket->id, 'support_ticket_id' => $ticket->id,
'user_id' => $employer->id, 'user_id' => $employer->id,
'message' => $request->message, 'message' => $request->message,
'voice_note_path' => $voiceNotePath,
]); ]);
if ($ticket->status === 'resolved') { if ($ticket->status === 'resolved') {
@ -278,6 +293,7 @@ public function replyToTicketFromEmployer(Request $request, $id)
'id' => $reply->id, 'id' => $reply->id,
'message' => $reply->message, 'message' => $reply->message,
'sender_name' => $reply->sender_name, '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(), 'created_at' => $reply->created_at->toIso8601String(),
] ]
], 201); ], 201);
@ -320,6 +336,7 @@ public function getTicketDetail(Request $request, $id)
'sender_name' => $reply->sender_name, 'sender_name' => $reply->sender_name,
'is_admin' => $reply->user && $reply->user->role === 'admin', 'is_admin' => $reply->user && $reply->user->role === 'admin',
'is_developer_response' => (bool)$reply->is_developer_response, '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(), 'created_at' => $reply->created_at->toIso8601String(),
]; ];
}); });

View File

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

View File

@ -55,6 +55,8 @@ class Worker extends Model
'passport_status', 'passport_status',
'visa_status', 'visa_status',
'emirates_id_status', 'emirates_id_status',
'document_expiry_days',
'document_expiry_status',
]; ];
public function getPassportStatusAttribute() public function getPassportStatusAttribute()
@ -86,6 +88,46 @@ public function getEmiratesIdStatusAttribute()
return $this->passport_status; 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() public function category()
{ {
return $this->belongsTo(WorkerCategory::class, 'category_id'); return $this->belongsTo(WorkerCategory::class, 'category_id');

View File

@ -0,0 +1,30 @@
<?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

@ -2324,16 +2324,18 @@
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
"application/json": { "multipart/form-data": {
"schema": { "schema": {
"type": "object", "type": "object",
"required": [
"message"
],
"properties": { "properties": {
"message": { "message": {
"type": "string", "type": "string",
"example": "I tried clearing cache and it still crashes." "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": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
"application/json": { "multipart/form-data": {
"schema": { "schema": {
"type": "object", "type": "object",
"required": [
"message"
],
"properties": { "properties": {
"message": { "message": {
"type": "string", "type": "string",
"example": "I tried a different card and it still fails." "example": "I tried a different card and it still fails."
},
"voice_note": {
"type": "string",
"format": "binary",
"description": "Optional audio/voice note file attachment."
} }
} }
} }

View File

@ -705,6 +705,16 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
<div className="flex items-center space-x-1 text-[9px] font-black uppercase text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100"> <div className="flex items-center space-x-1 text-[9px] font-black uppercase text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100">
<span>{worker.visa_status}</span> <span>{worker.visa_status}</span>
</div> </div>
{worker.document_expiry_status && (
<div className={`flex items-center space-x-1 text-[9px] font-black uppercase px-1.5 py-0.5 rounded border ${
worker.document_expiry_days !== null && worker.document_expiry_days <= 30
? 'text-rose-700 bg-rose-50 border-rose-200 animate-pulse'
: worker.document_expiry_days !== null && worker.document_expiry_days <= 90
? 'text-amber-700 bg-amber-50 border-amber-200'
: 'text-[#185FA5] bg-blue-50 border-blue-100'
}`}>
<span>📅 {worker.document_expiry_status}</span>
</div>
)} )}
</div> </div>

View File

@ -0,0 +1,85 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\SupportTicket;
use App\Models\SupportTicketReply;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class EmployerSupportTicketApiTest extends TestCase
{
use RefreshDatabase;
protected $employer;
protected $token;
protected function setUp(): void
{
parent::setUp();
$this->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)
]);
}
}

View File

@ -399,4 +399,57 @@ public function test_get_candidates_area_filter()
$this->assertCount(1, $candidates); $this->assertCount(1, $candidates);
$this->assertEquals('Worker India In Dubai', $candidates[0]['name']); $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']);
}
} }

View File

@ -94,4 +94,56 @@ public function test_worker_can_create_ticket_with_reason_and_voice_note()
Storage::disk('public')->assertExists($ticket->voice_note_path); 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)
]);
}
} }