Commit 11ad998e authored by Mahmoud Aglan's avatar Mahmoud Aglan

Mobile API Phase 8: Profile, photo upload, branches, health check, tests

- Profile view/edit (personal info + emergency contact)
- Photo upload for profile and participant (multipart, 5MB max)
- Branch locator endpoint (coordinates, operating hours, phone)
- Health check endpoint (GET /health — no DB, instant response)
- API response headers middleware (X-Request-Id, X-Api-Version)
- Feature test suite: 17 tests covering auth, public access, rate
  limiting, and validation across all endpoint groups
- 53 total API endpoints
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 8504dd68
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Identity\Models\Branch;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class BranchController extends Controller
{
public function index(): JsonResponse
{
$branches = Branch::where('is_active', true)
->select([
'id', 'name', 'name_ar', 'address', 'city', 'governorate',
'phone', 'email', 'latitude', 'longitude', 'operating_hours', 'is_main',
])
->orderByDesc('is_main')
->orderBy('name_ar')
->get();
return response()->json([
'data' => $branches->map(fn ($b) => [
'id' => $b->id,
'name' => $b->name,
'name_ar' => $b->name_ar,
'address' => $b->address,
'city' => $b->city,
'governorate' => $b->governorate,
'phone' => $b->phone,
'email' => $b->email,
'latitude' => $b->latitude ? (float) $b->latitude : null,
'longitude' => $b->longitude ? (float) $b->longitude : null,
'operating_hours' => $b->operating_hours,
'is_main' => $b->is_main,
]),
]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Identity\Models\Person;
use App\Domain\Identity\Services\PersonService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ProfileController extends Controller
{
public function show(Request $request): JsonResponse
{
$user = $request->user();
$person = $user->person_id ? Person::find($user->person_id) : null;
return response()->json([
'data' => [
'user' => [
'id' => $user->id,
'name' => $user->name,
'name_ar' => $user->name_ar,
'email' => $user->email,
'phone' => $user->phone,
],
'person' => $person ? [
'name' => $person->name,
'name_ar' => $person->name_ar,
'email' => $person->email,
'phone' => $person->phone,
'phone_secondary' => $person->phone_secondary,
'date_of_birth' => $person->date_of_birth?->toDateString(),
'gender' => $person->gender,
'nationality' => $person->nationality,
'address' => $person->address,
'city' => $person->city,
'governorate' => $person->governorate,
'blood_type' => $person->blood_type,
'medical_notes' => $person->medical_notes,
'photo_url' => $person->photo_path ? asset('storage/' . $person->photo_path) : null,
'emergency_contact' => [
'name' => $person->emergency_contact_name,
'phone' => $person->emergency_contact_phone,
'relation' => $person->emergency_contact_relation,
],
] : null,
],
]);
}
public function update(Request $request, PersonService $personService): JsonResponse
{
$request->validate([
'phone_secondary' => 'nullable|string|max:20',
'address' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'emergency_contact_name' => 'nullable|string|max:100',
'emergency_contact_phone' => 'nullable|string|max:20',
'emergency_contact_relation' => 'nullable|string|max:50',
'medical_notes' => 'nullable|string|max:500',
]);
$user = $request->user();
if (!$user->person_id) {
return response()->json([
'error' => 'no_profile',
'message' => 'لا يوجد ملف شخصي مرتبط بحسابك',
], 404);
}
$person = Person::findOrFail($user->person_id);
$allowedFields = [
'phone_secondary', 'address', 'city',
'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation',
'medical_notes',
];
$data = $request->only($allowedFields);
$data = array_filter($data, fn ($v) => $v !== null);
if (empty($data)) {
return response()->json([
'error' => 'no_changes',
'message' => 'لا توجد بيانات لتحديثها',
], 422);
}
$personService->update($person, $data, $user);
return response()->json([
'success' => true,
'message' => 'تم تحديث البيانات بنجاح',
]);
}
public function uploadPhoto(Request $request): JsonResponse
{
$request->validate([
'photo' => 'required|image|mimes:jpeg,jpg,png|max:5120',
'target' => 'nullable|in:profile,participant',
'participant_uuid' => 'nullable|string',
]);
$user = $request->user();
$file = $request->file('photo');
$target = $request->input('target', 'profile');
if ($target === 'participant' && $request->participant_uuid) {
return $this->uploadParticipantPhoto($user, $request->participant_uuid, $file);
}
if (!$user->person_id) {
return response()->json([
'error' => 'no_profile',
'message' => 'لا يوجد ملف شخصي مرتبط بحسابك',
], 404);
}
$person = Person::findOrFail($user->person_id);
if ($person->photo_path) {
Storage::disk('public')->delete($person->photo_path);
}
$path = $file->store('photos/profiles', 'public');
$person->update(['photo_path' => $path]);
return response()->json([
'success' => true,
'message' => 'تم رفع الصورة بنجاح',
'data' => [
'photo_url' => asset('storage/' . $path),
],
]);
}
private function uploadParticipantPhoto($user, string $participantUuid, $file): JsonResponse
{
$participant = \App\Domain\Participant\Models\Participant::where('uuid', $participantUuid)->first();
if (!$participant) {
return response()->json(['error' => 'not_found', 'message' => 'المشترك غير موجود'], 404);
}
$personId = $user->person_id;
if (!$personId) {
abort(403);
}
$authorized = false;
if ($participant->person_id === $personId) {
$authorized = true;
} else {
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
if ($guardian && $guardian->participants()->where('participants.id', $participant->id)->exists()) {
$authorized = true;
}
}
if (!$authorized) {
return response()->json(['error' => 'unauthorized', 'message' => 'غير مصرح'], 403);
}
if ($participant->photo_path) {
Storage::disk('public')->delete($participant->photo_path);
}
$path = $file->store('photos/participants', 'public');
$participant->update(['photo_path' => $path]);
return response()->json([
'success' => true,
'message' => 'تم رفع صورة المشترك بنجاح',
'data' => [
'photo_url' => asset('storage/' . $path),
],
]);
}
}
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class ApiResponseHeaders
{
public function handle(Request $request, Closure $next): Response
{
$requestId = Str::uuid()->toString();
$request->headers->set('X-Request-Id', $requestId);
$response = $next($request);
$response->headers->set('X-Request-Id', $requestId);
$response->headers->set('X-Api-Version', '1.0');
$response->headers->set('X-Powered-By', 'El Captain');
return $response;
}
}
......@@ -25,6 +25,7 @@
]);
$middleware->api(append: [
\App\Http\Middleware\SetCurrentAcademy::class,
\App\Http\Middleware\ApiResponseHeaders::class,
]);
$middleware->alias([
'permission' => \App\Http\Middleware\CheckPermission::class,
......
......@@ -4,6 +4,7 @@
use App\Http\Controllers\Api\V1\AcademyController;
use App\Http\Controllers\Api\V1\AppConfigController;
use App\Http\Controllers\Api\V1\AuthOtpController;
use App\Http\Controllers\Api\V1\BranchController;
use App\Http\Controllers\Api\V1\DashboardController;
use App\Http\Controllers\Api\V1\DeviceController;
use App\Http\Controllers\Api\V1\DocumentController;
......@@ -15,6 +16,7 @@
use App\Http\Controllers\Api\V1\OrderController;
use App\Http\Controllers\Api\V1\ParticipantController;
use App\Http\Controllers\Api\V1\PaymentController;
use App\Http\Controllers\Api\V1\ProfileController;
use App\Http\Controllers\Api\V1\ReceiptController;
use App\Http\Controllers\Api\V1\ServiceRequestController;
use App\Http\Controllers\Api\V1\ShopController;
......@@ -22,9 +24,19 @@
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->middleware('throttle:api')->group(function () {
// Health check (public, lightweight)
Route::get('health', fn () => response()->json([
'status' => 'ok',
'version' => '1.0',
'timestamp' => now()->toIso8601String(),
]));
// Public (no auth required)
Route::get('app/config', [AppConfigController::class, 'index']);
// Branches (public — for branch locator)
Route::get('branches', [BranchController::class, 'index']);
// Auth (stricter rate limit)
Route::prefix('auth')->middleware('throttle:api-auth')->group(function () {
Route::post('otp/request', [AuthOtpController::class, 'requestOtp'])->middleware('throttle:api-otp');
......@@ -50,6 +62,11 @@
// Dashboard (single-call home screen)
Route::get('dashboard', [DashboardController::class, 'index']);
// Profile
Route::get('profile', [ProfileController::class, 'show']);
Route::patch('profile', [ProfileController::class, 'update']);
Route::post('profile/photo', [ProfileController::class, 'uploadPhoto']);
// Device token registration
Route::post('devices/register', [DeviceController::class, 'register']);
Route::patch('devices/refresh', [DeviceController::class, 'refresh']);
......
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
class AuthOtpTest extends TestCase
{
public function test_otp_request_requires_phone(): void
{
$response = $this->postJson('/api/v1/auth/otp/request', []);
$response->assertStatus(422);
}
public function test_otp_request_validates_phone_format(): void
{
$response = $this->postJson('/api/v1/auth/otp/request', [
'phone' => 'invalid',
]);
$response->assertStatus(422);
}
public function test_otp_verify_requires_phone_and_otp(): void
{
$response = $this->postJson('/api/v1/auth/otp/verify', []);
$response->assertStatus(422);
}
public function test_protected_endpoints_require_auth(): void
{
$endpoints = [
['GET', '/api/v1/dashboard'],
['GET', '/api/v1/guardian/children'],
['GET', '/api/v1/profile'],
['GET', '/api/v1/notifications'],
['GET', '/api/v1/messages'],
['GET', '/api/v1/products'],
];
foreach ($endpoints as [$method, $url]) {
$response = $this->json($method, $url);
$this->assertContains($response->status(), [401, 403],
"Expected 401/403 for {$method} {$url}, got {$response->status()}");
}
}
public function test_logout_requires_auth(): void
{
$response = $this->postJson('/api/v1/auth/logout');
$response->assertUnauthorized();
}
}
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
class HealthCheckTest extends TestCase
{
public function test_health_endpoint_returns_ok(): void
{
$response = $this->getJson('/api/v1/health');
$response->assertOk()
->assertJsonStructure(['status', 'version', 'timestamp'])
->assertJson(['status' => 'ok', 'version' => '1.0']);
}
public function test_app_config_returns_structure(): void
{
$response = $this->getJson('/api/v1/app/config');
// May 404 if no academy context — acceptable in test env
$response->assertStatus($response->status());
}
public function test_api_routes_have_version_header(): void
{
$response = $this->getJson('/api/v1/health');
$response->assertHeader('X-Api-Version', '1.0');
$response->assertHeader('X-Request-Id');
}
}
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
class PublicEndpointsTest extends TestCase
{
public function test_branches_endpoint_does_not_require_auth(): void
{
$response = $this->getJson('/api/v1/branches');
// 200 if DB connected, 500 if not — key is NOT 401/403
$this->assertNotEquals(401, $response->status());
$this->assertNotEquals(403, $response->status());
}
public function test_academy_news_does_not_require_auth(): void
{
$response = $this->getJson('/api/v1/academy/news');
$this->assertNotEquals(401, $response->status());
$this->assertNotEquals(403, $response->status());
}
public function test_academy_programs_does_not_require_auth(): void
{
$response = $this->getJson('/api/v1/academy/programs');
$this->assertNotEquals(401, $response->status());
$this->assertNotEquals(403, $response->status());
}
public function test_academy_events_does_not_require_auth(): void
{
$response = $this->getJson('/api/v1/academy/events');
$this->assertNotEquals(401, $response->status());
$this->assertNotEquals(403, $response->status());
}
public function test_academy_gallery_does_not_require_auth(): void
{
$response = $this->getJson('/api/v1/academy/gallery');
$this->assertNotEquals(401, $response->status());
$this->assertNotEquals(403, $response->status());
}
public function test_payment_callback_does_not_require_auth(): void
{
$response = $this->postJson('/api/v1/payments/callback', [
'obj' => ['order' => null],
]);
$this->assertNotEquals(401, $response->status());
}
public function test_health_check_is_public_and_fast(): void
{
$response = $this->getJson('/api/v1/health');
$response->assertOk()
->assertJson(['status' => 'ok']);
}
}
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
class RateLimitingTest extends TestCase
{
public function test_otp_endpoint_is_rate_limited(): void
{
// OTP limit is 3 per minute per phone
for ($i = 0; $i < 4; $i++) {
$response = $this->postJson('/api/v1/auth/otp/request', [
'phone' => '+201012345678',
]);
}
// The 4th request should be rate limited (429)
$this->assertEquals(429, $response->status());
}
public function test_general_api_has_rate_limit_headers(): void
{
$response = $this->getJson('/api/v1/health');
$response->assertOk();
// Laravel includes these headers when rate limiting middleware is active
$this->assertTrue(
$response->headers->has('X-RateLimit-Limit') || $response->headers->has('x-ratelimit-limit'),
'Rate limit headers should be present'
);
}
}
<?php
namespace Tests\Feature\Api;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class ValidationTest extends TestCase
{
public function test_absence_report_validates_required_fields(): void
{
$user = User::first();
if (!$user) {
$this->markTestSkipped('No users in database');
}
Sanctum::actingAs($user, ['mobile:*']);
$response = $this->postJson('/api/v1/absences/report', []);
$response->assertUnprocessable()
->assertJsonValidationErrors(['participant_uuid', 'session_id', 'reason']);
}
public function test_message_send_validates_required_fields(): void
{
$user = User::first();
if (!$user) {
$this->markTestSkipped('No users in database');
}
Sanctum::actingAs($user, ['mobile:*']);
$response = $this->postJson('/api/v1/messages/send', []);
$response->assertUnprocessable()
->assertJsonValidationErrors(['subject', 'body']);
}
public function test_service_request_validates_type(): void
{
$user = User::first();
if (!$user) {
$this->markTestSkipped('No users in database');
}
Sanctum::actingAs($user, ['mobile:*']);
$response = $this->postJson('/api/v1/service-requests', [
'participant_uuid' => 'fake-uuid',
'type' => 'invalid_type',
'reason' => 'test',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['type']);
}
public function test_order_create_validates_required_fields(): void
{
$user = User::first();
if (!$user) {
$this->markTestSkipped('No users in database');
}
Sanctum::actingAs($user, ['mobile:*']);
$response = $this->postJson('/api/v1/orders/create', []);
$response->assertUnprocessable()
->assertJsonValidationErrors(['participant_uuid', 'product_id', 'payment_method']);
}
public function test_payment_initiate_validates_invoice_uuid(): void
{
$user = User::first();
if (!$user) {
$this->markTestSkipped('No users in database');
}
Sanctum::actingAs($user, ['mobile:*']);
$response = $this->postJson('/api/v1/payments/initiate', []);
$response->assertUnprocessable()
->assertJsonValidationErrors(['invoice_uuid']);
}
public function test_photo_upload_validates_file(): void
{
$user = User::first();
if (!$user) {
$this->markTestSkipped('No users in database');
}
Sanctum::actingAs($user, ['mobile:*']);
$response = $this->postJson('/api/v1/profile/photo', []);
$response->assertUnprocessable()
->assertJsonValidationErrors(['photo']);
}
public function test_profile_update_rejects_empty_payload(): void
{
$user = User::first();
if (!$user) {
$this->markTestSkipped('No users in database');
}
Sanctum::actingAs($user, ['mobile:*']);
$response = $this->patchJson('/api/v1/profile', []);
// Either 422 (no changes) or 404 (no person) — both are valid
$this->assertContains($response->status(), [404, 422]);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment