mohan #25

Merged
mohanmd merged 10 commits from mohan into master 2026-07-04 16:10:27 +00:00
8 changed files with 543 additions and 0 deletions
Showing only changes of commit 52b47cc2ac - Show all commits

View File

@ -933,4 +933,155 @@ private function cleanVisaData(array $visaDataInput): array
]
];
}
/**
* Get details of an employer profile for worker view.
* GET /api/workers/employers/{id}
*/
public function getEmployerProfile(Request $request, $id)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
try {
// Find the employer (role should be 'employer')
$employer = \App\Models\User::where('id', $id)->where('role', 'employer')->first();
if (!$employer) {
return response()->json([
'success' => false,
'message' => 'Employer not found.'
], 404);
}
$empProfile = $employer->employerProfile;
// 1. Check access permissions for contact details
$hasAccess = false;
// Check if there is an active/previous JobOffer
$hasOffer = \App\Models\JobOffer::where('employer_id', $employer->id)
->where('worker_id', $worker->id)
->exists();
// Check if the worker has applied to any jobs of this employer
$hasApplication = \App\Models\JobApplication::where('worker_id', $worker->id)
->whereHas('jobPost', function ($query) use ($employer) {
$query->where('employer_id', $employer->id);
})
->exists();
// Check if there is an active/previous conversation
$hasConversation = \App\Models\Conversation::where('employer_id', $employer->id)
->where('worker_id', $worker->id)
->exists();
if ($hasOffer || $hasApplication || $hasConversation) {
$hasAccess = true;
}
// 2. Fetch active job details (if applicable)
$activeJobs = \App\Models\JobPost::where('employer_id', $employer->id)
->where('status', 'active')
->latest()
->get()
->map(function ($job) {
return [
'id' => $job->id,
'title' => $job->title,
'location' => $job->location,
'salary' => (int) $job->salary,
'job_type' => $job->job_type,
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
'description' => $job->description,
'requirements' => $job->requirements,
'posted_at' => $job->created_at->toIso8601String(),
];
});
// 3. Overall Rating and Review Summary
$reviewsQuery = \App\Models\EmployerReview::where('employer_id', $employer->id);
$totalReviews = $reviewsQuery->count();
$avgRating = $totalReviews > 0 ? round($reviewsQuery->avg('rating'), 1) : 0.0;
$starsBreakdown = [
'5' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 5)->count(),
'4' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 4)->count(),
'3' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 3)->count(),
'2' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 2)->count(),
'1' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 1)->count(),
];
// 4. Paginated Reviews list
$perPage = (int) $request->input('per_page', 10);
$reviewsPaginator = \App\Models\EmployerReview::where('employer_id', $employer->id)
->with('worker')
->latest()
->paginate($perPage);
$reviews = collect($reviewsPaginator->items())->map(function ($rev) {
return [
'id' => $rev->id,
'rating' => $rev->rating,
'title' => $rev->title,
'comment' => $rev->comment,
'created_at' => $rev->created_at->toIso8601String(),
'worker' => $rev->worker ? [
'id' => $rev->worker->id,
'name' => $rev->worker->name,
'nationality' => $rev->worker->nationality,
] : null,
];
});
// 5. Structure the response
$employerData = [
'id' => $employer->id,
'name' => $employer->name,
'email' => $hasAccess ? $employer->email : null,
'phone' => $hasAccess ? ($empProfile->phone ?? $employer->phone) : null,
'company_name' => $empProfile->company_name ?? 'Private Sponsor',
'city' => $empProfile->city ?? null,
'district' => $empProfile->district ?? null,
'nationality' => $empProfile->nationality ?? null,
'address' => $empProfile->address ?? null,
'accommodation' => $empProfile->accommodation ?? null,
'language' => $empProfile->language ?? null,
'profile_photo_url' => $empProfile->profile_photo_url ?? null,
'rating' => $avgRating,
'reviews_count' => $totalReviews,
'review_summary' => [
'total' => $totalReviews,
'average' => $avgRating,
'stars' => $starsBreakdown,
],
'active_jobs' => $activeJobs,
];
return response()->json([
'success' => true,
'data' => [
'employer' => $employerData,
'reviews' => [
'data' => $reviews,
'pagination' => [
'total' => $reviewsPaginator->total(),
'per_page' => $reviewsPaginator->perPage(),
'current_page' => $reviewsPaginator->currentPage(),
'last_page' => $reviewsPaginator->lastPage(),
]
]
]
], 200);
} catch (\Exception $e) {
logger()->error('Worker View Employer Profile Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while loading the employer profile.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
}

View File

@ -24,6 +24,12 @@ public function addReview(Request $request)
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
// Sanitize job_id: if not a valid existing JobPost ID, treat it as null (job id is not needed/optional)
$jobIdInput = $request->input('job_id');
if (empty($jobIdInput) || !is_numeric($jobIdInput) || !\App\Models\JobPost::where('id', $jobIdInput)->exists()) {
$request->merge(['job_id' => null]);
}
$validator = Validator::make($request->all(), [
'employer_id' => 'required|exists:users,id',
'job_id' => 'nullable|exists:job_posts,id',

View File

@ -33,6 +33,8 @@ public function handle(Request $request, Closure $next): Response
}
if (!$user) {
session()->forget('user');
auth()->logout();
return redirect()->route('employer.login');
}

View File

@ -7305,6 +7305,161 @@
}
}
},
"/workers/employers/{id}": {
"get": {
"tags": [
"Worker/Employers"
],
"summary": "Worker View Employer Profile",
"description": "Allows authenticated workers to view the complete profile of a specific employer, including company details, ratings, review summaries, active jobs, and a paginated list of worker-submitted reviews. Contact details are conditionally visible depending on whether the worker has a past/present connection with the employer.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The unique database ID of the employer.",
"schema": {
"type": "integer",
"example": 1
}
},
{
"name": "per_page",
"in": "query",
"required": false,
"description": "Number of reviews to display per page (default: 10).",
"schema": {
"type": "integer",
"example": 10
}
},
{
"name": "page",
"in": "query",
"required": false,
"description": "The page number of the reviews list to fetch.",
"schema": {
"type": "integer",
"example": 1
}
}
],
"responses": {
"200": {
"description": "Employer profile loaded successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"type": "object",
"properties": {
"employer": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 1 },
"name": { "type": "string", "example": "Jane Sponsor" },
"email": { "type": "string", "nullable": true, "example": "sponsor@example.com" },
"phone": { "type": "string", "nullable": true, "example": "+971501112222" },
"company_name": { "type": "string", "example": "Sponsor Co" },
"city": { "type": "string", "nullable": true, "example": "Dubai" },
"district": { "type": "string", "nullable": true, "example": "Dubai Marina" },
"nationality": { "type": "string", "nullable": true, "example": "Emirati" },
"address": { "type": "string", "nullable": true, "example": "123 Street" },
"accommodation": { "type": "string", "nullable": true, "example": "Provided" },
"language": { "type": "string", "nullable": true, "example": "English" },
"profile_photo_url": { "type": "string", "nullable": true, "example": "https://example.com/photo.jpg" },
"rating": { "type": "number", "format": "float", "example": 4.5 },
"reviews_count": { "type": "integer", "example": 12 },
"review_summary": {
"type": "object",
"properties": {
"total": { "type": "integer", "example": 12 },
"average": { "type": "number", "example": 4.5 },
"stars": {
"type": "object",
"properties": {
"5": { "type": "integer", "example": 8 },
"4": { "type": "integer", "example": 2 },
"3": { "type": "integer", "example": 1 },
"2": { "type": "integer", "example": 1 },
"1": { "type": "integer", "example": 0 }
}
}
}
},
"active_jobs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 5 },
"title": { "type": "string", "example": "Housekeeper Needed" },
"location": { "type": "string", "example": "Dubai Marina" },
"salary": { "type": "integer", "example": 2500 },
"job_type": { "type": "string", "example": "Full Time" },
"start_date": { "type": "string", "format": "date", "example": "2026-08-01" },
"description": { "type": "string", "example": "Clean house" },
"requirements": { "type": "string", "nullable": true, "example": "Experience required" },
"posted_at": { "type": "string", "format": "date-time", "example": "2026-07-04T12:00:00Z" }
}
}
}
}
},
"reviews": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 1 },
"rating": { "type": "integer", "example": 5 },
"title": { "type": "string", "example": "Great employer" },
"comment": { "type": "string", "example": "Highly recommended!" },
"created_at": { "type": "string", "format": "date-time", "example": "2026-07-04T12:00:00Z" },
"worker": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 2 },
"name": { "type": "string", "example": "Worker Name" },
"nationality": { "type": "string", "example": "Filipino" }
}
}
}
}
},
"pagination": {
"type": "object",
"properties": {
"total": { "type": "integer", "example": 1 },
"per_page": { "type": "integer", "example": 10 },
"current_page": { "type": "integer", "example": 1 },
"last_page": { "type": "integer", "example": 1 }
}
}
}
}
}
}
}
}
}
}
},
"404": {
"description": "Employer not found."
}
}
}
},
"/workers/forgot-password": {
"post": {
"tags": [

View File

@ -78,6 +78,7 @@
Route::get('/workers/dashboard/views', [WorkerProfileController::class, 'getProfileViews']);
Route::get('/workers/dashboard', [WorkerProfileController::class, 'getDashboard']);
Route::get('/workers/employers', [WorkerProfileController::class, 'getEmployers']);
Route::get('/workers/employers/{id}', [WorkerProfileController::class, 'getEmployerProfile']);
Route::post('/workers/change-password', [WorkerProfileController::class, 'changePassword']);

View File

@ -85,4 +85,31 @@ public function test_employer_upload_emirates_id_ocr_and_purge()
$this->assertNotNull($profile->emirates_id_expiry);
$this->assertEquals('approved', $profile->verification_status);
}
/**
* Test that a stale session with an invalid user ID does not cause a redirect loop.
*/
public function test_stale_session_does_not_cause_redirect_loop()
{
// 1. Visit with a session user object containing a non-existent ID
$staleUser = (object)[
'id' => 999999, // Doesn't exist
'name' => 'Stale User',
'email' => 'stale@example.com',
'role' => 'employer',
'subscription_status' => 'active',
'verification_status' => 'approved',
];
// Accessing dashboard should fail auth, clear session, and redirect to login
$response = $this->withSession(['user' => $staleUser])
->get('/employer/dashboard');
$response->assertRedirect(route('employer.login'));
$this->assertNull(session('user')); // Session must be cleared
// Subsequent access to login page should render successfully (no redirect)
$loginResponse = $this->get('/employer/login');
$loginResponse->assertStatus(200);
}
}

View File

@ -236,4 +236,172 @@ public function test_worker_employers_list_pagination_and_sorting()
$this->assertEquals(3, $responsePaginated->json('data.pagination.total'));
$this->assertEquals(2, $responsePaginated->json('data.pagination.per_page'));
}
public function test_worker_can_view_employer_profile_with_permissions_and_reviews()
{
// Create worker
$worker = Worker::create([
'name' => 'Rahul Sharma',
'email' => 'rahul@example.com',
'phone' => '+971501234567',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'Indian',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'Not Specified',
'religion' => 'Not Specified',
'bio' => 'Test',
'verified' => true,
'status' => 'active',
'api_token' => 'worker-test-token',
]);
WorkerDocument::create([
'worker_id' => $worker->id,
'type' => 'passport',
'number' => '123456',
'file_path' => 'passports/test.pdf',
]);
// Create an employer
$employer = User::create([
'name' => 'Employer One',
'email' => 'emp1@example.com',
'password' => bcrypt('password'),
'role' => 'employer',
]);
EmployerProfile::create([
'user_id' => $employer->id,
'company_name' => 'Ahmad Tech Ltd',
'phone' => '+971500000001',
'nationality' => 'UAE',
'city' => 'Dubai',
'accommodation' => 'Provided',
'language' => 'English',
]);
// Create active job post
\App\Models\JobPost::create([
'employer_id' => $employer->id,
'title' => 'Housekeeper Needed',
'location' => 'Dubai Marina',
'salary' => 2500,
'workers_needed' => 1,
'job_type' => 'Full Time',
'start_date' => now()->addDays(5)->toDateString(),
'description' => 'Clean house',
'status' => 'active',
]);
// Create an accepted job offer (connection exists)
JobOffer::create([
'employer_id' => $employer->id,
'worker_id' => $worker->id,
'work_date' => now()->subDays(10),
'location' => 'Dubai',
'salary' => 2500,
'status' => 'accepted',
]);
// Create a review submitted by another worker
$otherWorker = Worker::create([
'name' => 'John Doe',
'email' => 'john@example.com',
'phone' => '+971501234568',
'password' => bcrypt('password'),
'nationality' => 'Filipino',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'Not Specified',
'religion' => 'Not Specified',
'bio' => 'Test',
'passport_status' => 'valid',
]);
\App\Models\EmployerReview::create([
'worker_id' => $otherWorker->id,
'employer_id' => $employer->id,
'rating' => 5,
'title' => 'Excellent sponsor',
'comment' => 'Very polite and pays on time.',
]);
// Hit the new API endpoint
$response = $this->withHeaders([
'Authorization' => 'Bearer worker-test-token',
])->getJson("/api/workers/employers/{$employer->id}");
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$response->assertJsonPath('data.employer.name', 'Employer One');
$response->assertJsonPath('data.employer.email', 'emp1@example.com'); // Contact info visible because of JobOffer connection
$response->assertJsonPath('data.employer.phone', '+971500000001');
$response->assertJsonPath('data.employer.company_name', 'Ahmad Tech Ltd');
$response->assertJsonPath('data.employer.rating', 5);
$response->assertJsonPath('data.employer.reviews_count', 1);
$response->assertJsonPath('data.employer.review_summary.stars.5', 1);
$response->assertJsonCount(1, 'data.employer.active_jobs');
$response->assertJsonPath('data.employer.active_jobs.0.title', 'Housekeeper Needed');
$response->assertJsonCount(1, 'data.reviews.data');
$response->assertJsonPath('data.reviews.data.0.worker.name', 'John Doe');
$response->assertJsonPath('data.reviews.data.0.title', 'Excellent sponsor');
}
public function test_worker_can_view_employer_profile_without_permissions_masks_contact()
{
// Create worker
$worker = Worker::create([
'name' => 'Rahul Sharma',
'email' => 'rahul@example.com',
'phone' => '+971501234567',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'Indian',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'Not Specified',
'religion' => 'Not Specified',
'bio' => 'Test',
'verified' => true,
'status' => 'active',
'api_token' => 'worker-test-token',
]);
WorkerDocument::create([
'worker_id' => $worker->id,
'type' => 'passport',
'number' => '123456',
'file_path' => 'passports/test.pdf',
]);
// Create an employer
$employer = User::create([
'name' => 'Employer One',
'email' => 'emp1@example.com',
'password' => bcrypt('password'),
'role' => 'employer',
]);
EmployerProfile::create([
'user_id' => $employer->id,
'company_name' => 'Ahmad Tech Ltd',
'phone' => '+971500000001',
]);
// Hit the API (no connection exists)
$response = $this->withHeaders([
'Authorization' => 'Bearer worker-test-token',
])->getJson("/api/workers/employers/{$employer->id}");
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$response->assertJsonPath('data.employer.name', 'Employer One');
$response->assertJsonPath('data.employer.email', null); // Masked/null since no connection exists
$response->assertJsonPath('data.employer.phone', null);
}
}

View File

@ -268,4 +268,37 @@ public function test_view_and_list_reviews()
$response->assertJsonCount(1, 'data.reviews');
$response->assertJsonPath('data.reviews.0.title', 'Awesome place');
}
public function test_review_with_invalid_or_missing_job_id_passes_and_saves_null()
{
// Setup job application with hired status and joining_confirmed_at 3.5 months ago
JobApplication::create([
'job_id' => $this->job->id,
'worker_id' => $this->worker->id,
'status' => 'hired',
'joining_confirmed_at' => now()->subMonths(3)->subDays(15)->toDateString(),
]);
// Act: Post review with an invalid job_id (e.g. 'invalid-id-or-empty')
$response = $this->postJson('/api/workers/reviews', [
'employer_id' => $this->employer->id,
'job_id' => 'invalid-id-or-empty',
'rating' => 4,
'title' => 'Fair Employer',
'comment' => 'Very good experience overall.',
], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(201);
$response->assertJsonPath('success', true);
$response->assertJsonPath('data.review.job_id', null); // Resolved to null
$this->assertDatabaseHas('employer_reviews', [
'worker_id' => $this->worker->id,
'employer_id' => $this->employer->id,
'job_id' => null,
'rating' => 4,
]);
}
}