mohan #5

Merged
mohanmd merged 46 commits from mohan into master 2026-06-15 09:13:23 +00:00
35 changed files with 1183 additions and 248 deletions
Showing only changes of commit 53c4152cbf - Show all commits

View File

@ -42,7 +42,6 @@ public function index()
'country' => $worker->country,
'city' => $worker->city,
'area' => $worker->area,
'preferred_location' => $worker->preferred_location,
'live_in_out' => $worker->live_in_out ?? 'Live-in',
'category' => $worker->category ? $worker->category->name : 'N/A',
'experience' => $worker->experience,
@ -126,7 +125,6 @@ public function updateProfile(Request $request, $id)
'country' => 'nullable|string',
'city' => 'nullable|string',
'area' => 'nullable|string',
'preferred_location' => 'nullable|string',
'live_in_out' => 'nullable|string',
'category' => 'required|string',
'experience' => 'required|string',
@ -148,7 +146,6 @@ public function updateProfile(Request $request, $id)
$worker->country = $validated['country'] ?? $worker->country;
$worker->city = $validated['city'] ?? $worker->city;
$worker->area = $validated['area'] ?? $worker->area;
$worker->preferred_location = $validated['preferred_location'] ?? $worker->preferred_location;
$worker->live_in_out = $validated['live_in_out'] ?? $worker->live_in_out;
$worker->experience = $validated['experience'];
$worker->salary = $validated['salary'] ?? $worker->salary;

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\EmployerProfile;
use App\Models\Sponsor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
@ -21,10 +22,17 @@ class EmployerAuthController extends Controller
public function login(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'email' => 'nullable|string',
'mobile' => 'nullable|string',
'password' => 'required|string',
]);
$validator->after(function ($validator) use ($request) {
if (!$request->filled('email') && !$request->filled('mobile')) {
$validator->errors()->add('mobile', 'Either mobile number or email is required.');
}
});
if ($validator->fails()) {
return response()->json([
'success' => false,
@ -34,15 +42,49 @@ public function login(Request $request)
}
try {
$user = User::where('email', $request->email)->where('role', 'employer')->first();
$loginValue = $request->input('mobile') ?? $request->input('email');
if (!$user || !Hash::check($request->password, $user->password)) {
$sponsor = Sponsor::where('mobile', $loginValue)
->orWhere('email', $loginValue)
->first();
if ($sponsor) {
$user = User::where('email', $sponsor->email)
->where('role', 'employer')
->first();
} else {
$user = User::where('email', $loginValue)
->where('role', 'employer')
->first();
}
if (!$sponsor && !$user) {
return response()->json([
'success' => false,
'message' => 'Invalid email or password.'
'message' => 'Invalid credentials.'
], 401);
}
$passwordHash = $sponsor ? $sponsor->password : $user->password;
if (!Hash::check($request->password, $passwordHash)) {
return response()->json([
'success' => false,
'message' => 'Invalid credentials.'
], 401);
}
if ($sponsor && $sponsor->status === 'suspended') {
return response()->json([
'success' => false,
'message' => 'Your account has been suspended. Please contact support.'
], 403);
}
$apiToken = Str::random(80);
if ($user) {
// Employer account
$profile = EmployerProfile::where('user_id', $user->id)->first();
$verification_status = $profile ? $profile->verification_status : 'approved';
@ -61,21 +103,43 @@ public function login(Request $request)
], 403);
}
// Generate and assign a fresh API token
$apiToken = Str::random(80);
$user->update(['api_token' => $apiToken]);
if ($sponsor) {
$sponsor->update([
'api_token' => $apiToken,
'last_login_at' => now(),
]);
}
return response()->json([
'success' => true,
'message' => 'Employer logged in successfully.',
'message' => 'Login successful.',
'role' => 'employer',
'data' => [
'employer' => $user->load('employerProfile'),
'token' => $apiToken
]
], 200);
} else {
// Pure Sponsor account
$sponsor->update([
'api_token' => $apiToken,
'last_login_at' => now(),
]);
return response()->json([
'success' => true,
'message' => 'Login successful.',
'role' => 'sponsor',
'data' => [
'sponsor' => $sponsor->makeHidden(['password', 'api_token']),
'token' => $apiToken
]
], 200);
}
} catch (\Exception $e) {
logger()->error('Mobile Employer Login Failure: ' . $e->getMessage());
logger()->error('Mobile Login Failure: ' . $e->getMessage());
return response()->json([
'success' => false,

View File

@ -0,0 +1,162 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Sponsor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class SponsorAuthController extends Controller
{
/**
* Register a new Sponsor account.
*
* Required fields: full_name, mobile, password, license_file
* Optional: organization_name, email, nationality, city, address, country_code
*
* POST /api/sponsors/register
*/
public function register(Request $request)
{
$validator = Validator::make($request->all(), [
'full_name' => 'required|string|max:255',
'mobile' => 'required|string|max:50|unique:sponsors,mobile',
'password' => 'required|string|min:6',
'license_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240',
'organization_name' => 'nullable|string|max:255',
'email' => 'nullable|email|max:255|unique:sponsors,email',
'nationality' => 'nullable|string|max:100',
'city' => 'nullable|string|max:100',
'address' => 'nullable|string|max:255',
'country_code' => 'nullable|string|max:10',
], [
'mobile.unique' => 'This mobile number is already registered.',
'email.unique' => 'This email address is already registered.',
'license_file.required' => 'Please upload your organization or trade license.',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
try {
// Auto-generate email if not provided
$mobileClean = preg_replace('/[^0-9]/', '', $request->mobile);
$email = $request->email ?? "sponsor.{$mobileClean}@migrant.ae";
if (!$request->email && Sponsor::where('email', $email)->exists()) {
$email = "sponsor.{$mobileClean}." . rand(10, 99) . "@migrant.ae";
}
// Store license file
$destinationPath = public_path('uploads/licenses');
if (!file_exists($destinationPath)) {
mkdir($destinationPath, 0755, true);
}
$licenseFile = $request->file('license_file');
$licenseFileName = time() . '_license_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $licenseFile->getClientOriginalName());
$licenseFile->move($destinationPath, $licenseFileName);
$licensePath = 'uploads/licenses/' . $licenseFileName;
$apiToken = Str::random(80);
$sponsor = DB::transaction(function () use ($request, $email, $licensePath, $apiToken) {
return Sponsor::create([
'full_name' => $request->full_name,
'organization_name' => $request->organization_name,
'email' => $email,
'mobile' => $request->mobile,
'password' => Hash::make($request->password),
'country_code' => $request->country_code,
'nationality' => $request->nationality,
'city' => $request->city,
'address' => $request->address,
'license_file' => $licensePath,
'status' => 'active',
'is_verified' => false,
'subscription_status' => 'none',
'api_token' => $apiToken,
]);
});
return response()->json([
'success' => true,
'message' => 'Sponsor account registered successfully. Your license is pending admin review.',
'data' => [
'sponsor' => $sponsor->makeHidden(['password', 'api_token']),
'token' => $apiToken,
]
], 201);
} catch (\Exception $e) {
logger()->error('Sponsor Registration Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred during registration. Please try again.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* Authenticate a Sponsor and return their bearer token.
*
* POST /api/sponsors/login
*/
public function login(Request $request)
{
$validator = Validator::make($request->all(), [
'mobile' => 'required|string',
'password' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
$sponsor = Sponsor::where('mobile', $request->mobile)->first();
if (!$sponsor || !Hash::check($request->password, $sponsor->password)) {
return response()->json([
'success' => false,
'message' => 'Invalid mobile number or password.'
], 401);
}
if ($sponsor->status === 'suspended') {
return response()->json([
'success' => false,
'message' => 'Your account has been suspended. Please contact support.'
], 403);
}
// Rotate token on each login for security
$apiToken = Str::random(80);
$sponsor->update([
'api_token' => $apiToken,
'last_login_at' => now(),
]);
return response()->json([
'success' => true,
'message' => 'Login successful.',
'data' => [
'sponsor' => $sponsor->makeHidden(['password', 'api_token']),
'token' => $apiToken,
]
], 200);
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Announcement;
use App\Models\Sponsor;
use Illuminate\Http\Request;
class SponsorController extends Controller
{
/**
* GET /api/sponsors/dashboard
*
* Returns the sponsor's profile summary and unread charity event count.
*/
public function getDashboard(Request $request)
{
/** @var Sponsor $sponsor */
$sponsor = $request->attributes->get('sponsor');
try {
// Recent charity/update announcements for the dashboard preview
$recentEvents = Announcement::latest()
->limit(5)
->get()
->map(function ($event) {
return [
'id' => $event->id,
'title' => $event->title,
'body' => $event->body,
'type' => $event->type,
'created_at' => $event->created_at->toIso8601String(),
'time_ago' => $event->created_at->diffForHumans(),
];
});
return response()->json([
'success' => true,
'data' => [
'sponsor' => [
'id' => $sponsor->id,
'full_name' => $sponsor->full_name,
'organization_name' => $sponsor->organization_name,
'mobile' => $sponsor->mobile,
'email' => $sponsor->email,
'city' => $sponsor->city,
'nationality' => $sponsor->nationality,
'is_verified' => $sponsor->is_verified,
'status' => $sponsor->status,
'license_file' => $sponsor->license_file ? asset($sponsor->license_file) : null,
'joined_at' => $sponsor->created_at->toIso8601String(),
],
'recent_charity_events' => $recentEvents,
'total_events' => Announcement::count(),
]
], 200);
} catch (\Exception $e) {
logger()->error('Sponsor Dashboard API Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while loading your dashboard.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* GET /api/sponsors/charity-events
*
* Returns a paginated list of all charity/announcement events.
*/
public function getCharityEvents(Request $request)
{
try {
$page = (int) $request->input('page', 1);
$perPage = (int) $request->input('per_page', 15);
$type = $request->input('type'); // optional filter: 'charity', 'update', etc.
$query = Announcement::with('employer.employerProfile')->latest();
if ($type) {
$query->where('type', $type);
}
$total = $query->count();
$offset = ($page - 1) * $perPage;
$events = $query->skip($offset)->take($perPage)->get()
->map(function ($event) {
return [
'id' => $event->id,
'title' => $event->title,
'body' => $event->body,
'type' => $event->type,
'posted_by' => $event->employer->name ?? 'System',
'organization' => optional($event->employer)->employerProfile->company_name ?? 'Migrant Support',
'created_at' => $event->created_at->toIso8601String(),
'time_ago' => $event->created_at->diffForHumans(),
];
});
return response()->json([
'success' => true,
'data' => [
'events' => $events,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => max(1, (int) ceil($total / $perPage)),
],
]
], 200);
} catch (\Exception $e) {
logger()->error('Sponsor Charity Events API Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while fetching charity events.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* GET /api/sponsors/profile
*
* Returns the authenticated sponsor's full profile.
*/
public function getProfile(Request $request)
{
/** @var Sponsor $sponsor */
$sponsor = $request->attributes->get('sponsor');
return response()->json([
'success' => true,
'data' => [
'sponsor' => [
'id' => $sponsor->id,
'full_name' => $sponsor->full_name,
'organization_name' => $sponsor->organization_name,
'mobile' => $sponsor->mobile,
'email' => $sponsor->email,
'nationality' => $sponsor->nationality,
'city' => $sponsor->city,
'address' => $sponsor->address,
'country_code' => $sponsor->country_code,
'is_verified' => $sponsor->is_verified,
'status' => $sponsor->status,
'license_file' => $sponsor->license_file ? asset($sponsor->license_file) : null,
'joined_at' => $sponsor->created_at->toIso8601String(),
]
]
], 200);
}
}

View File

@ -292,6 +292,13 @@ public function register(Request $request)
'nationality' => 'nullable|string|max:100',
'age' => 'nullable|integer',
'experience' => 'nullable|string|max:100',
'in_country' => 'nullable',
'visa_status' => 'nullable|string|max:100',
'preferred_job_type' => 'nullable|string|max:100',
'live_in_out' => 'nullable|string|max:100',
'country' => 'nullable|string|max:100',
'city' => 'nullable|string|max:100',
'area' => 'nullable|string|max:100',
]);
if ($validator->fails()) {
@ -344,9 +351,10 @@ public function register(Request $request)
$visaPath = 'uploads/documents/' . $fileName;
}
$inCountry = filter_var($request->input('in_country', true), FILTER_VALIDATE_BOOLEAN);
$apiToken = Str::random(80);
$result = DB::transaction(function () use ($request, $email, $skillsArray, $passportPath, $visaPath, $apiToken) {
$result = DB::transaction(function () use ($request, $email, $skillsArray, $passportPath, $visaPath, $apiToken, $inCountry) {
$worker = Worker::create([
'name' => $request->name,
'email' => $email,
@ -364,6 +372,13 @@ public function register(Request $request)
'verified' => false,
'status' => 'active',
'api_token' => $apiToken,
'in_country' => $inCountry,
'visa_status' => $inCountry ? $request->visa_status : null,
'preferred_job_type' => $request->preferred_job_type,
'live_in_out' => $request->live_in_out,
'country' => $request->country,
'city' => $request->city,
'area' => $request->area,
]);
if (!empty($skillsArray)) {

View File

@ -61,6 +61,13 @@ public function updateProfile(Request $request)
'category_id' => 'nullable|exists:worker_categories,id',
'skills' => 'nullable|array',
'skills.*' => 'exists:skills,id',
'in_country' => 'nullable',
'visa_status' => 'nullable|string|max:100',
'preferred_job_type' => 'nullable|string|max:100',
'live_in_out' => 'nullable|string|max:100',
'country' => 'nullable|string|max:100',
'city' => 'nullable|string|max:100',
'area' => 'nullable|string|max:100',
]);
if ($validator->fails()) {
@ -84,7 +91,13 @@ public function updateProfile(Request $request)
'experience',
'religion',
'bio',
'category_id'
'category_id',
'visa_status',
'preferred_job_type',
'live_in_out',
'country',
'city',
'area'
]);
// Filter out null inputs if we want to support partial updates
@ -92,6 +105,10 @@ public function updateProfile(Request $request)
return !is_null($value);
});
if ($request->has('in_country')) {
$updateData['in_country'] = filter_var($request->input('in_country'), FILTER_VALIDATE_BOOLEAN);
}
$worker->update($updateData);
// Sync skills if provided

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Middleware;
use App\Models\Sponsor;
use Closure;
use Illuminate\Http\Request;
class SponsorApiMiddleware
{
/**
* Authenticate incoming sponsor API requests via Bearer token.
*/
public function handle(Request $request, Closure $next)
{
$authHeader = $request->header('Authorization');
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
return response()->json([
'success' => false,
'message' => 'Authentication token required.'
], 401);
}
$token = substr($authHeader, 7);
$sponsor = Sponsor::where('api_token', $token)->first();
if (!$sponsor) {
return response()->json([
'success' => false,
'message' => 'Invalid or expired authentication token.'
], 401);
}
if ($sponsor->status === 'suspended') {
return response()->json([
'success' => false,
'message' => 'Your account has been suspended. Please contact support.'
], 403);
}
// Attach sponsor to request for use in controllers
$request->attributes->set('sponsor', $sponsor);
return $next($request);
}
}

View File

@ -12,6 +12,7 @@ class Sponsor extends Authenticatable
protected $fillable = [
'full_name',
'organization_name',
'email',
'mobile',
'password',
@ -29,10 +30,13 @@ class Sponsor extends Authenticatable
'nationality',
'status',
'last_login_at',
'api_token',
'license_file',
];
protected $hidden = [
'password',
'api_token',
];
protected $casts = [

View File

@ -20,7 +20,6 @@ class Worker extends Model
'country',
'city',
'area',
'preferred_location',
'live_in_out',
'age',
'gender',
@ -33,6 +32,9 @@ class Worker extends Model
'verified',
'status',
'api_token',
'in_country',
'visa_status',
'preferred_job_type',
];
protected $hidden = [
@ -42,6 +44,7 @@ class Worker extends Model
protected $casts = [
'verified' => 'boolean',
'in_country' => 'boolean',
'salary' => 'decimal:2',
'password' => 'hashed',
];
@ -63,6 +66,9 @@ public function getPassportStatusAttribute()
public function getVisaStatusAttribute()
{
if ($this->attributes['visa_status'] ?? null) {
return $this->attributes['visa_status'];
}
$visa = $this->documents->where('type', 'visa')->first();
if (!$visa) {
return 'Visa Pending';

View File

@ -20,7 +20,8 @@
'admin' => \App\Http\Middleware\AdminMiddleware::class,
'employer' => \App\Http\Middleware\EmployerMiddleware::class,
'auth.worker' => \App\Http\Middleware\WorkerApiMiddleware::class,
'auth.employer' => \App\Http\Middleware\EmployerApiMiddleware::class,
'auth.employer'=> \App\Http\Middleware\EmployerApiMiddleware::class,
'auth.sponsor' => \App\Http\Middleware\SponsorApiMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@ -0,0 +1,36 @@
<?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('workers', function (Blueprint $table) {
if (!Schema::hasColumn('workers', 'in_country')) {
$table->boolean('in_country')->default(true)->after('nationality');
}
if (!Schema::hasColumn('workers', 'visa_status')) {
$table->string('visa_status')->nullable()->after('in_country');
}
if (!Schema::hasColumn('workers', 'preferred_job_type')) {
$table->string('preferred_job_type')->nullable()->after('live_in_out');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->dropColumn(['in_country', 'visa_status', 'preferred_job_type']);
});
}
};

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
{
public function up(): void
{
Schema::table('sponsors', function (Blueprint $table) {
if (!Schema::hasColumn('sponsors', 'api_token')) {
$table->string('api_token', 100)->nullable()->unique()->after('status');
}
if (!Schema::hasColumn('sponsors', 'license_file')) {
$table->string('license_file')->nullable()->after('api_token');
}
if (!Schema::hasColumn('sponsors', 'organization_name')) {
$table->string('organization_name')->nullable()->after('full_name');
}
});
}
public function down(): void
{
Schema::table('sponsors', function (Blueprint $table) {
$table->dropColumn(['api_token', 'license_file', 'organization_name']);
});
}
};

View File

@ -17,6 +17,216 @@
}
],
"paths": {
"/sponsors/register": {
"post": {
"tags": ["Sponsor/Auth"],
"summary": "Register Sponsor Account",
"description": "Registers a new sponsor with basic information and uploads their organization/trade license. Sponsors have restricted access — dashboard and charity events only. No payment required.",
"security": [],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": ["full_name", "mobile", "password", "license_file"],
"properties": {
"full_name": {
"type": "string",
"example": "Mohammed Al-Rashidi",
"description": "Full legal name of the sponsor. (REQUIRED)"
},
"mobile": {
"type": "string",
"example": "+971501112233",
"description": "Mobile phone number — must be unique. (REQUIRED)"
},
"password": {
"type": "string",
"format": "password",
"example": "securepass123",
"description": "Login password (min 6 characters). (REQUIRED)"
},
"license_file": {
"type": "string",
"format": "binary",
"description": "Organization or trade license document (jpg/png/pdf, max 10MB). (REQUIRED)"
},
"organization_name": {
"type": "string",
"example": "Al-Rashidi Charitable Foundation",
"description": "Name of the sponsoring organization."
},
"email": {
"type": "string",
"format": "email",
"example": "info@alrashidi.ae",
"description": "Email address (optional — auto-generated if not provided)."
},
"nationality": {
"type": "string",
"example": "UAE",
"description": "Nationality of the sponsor."
},
"city": {
"type": "string",
"example": "Dubai",
"description": "City of residence."
},
"address": {
"type": "string",
"example": "Villa 12, Jumeirah 2",
"description": "Physical address."
},
"country_code": {
"type": "string",
"example": "+971",
"description": "Country dial code."
}
}
}
}
}
},
"responses": {
"201": {
"description": "Sponsor registered successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {"type": "boolean", "example": true},
"message": {"type": "string", "example": "Sponsor account registered successfully. Your license is pending admin review."},
"data": {
"type": "object",
"properties": {
"sponsor": {"$ref": "#/components/schemas/Sponsor"},
"token": {"type": "string", "example": "abc123...bearerToken..."}
}
}
}
}
}
}
},
"422": {"description": "Validation error (duplicate mobile/email, missing required fields)."}
}
}
},
"/sponsors/login": {
"post": {
"tags": [
"Sponsor/Auth"
],
"summary": "Authenticate Sponsor or Employer",
"description": "Unified authentication endpoint. Validates email/mobile and password, then returns a stateless Bearer token and the user's role (employer or sponsor). Same behavior as /employers/login.",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"password"
],
"properties": {
"email": {
"type": "string",
"example": "ahmad@example.com",
"description": "Email address (required if mobile is not provided)"
},
"mobile": {
"type": "string",
"example": "+971509990001",
"description": "Mobile number (required if email is not provided)"
},
"password": {
"type": "string",
"example": "Password@123"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Login successful. Returns Bearer token and role.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Login successful."
},
"role": {
"type": "string",
"example": "sponsor",
"enum": ["employer", "sponsor"]
},
"data": {
"type": "object",
"properties": {
"token": {
"type": "string",
"example": "api_token_here"
}
}
}
}
}
}
}
}
}
}
},
"/sponsors/dashboard": {
"get": {
"tags": ["Sponsor"],
"summary": "Sponsor Dashboard",
"description": "Returns the sponsor profile summary and a preview of the latest charity events.",
"responses": {
"200": {"description": "Dashboard data retrieved successfully."},
"401": {"description": "Unauthenticated."}
}
}
},
"/sponsors/charity-events": {
"get": {
"tags": ["Sponsor"],
"summary": "List Charity Events",
"description": "Returns a paginated list of all charity and announcement events. Optionally filter by type.",
"parameters": [
{"name": "page", "in": "query", "schema": {"type": "integer", "default": 1}},
{"name": "per_page", "in": "query", "schema": {"type": "integer", "default": 15}},
{"name": "type", "in": "query", "schema": {"type": "string"}, "description": "Optional event type filter (e.g. 'charity', 'update')."}
],
"responses": {
"200": {"description": "Events retrieved successfully."},
"401": {"description": "Unauthenticated."}
}
}
},
"/sponsors/profile": {
"get": {
"tags": ["Sponsor"],
"summary": "Get Sponsor Profile",
"description": "Returns the full profile of the authenticated sponsor.",
"responses": {
"200": {"description": "Profile retrieved successfully."},
"401": {"description": "Unauthenticated."}
}
}
},
"/workers/register": {
"post": {
"tags": [
@ -101,6 +311,41 @@
"type": "string",
"example": "4 Years",
"description": "Years of experience."
},
"in_country": {
"type": "boolean",
"example": true,
"description": "Whether the worker is currently in-country (true) or out-country (false)."
},
"visa_status": {
"type": "string",
"example": "Tourist Visa",
"description": "Visa status (only required/applicable if in_country is true)."
},
"preferred_job_type": {
"type": "string",
"example": "full-time",
"description": "Preferred job type."
},
"live_in_out": {
"type": "string",
"example": "Live-out",
"description": "Accommodation preference (e.g. 'Live-in', 'Live-out')."
},
"country": {
"type": "string",
"example": "UAE",
"description": "Preferred location country."
},
"city": {
"type": "string",
"example": "Dubai",
"description": "Preferred location city."
},
"area": {
"type": "string",
"example": "Marina",
"description": "Preferred location area."
}
}
}
@ -1277,53 +1522,6 @@
}
}
},
"/sponsors/register": {
"post": {
"tags": [
"Sponsor/Auth"
],
"summary": "Register Sponsor",
"description": "Registers a new sponsor profile with basic info (Name, Email, Phone).",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"email",
"phone"
],
"properties": {
"name": {
"type": "string",
"example": "Ahmad Bin Ahmed",
"description": "Sponsor's full name"
},
"email": {
"type": "string",
"example": "ahmad@example.com",
"description": "Sponsor's contact email address"
},
"phone": {
"type": "string",
"example": "+971509990001",
"description": "Sponsor's mobile phone number"
}
}
}
}
}
},
"responses": {
"201": {
"description": "Sponsor registered successfully."
}
}
}
},
"/employers/verify": {
"post": {
"tags": [
@ -1364,46 +1562,6 @@
}
}
},
"/sponsors/verify": {
"post": {
"tags": [
"Sponsor/Auth"
],
"summary": "Verify Sponsor Email OTP (Sponsor Prefix)",
"description": "Verifies the email address with the OTP verification code (Step 2).",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email",
"otp"
],
"properties": {
"email": {
"type": "string",
"example": "ahmad@example.com"
},
"otp": {
"type": "string",
"example": "111111",
"description": "6-digit OTP code"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Email verified successfully."
}
}
}
},
"/employers/payment": {
"post": {
"tags": [
@ -1453,55 +1611,6 @@
}
}
},
"/sponsors/payment": {
"post": {
"tags": [
"Sponsor/Auth"
],
"summary": "Sponsor Subscription Payment (Sponsor Prefix)",
"description": "Submits a successful plan selection and PayTabs payment confirmation (Step 3).",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email",
"plan_id",
"amount_aed",
"paytabs_transaction_id"
],
"properties": {
"email": {
"type": "string",
"example": "ahmad@example.com"
},
"plan_id": {
"type": "string",
"example": "premium"
},
"amount_aed": {
"type": "number",
"example": 199
},
"paytabs_transaction_id": {
"type": "string",
"example": "TXN998877"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Subscription payment confirmed."
}
}
}
},
"/employers/password": {
"post": {
"tags": [
@ -1548,52 +1657,6 @@
}
}
},
"/sponsors/password": {
"post": {
"tags": [
"Sponsor/Auth"
],
"summary": "Create Sponsor Account Password (Sponsor Prefix)",
"description": "Configures and finalizes the portal login password to complete registration (Step 4). Returns bearer token.",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email",
"password",
"password_confirmation"
],
"properties": {
"email": {
"type": "string",
"example": "ahmad@example.com"
},
"password": {
"type": "string",
"format": "password",
"example": "Password@123"
},
"password_confirmation": {
"type": "string",
"format": "password",
"example": "Password@123"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Password set and registration finalized successfully."
}
}
}
},
"/employers/plans": {
"get": {
"tags": [
@ -1609,28 +1672,13 @@
}
}
},
"/sponsors/plans": {
"get": {
"tags": [
"Sponsor/Auth"
],
"summary": "Get Available Subscription Plans (Sponsor Prefix)",
"description": "Returns details (price, features) of subscription packages available to sponsors.",
"security": [],
"responses": {
"200": {
"description": "Subscription plans list retrieved successfully."
}
}
}
},
"/employers/login": {
"post": {
"tags": [
"Employer/Auth"
],
"summary": "Authenticate Employer",
"description": "Validates employer email and password and returns a stateless Bearer token.",
"summary": "Authenticate Employer or Sponsor",
"description": "Unified authentication endpoint. Validates email/mobile and password, then returns a stateless Bearer token and the user's role (employer or sponsor).",
"security": [],
"requestBody": {
"required": true,
@ -1639,13 +1687,18 @@
"schema": {
"type": "object",
"required": [
"email",
"password"
],
"properties": {
"email": {
"type": "string",
"example": "ahmad@example.com"
"example": "ahmad@example.com",
"description": "Email address (required if mobile is not provided)"
},
"mobile": {
"type": "string",
"example": "+971509990001",
"description": "Mobile number (required if email is not provided)"
},
"password": {
"type": "string",
@ -1658,7 +1711,38 @@
},
"responses": {
"200": {
"description": "Employer logged in successfully."
"description": "Login successful. Returns Bearer token and role.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Login successful."
},
"role": {
"type": "string",
"example": "employer",
"enum": ["employer", "sponsor"]
},
"data": {
"type": "object",
"properties": {
"token": {
"type": "string",
"example": "api_token_here"
}
}
}
}
}
}
}
}
}
}
@ -3086,6 +3170,24 @@
}
},
"schemas": {
"Sponsor": {
"type": "object",
"properties": {
"id": {"type": "integer", "example": 1},
"full_name": {"type": "string", "example": "Mohammed Al-Rashidi"},
"organization_name": {"type": "string", "example": "Al-Rashidi Charitable Foundation"},
"email": {"type": "string", "example": "sponsor.971501112233@migrant.ae"},
"mobile": {"type": "string", "example": "+971501112233"},
"nationality": {"type": "string", "example": "UAE"},
"city": {"type": "string", "example": "Dubai"},
"address": {"type": "string", "example": "Villa 12, Jumeirah 2"},
"country_code": {"type": "string", "example": "+971"},
"is_verified": {"type": "boolean", "example": false},
"status": {"type": "string", "example": "active"},
"license_file": {"type": "string", "example": "uploads/licenses/1234567890_license_trade.pdf"},
"created_at": {"type": "string", "format": "date-time"}
}
},
"Worker": {
"type": "object",
"properties": {
@ -3145,6 +3247,34 @@
"type": "string",
"example": "active"
},
"in_country": {
"type": "boolean",
"example": true
},
"visa_status": {
"type": "string",
"example": "Tourist Visa"
},
"preferred_job_type": {
"type": "string",
"example": "full-time"
},
"live_in_out": {
"type": "string",
"example": "Live-out"
},
"country": {
"type": "string",
"example": "UAE"
},
"city": {
"type": "string",
"example": "Dubai"
},
"area": {
"type": "string",
"example": "Marina"
},
"created_at": {
"type": "string",
"format": "date-time",

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@ -10,6 +10,8 @@
use App\Http\Controllers\Api\EmployerAnnouncementController;
use App\Http\Controllers\Api\EmployerProfileController;
use App\Http\Controllers\Api\EmployerReviewController;
use App\Http\Controllers\Api\SponsorAuthController;
use App\Http\Controllers\Api\SponsorController;
/*
|--------------------------------------------------------------------------
@ -33,20 +35,17 @@
// Unprotected Employer Mobile Auth Endpoints
Route::post('/employers/register', [EmployerAuthController::class, 'register']);
Route::post('/sponsors/register', [EmployerAuthController::class, 'register']);
Route::post('/employers/verify', [EmployerAuthController::class, 'verify']);
Route::post('/sponsors/verify', [EmployerAuthController::class, 'verify']);
Route::post('/employers/payment', [EmployerAuthController::class, 'payment']);
Route::post('/sponsors/payment', [EmployerAuthController::class, 'payment']);
Route::post('/employers/password', [EmployerAuthController::class, 'password']);
Route::post('/sponsors/password', [EmployerAuthController::class, 'password']);
Route::get('/employers/plans', [EmployerAuthController::class, 'plans']);
Route::get('/sponsors/plans', [EmployerAuthController::class, 'plans']);
Route::post('/employers/login', [EmployerAuthController::class, 'login']);
Route::post('/employers/forgot-password', [EmployerAuthController::class, 'forgotPassword']);
Route::post('/sponsors/forgot-password', [EmployerAuthController::class, 'forgotPassword']);
Route::post('/employers/reset-password', [EmployerAuthController::class, 'resetPassword']);
Route::post('/sponsors/reset-password', [EmployerAuthController::class, 'resetPassword']);
// Sponsor Mobile Auth Endpoints (unprotected)
Route::post('/sponsors/register', [SponsorAuthController::class, 'register']);
Route::post('/sponsors/login', [EmployerAuthController::class, 'login']);
// Protected Worker Mobile Endpoints (Token Authenticated via Bearer Token)
@ -119,3 +118,11 @@
Route::post('/employers/report', [\App\Http\Controllers\Api\ReportController::class, 'reportFromEmployer']);
Route::get('/employers/report-reasons', [\App\Http\Controllers\Api\ReportController::class, 'getReasonsForEmployer']);
});
// Protected Sponsor Mobile Endpoints (Token Authenticated via Bearer Token)
// Sponsors can ONLY access: dashboard and charity events. No payments, no worker browsing.
Route::middleware(['auth.sponsor'])->group(function () {
Route::get('/sponsors/profile', [SponsorController::class, 'getProfile']);
Route::get('/sponsors/dashboard', [SponsorController::class, 'getDashboard']);
Route::get('/sponsors/charity-events', [SponsorController::class, 'getCharityEvents']);
});

View File

@ -0,0 +1,168 @@
<?php
namespace Tests\Feature;
use App\Models\Sponsor;
use App\Models\User;
use App\Models\EmployerProfile;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class SponsorAuthApiTest extends TestCase
{
use RefreshDatabase;
/**
* Test a pure Sponsor can register with simple info and a license file upload.
*/
public function test_sponsor_can_register_with_license()
{
$licenseFile = UploadedFile::fake()->create('license.pdf', 500, 'application/pdf');
$response = $this->postJson('/api/sponsors/register', [
'full_name' => 'Test Sponsor Organization',
'mobile' => '+971509990001',
'password' => 'securepassword',
'license_file' => $licenseFile,
'organization_name' => 'Charity Co',
'nationality' => 'Emirati',
'city' => 'Abu Dhabi',
]);
$response->assertStatus(201)
->assertJsonStructure([
'success',
'message',
'data' => [
'sponsor' => [
'id',
'full_name',
'mobile',
'organization_name',
'nationality',
'city',
'license_file',
],
'token',
]
]);
$this->assertDatabaseHas('sponsors', [
'mobile' => '+971509990001',
'full_name' => 'Test Sponsor Organization',
]);
}
/**
* Test both Sponsor and Employer can log in using the unified login endpoint.
*/
public function test_unified_login_returns_correct_role_and_data()
{
// 1. Create a pure Sponsor (no User record)
$sponsorUser = Sponsor::create([
'full_name' => 'Sponsor User',
'email' => 'sponsor@example.com',
'mobile' => '+971509991111',
'password' => Hash::make('password123'),
'license_file' => 'uploads/licenses/fake.pdf',
'status' => 'active',
]);
// 2. Create an Employer (has both User and Sponsor records, legacy setup)
$employerUser = User::create([
'name' => 'Employer User',
'email' => 'employer@example.com',
'password' => Hash::make('password123'),
'role' => 'employer',
'api_token' => 'old_token',
]);
EmployerProfile::create([
'user_id' => $employerUser->id,
'company_name' => 'Employer Corp',
'phone' => '+971509992222',
'country' => 'United Arab Emirates',
'verification_status' => 'approved',
]);
Sponsor::create([
'full_name' => 'Employer User',
'email' => 'employer@example.com',
'mobile' => '+971509992222',
'password' => $employerUser->password,
'status' => 'active',
]);
// --- Test Sponsor Login via /api/employers/login ---
$responseSponsor = $this->postJson('/api/employers/login', [
'mobile' => '+971509991111',
'password' => 'password123',
]);
$responseSponsor->assertStatus(200)
->assertJson([
'success' => true,
'role' => 'sponsor',
])
->assertJsonStructure([
'data' => [
'sponsor',
'token',
]
]);
// --- Test Employer Login via /api/employers/login ---
$responseEmployer = $this->postJson('/api/employers/login', [
'mobile' => '+971509992222',
'password' => 'password123',
]);
$responseEmployer->assertStatus(200)
->assertJson([
'success' => true,
'role' => 'employer',
])
->assertJsonStructure([
'data' => [
'employer' => [
'id',
'name',
'email',
'employer_profile',
],
'token',
]
]);
}
/**
* Test that pure sponsors can only access their limited routes.
*/
public function test_sponsor_restricted_access()
{
$sponsor = Sponsor::create([
'full_name' => 'Pure Sponsor',
'email' => 'pure_sponsor@example.com',
'mobile' => '+971509993333',
'password' => Hash::make('password123'),
'api_token' => 'sponsor_token_abc',
'status' => 'active',
]);
// Access dashboard successfully
$responseDash = $this->withHeaders([
'Authorization' => 'Bearer sponsor_token_abc',
])->getJson('/api/sponsors/dashboard');
$responseDash->assertStatus(200);
// Try to access employer protected route (should get blocked as they don't have employer token/role)
$responseEmployerRoute = $this->withHeaders([
'Authorization' => 'Bearer sponsor_token_abc',
])->getJson('/api/employers/profile');
$responseEmployerRoute->assertStatus(401);
}
}

View File

@ -407,4 +407,95 @@ public function test_s6_outcome_mark_hired()
'status' => 'Hired',
]);
}
/**
* Test registration with new API fields.
*/
public function test_register_with_new_api_fields()
{
$fakePassport = UploadedFile::fake()->create('passport.pdf', 500, 'application/pdf');
$response = $this->postJson('/api/workers/register', [
'name' => 'John New Worker',
'phone' => '+971501111112',
'salary' => 2200.50,
'password' => 'secret123',
'passport_file' => $fakePassport,
'language' => 'HI, SW',
'nationality' => 'Kenya',
'age' => 30,
'experience' => '5 Years',
'in_country' => 'true',
'visa_status' => 'Tourist Visa',
'preferred_job_type' => 'full-time',
'live_in_out' => 'Live-out (External housing)',
'country' => 'UAE',
'city' => 'Dubai',
'area' => 'Marina',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('workers', [
'name' => 'John New Worker',
'phone' => '+971501111112',
'in_country' => true,
'visa_status' => 'Tourist Visa',
'preferred_job_type' => 'full-time',
'live_in_out' => 'Live-out (External housing)',
'country' => 'UAE',
'city' => 'Dubai',
'area' => 'Marina',
]);
}
/**
* Test updating profile with new API fields.
*/
public function test_update_profile_with_new_api_fields()
{
$worker = Worker::create([
'name' => 'Original Worker',
'email' => 'orig@example.com',
'phone' => '+971502222223',
'language' => 'SW',
'password' => bcrypt('password'),
'nationality' => 'Kenya',
'age' => 22,
'salary' => 1200,
'availability' => 'Immediate',
'experience' => 'None',
'religion' => 'Christian',
'bio' => 'New helper',
'category_id' => $this->category->id,
'verified' => false,
'status' => 'active',
'api_token' => 'bearer-update-token',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer bearer-update-token',
])->postJson('/api/workers/profile/update', [
'in_country' => false,
'visa_status' => null,
'preferred_job_type' => 'part-time',
'live_in_out' => 'Live-in (Stay with family)',
'country' => 'UAE',
'city' => 'Abu Dhabi',
'area' => 'Khalifa City',
]);
$response->assertStatus(200);
$this->assertDatabaseHas('workers', [
'id' => $worker->id,
'in_country' => false,
'visa_status' => null,
'preferred_job_type' => 'part-time',
'live_in_out' => 'Live-in (Stay with family)',
'country' => 'UAE',
'city' => 'Abu Dhabi',
'area' => 'Khalifa City',
]);
}
}