mohan #5
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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": {
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
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->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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user