mohan #5
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
});
|
||||
|
||||
@ -17,6 +17,7 @@ class SupportTicketReply extends Model
|
||||
'worker_id',
|
||||
'message',
|
||||
'is_developer_response',
|
||||
'voice_note_path',
|
||||
];
|
||||
|
||||
public function ticket()
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
<span>{worker.visa_status}</span>
|
||||
</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>
|
||||
|
||||
|
||||
85
tests/Feature/EmployerSupportTicketApiTest.php
Normal file
85
tests/Feature/EmployerSupportTicketApiTest.php
Normal 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)
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user