Commit 7a8a5594 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Mobile API Phase 1: Sanctum auth, OTP flow, participant endpoints

- Install Laravel Sanctum, add HasApiTokens to User model
- Register API routes under /api/v1/ prefix in bootstrap/app.php
- Create AppConfigController (public): academy branding, feature flags, auth mode
- Create AuthOtpController: OTP request/verify with demo mode, rate limiting
- Create DeviceController: FCM token register/refresh/delete
- Create ParticipantController: children, show, summary, schedule, attendance, invoices, enrollments
- Create API Resources (User, Participant, Session, Attendance, Invoice, Enrollment)
- Create device_tokens migration with platform CHECK constraint
- Create DeviceToken model with BelongsToAcademy trait
- Add mobile API implementation plan document
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 0da841fd
# Mobile API Implementation Plan — System Side
This is the step-by-step work to prepare the Laravel backend for the Flutter mobile app.
Each step is a vertical slice: migration → model → service → controller → test in browser/Postman.
---
## Phase 1: Foundation (API scaffold + auth)
### Step 1.1: API Route File + Versioned Prefix
**File:** `routes/api.php`
```php
Route::prefix('v1')->group(function () {
// Public (no auth)
Route::get('app/config', [AppConfigController::class, 'index']);
// Auth
Route::prefix('auth')->group(function () {
Route::post('otp/request', [AuthOtpController::class, 'request']);
Route::post('otp/verify', [AuthOtpController::class, 'verify']);
Route::post('logout', [AuthOtpController::class, 'logout'])->middleware('auth:sanctum');
Route::get('me', [AuthOtpController::class, 'me'])->middleware('auth:sanctum');
});
// Protected
Route::middleware('auth:sanctum')->group(function () {
// ... all other endpoints
});
});
```
**Work:**
- Create `routes/api.php` (Laravel already has it scaffolded, just empty)
- Add `api` middleware group in `bootstrap/app.php` if not present
- Ensure Sanctum is configured for token auth (stateless, no cookies)
- Add `HasApiTokens` trait to User model
---
### Step 1.2: App Config Endpoint
**Controller:** `app/Http/Controllers/Api/V1/AppConfigController.php`
**Logic:**
- Read current academy (from host header or default)
- Return: name, logo URL, colors, feature flags, auth mode, min version
**System settings keys needed:**
- `app_primary_color` (default: academy's website primary color)
- `app_accent_color`
- `app_features_shop` (bool)
- `app_features_events` (bool)
- `app_features_chat` (bool)
- `app_min_version` (string, e.g. "1.0.0")
- `app_maintenance_mode` (bool)
- `app_maintenance_message` (text)
- `auth_otp_mode` ('demo' | 'sms')
**Migration:** Add these keys to `system_settings` seeder (or insert-if-not-exists migration).
---
### Step 1.3: OTP Auth Flow
**Controller:** `app/Http/Controllers/Api/V1/AuthOtpController.php`
**Endpoints:**
`POST /api/v1/auth/otp/request`
```json
// Request
{"phone": "+201012345678"}
// Response (demo mode)
{"sent": true, "mode": "demo", "expires_in": 300}
// Response (sms mode)
{"sent": true, "mode": "sms", "expires_in": 300}
// Error: phone not found
{"error": "phone_not_found", "message": "هذا الرقم غير مسجل في النظام"}
```
`POST /api/v1/auth/otp/verify`
```json
// Request
{"phone": "+201012345678", "otp": "123456"}
// Response
{
"token": "1|abc123...",
"user": {"id": 1, "name_ar": "أحمد محمد", "phone": "+201012345678"},
"participants": [
{"uuid": "abc-123", "name_ar": "محمد أحمد", "program": "فريق 2016", "photo_url": null}
]
}
// Error: wrong OTP
{"error": "invalid_otp", "message": "رمز التحقق غير صحيح"}
```
**Logic:**
```php
public function request(Request $request)
{
$phone = $this->normalizePhone($request->phone);
$user = User::where('phone', $phone)->first();
if (!$user) return error('phone_not_found');
$mode = SystemSetting::get('auth_otp_mode', 'demo');
if ($mode === 'demo') {
Cache::put("otp:{$phone}", '123456', 300);
} else {
$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
Cache::put("otp:{$phone}", $otp, 300);
SmsService::send($phone, "رمز التحقق: {$otp}");
}
return response()->json(['sent' => true, 'mode' => $mode, 'expires_in' => 300]);
}
public function verify(Request $request)
{
$phone = $this->normalizePhone($request->phone);
$cached = Cache::get("otp:{$phone}");
if (!$cached || $cached !== $request->otp) {
return error('invalid_otp');
}
Cache::forget("otp:{$phone}");
$user = User::where('phone', $phone)->firstOrFail();
$token = $user->createToken('mobile', ['mobile:*'])->plainTextToken;
$participants = $this->getLinkedParticipants($user);
return response()->json([
'token' => $token,
'user' => UserResource::make($user),
'participants' => ParticipantResource::collection($participants),
]);
}
```
**Rate limiting:** 3 OTP requests per phone per 10 minutes.
---
### Step 1.4: Device Token Registration
**Migration:** `create_device_tokens_table`
```php
Schema::create('device_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('academies');
$table->foreignId('user_id')->constrained('users');
$table->string('device_token', 255)->unique();
$table->string('platform', 10); // android, ios
$table->string('device_name', 100)->nullable();
$table->string('app_version', 20)->nullable();
$table->boolean('is_active')->default(true);
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'is_active']);
});
DB::statement("ALTER TABLE device_tokens ADD CONSTRAINT device_tokens_platform_check CHECK (platform IN ('android', 'ios'))");
```
**Controller:** `app/Http/Controllers/Api/V1/DeviceController.php`
```
POST /api/v1/devices/register {"token": "fcm_xxx", "platform": "android", "device_name": "Samsung S24", "app_version": "1.0.0"}
PATCH /api/v1/devices/refresh {"old_token": "fcm_old", "new_token": "fcm_new"}
DELETE /api/v1/devices/{token} (on logout)
```
---
### Step 1.5: Participant Data Endpoints
**Controller:** `app/Http/Controllers/Api/V1/ParticipantController.php`
```
GET /api/v1/participants/{uuid} → ParticipantResource (full profile)
GET /api/v1/participants/{uuid}/summary → dashboard data
GET /api/v1/participants/{uuid}/schedule → upcoming sessions (next 7 days)
GET /api/v1/participants/{uuid}/attendance → attendance history + rate
GET /api/v1/participants/{uuid}/invoices → outstanding + recent paid
GET /api/v1/participants/{uuid}/enrollments → active enrollments
GET /api/v1/guardian/children → all linked participants
```
**Authorization:** User can only access participants linked to them (own children or self).
**Resources:**
```
app/Http/Resources/Api/V1/
├── ParticipantResource.php
├── ParticipantSummaryResource.php
├── SessionResource.php
├── AttendanceResource.php
├── InvoiceResource.php
├── InvoiceItemResource.php
├── EnrollmentResource.php
└── UserResource.php
```
---
## Phase 2: Push Notifications
### Step 2.1: Install Firebase PHP SDK
```bash
composer require kreait/firebase-php
```
---
### Step 2.2: PushNotificationService
**File:** `app/Domain/Shared/Services/PushNotificationService.php`
```php
class PushNotificationService
{
public function sendToUser(User $user, string $title, string $body, array $data = []): void
public function sendToParticipantGuardians(Participant $participant, string $title, string $body, array $data = []): void
public function sendToGroup(TrainingGroup $group, string $title, string $body, array $data = []): void
public function sendBulk(array $userIds, string $title, string $body, array $data = []): void
}
```
**Credentials loading:**
1. Check `system_settings` for `firebase_service_account_json`
2. If not found, check `storage/app/firebase/service-account.json`
3. If not found, check env `FIREBASE_CREDENTIALS`
4. If nothing → log warning, don't crash (graceful degradation)
---
### Step 2.3: Event Listeners → Push
Extend existing event listeners to also dispatch push notifications:
| Event | Push Target | Title | Body |
|-------|-------------|-------|------|
| `AttendanceMarked` | participant's guardians | تسجيل حضور | تم تسجيل حضور {name} في جلسة اليوم |
| `AttendanceMarked` (absent) | participant's guardians | تسجيل غياب | تم تسجيل غياب {name} في جلسة اليوم |
| `InvoiceCreated` | invoice billable's user | فاتورة جديدة | فاتورة بقيمة {amount} ج.م بانتظار الدفع |
| `PaymentConfirmed` | payer | تأكيد الدفع | تم تأكيد دفع {amount} ج.م بنجاح |
| `SessionCancelled` | group participants' guardians | إلغاء جلسة | تم إلغاء جلسة {group} يوم {date} |
| `EnrollmentCreated` | participant's guardians | تأكيد الاشتراك | تم تسجيل {name} في {program} |
---
### Step 2.4: Scheduled Push Jobs
**Session Reminder:**
```php
// Runs every minute
// Find sessions starting in exactly 30 minutes
// Push to all participants' guardians in those sessions
```
**Installment Due Reminder:**
```php
// Runs daily at 9am
// Find installments with due_date = tomorrow
// Push to the participant's guardian
```
**Medical Certificate Expiry:**
```php
// Runs daily
// Find approved medical certs expiring in 7 days
// Push to participant's guardian
```
---
### Step 2.5: Notification History Endpoint
**Controller:** `app/Http/Controllers/Api/V1/NotificationController.php`
```
GET /api/v1/notifications → paginated list (20 per page)
PATCH /api/v1/notifications/{id}/read → mark as read
POST /api/v1/notifications/read-all → mark all as read
POST /api/v1/notifications/preferences → update per-type toggles
GET /api/v1/notifications/preferences → current preferences
```
Uses existing `notification_logs` table, filtered by user.
---
## Phase 3: Academy Explore + Shop
### Step 3.1: Academy Info/News/Gallery
**Controller:** `app/Http/Controllers/Api/V1/AcademyController.php`
```
GET /api/v1/academy/news → paginated news articles (from website_news table)
GET /api/v1/academy/gallery → paginated gallery items (from website sections)
GET /api/v1/academy/programs → list of active programs with descriptions
GET /api/v1/academy/events → upcoming events
POST /api/v1/events/{uuid}/register → join event
```
---
### Step 3.2: Product Shop API
**Controller:** `app/Http/Controllers/Api/V1/ShopController.php`
```
GET /api/v1/products → essential products list
```
Response includes `already_purchased_at` field per product (same logic as POS warning):
```json
{
"data": [
{
"id": 1,
"name_ar": "الزي الرسمي",
"price": 65000,
"image_url": "...",
"installment_plans": [...],
"already_purchased_at": "2026-03-15" // null if not purchased this year
}
]
}
```
```
POST /api/v1/orders/create → create order + initiate payment
GET /api/v1/orders → order history
GET /api/v1/orders/{uuid} → order details + delivery status
```
---
## Phase 4: Financial API
### Step 4.1: Invoice & Payment Endpoints
**Controller:** `app/Http/Controllers/Api/V1/FinancialController.php`
```
GET /api/v1/participants/{uuid}/invoices → outstanding + paid (paginated)
GET /api/v1/invoices/{uuid} → invoice detail with items
POST /api/v1/payments/initiate → start online payment
POST /api/v1/payments/callback → webhook from payment gateway (no auth)
```
**Payment initiation response:**
```json
{
"payment_key": "ZXlKaGJ...", // Paymob iframe key
"iframe_url": "https://accept.paymob.com/api/acceptance/iframes/...",
"order_id": "ORD-123"
}
```
---
### Step 4.2: Payment Gateway Integration (Paymob)
**Service:** `app/Domain/Financial/Services/PaymobService.php`
```php
class PaymobService
{
public function createPaymentIntention(Invoice $invoice, User $payer): array
public function verifyCallback(Request $request): bool
public function processSuccessfulPayment(array $callbackData): Payment
}
```
**System settings:**
- `paymob_api_key`
- `paymob_integration_id`
- `paymob_iframe_id`
- `paymob_hmac_secret`
---
## Phase 5: Communication + Polish
### Step 5.1: Report Absence
**Controller:** `app/Http/Controllers/Api/V1/AbsenceController.php`
```
POST /api/v1/absences/report
{"participant_uuid": "abc", "session_id": 123, "reason": "مرض"}
```
Creates an `excused` attendance record for a future session.
---
### Step 5.2: Contact Academy
**Controller:** `app/Http/Controllers/Api/V1/MessageController.php`
```
POST /api/v1/messages/send
{"subject": "استفسار", "body": "...", "participant_uuid": "abc"}
```
Creates a record in a new `contact_messages` table and notifies academy admin.
---
### Step 5.3: Rate Limiting + Error Format
**Middleware:** `app/Http/Middleware/ApiRateLimit.php`
```
OTP requests: 3 per phone per 10 min
General API: 120 requests per minute per token
Payment: 5 per minute per token
```
**Standard error response:**
```json
{
"error": "error_code",
"message": "رسالة باللغة العربية",
"details": {} // optional validation errors
}
```
---
### Step 5.4: System Settings UI for Mobile App
Add a new tab/section in the existing System Settings Livewire component:
- **Mobile App** section with:
- OTP mode toggle (demo / sms)
- Firebase service account JSON upload
- Paymob credentials (API key, integration ID, HMAC)
- Feature toggles (shop, events, chat, online payment)
- Minimum app version
- Maintenance mode toggle + message
---
## File Structure (new files)
```
app/Http/Controllers/Api/V1/
├── AppConfigController.php
├── AuthOtpController.php
├── DeviceController.php
├── ParticipantController.php
├── FinancialController.php
├── NotificationController.php
├── AcademyController.php
├── ShopController.php
├── AbsenceController.php
└── MessageController.php
app/Http/Resources/Api/V1/
├── UserResource.php
├── ParticipantResource.php
├── ParticipantSummaryResource.php
├── SessionResource.php
├── AttendanceResource.php
├── InvoiceResource.php
├── InvoiceItemResource.php
├── EnrollmentResource.php
├── NotificationResource.php
├── ProductResource.php
├── OrderResource.php
├── EventResource.php
└── NewsResource.php
app/Http/Middleware/
└── ApiRateLimit.php
app/Domain/Shared/Services/
├── PushNotificationService.php
└── SmsService.php
app/Domain/Financial/Services/
└── PaymobService.php
database/migrations/
├── xxxx_create_device_tokens_table.php
├── xxxx_create_contact_messages_table.php
└── xxxx_add_mobile_app_system_settings.php
routes/
└── api.php
```
---
## Implementation Order (what to build first)
| # | What | Why First |
|---|------|-----------|
| 1 | `routes/api.php` + Sanctum setup | Everything depends on this |
| 2 | `GET /api/v1/app/config` | Flutter app needs this to boot |
| 3 | OTP auth (demo mode) | Flutter app needs login to work |
| 4 | Device token registration | Need this before push works |
| 5 | `GET /api/v1/guardian/children` | Home screen needs participant list |
| 6 | `GET /api/v1/participants/{uuid}/summary` | Dashboard data |
| 7 | Schedule + Attendance endpoints | Core daily-use screens |
| 8 | Invoice + Payment endpoints | Financial screens |
| 9 | `PushNotificationService` + Firebase | THE killer feature |
| 10 | Push event listeners | Attendance/invoice/session pushes |
| 11 | Scheduled reminder jobs | Session/installment/cert reminders |
| 12 | Academy explore (news/gallery/events) | Content screens |
| 13 | Shop API | Product purchasing |
| 14 | Paymob integration | Online payment |
| 15 | Communication (absence/messages) | Nice-to-have actions |
| 16 | System settings UI | Admin manages mobile config |
Steps 1-8 = **MVP** (app is usable for viewing data)
Steps 9-11 = **Core value** (push notifications work)
Steps 12-16 = **Full feature set**
<?php
namespace App\Domain\Shared\Models;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DeviceToken extends Model
{
use BelongsToAcademy;
protected $fillable = [
'academy_id',
'user_id',
'device_token',
'platform',
'device_name',
'app_version',
'is_active',
'last_used_at',
];
protected $casts = [
'is_active' => 'boolean',
'last_used_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Shared\Models\SystemSetting;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class AppConfigController extends Controller
{
public function index(): JsonResponse
{
$academy = app('current_academy');
if (!$academy) {
return response()->json([
'error' => 'academy_not_found',
'message' => 'لا يمكن تحديد الأكاديمية',
], 404);
}
return response()->json([
'academy' => [
'name' => $academy->name,
'name_ar' => $academy->name_ar,
'logo_url' => $academy->logo_path ? asset('storage/' . $academy->logo_path) : null,
'phone' => $academy->phone,
'email' => $academy->email,
],
'theme' => [
'primary_color' => SystemSetting::get('app_primary_color', '#1e40af'),
'accent_color' => SystemSetting::get('app_accent_color', '#f59e0b'),
],
'features' => [
'shop' => (bool) SystemSetting::get('app_features_shop', true),
'events' => (bool) SystemSetting::get('app_features_events', true),
'chat' => (bool) SystemSetting::get('app_features_chat', false),
'online_payment' => (bool) SystemSetting::get('app_features_online_payment', false),
],
'auth' => [
'otp_mode' => SystemSetting::get('auth_otp_mode', 'demo'),
],
'app' => [
'min_version' => SystemSetting::get('app_min_version', '1.0.0'),
'maintenance_mode' => (bool) SystemSetting::get('app_maintenance_mode', false),
'maintenance_message' => SystemSetting::get('app_maintenance_message', ''),
],
]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Shared\Models\SystemSetting;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\V1\ParticipantResource;
use App\Http\Resources\Api\V1\UserResource;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
class AuthOtpController extends Controller
{
public function requestOtp(Request $request): JsonResponse
{
$request->validate([
'phone' => 'required|string|min:10|max:15',
]);
$phone = $this->normalizePhone($request->phone);
$rateLimitKey = "otp_request:{$phone}";
if (RateLimiter::tooManyAttempts($rateLimitKey, 3)) {
$seconds = RateLimiter::availableIn($rateLimitKey);
return response()->json([
'error' => 'too_many_attempts',
'message' => "يرجى الانتظار {$seconds} ثانية قبل المحاولة مرة أخرى",
'retry_after' => $seconds,
], 429);
}
$user = User::withoutGlobalScope('academy')
->where('phone', $phone)
->where('status', 'active')
->first();
if (!$user) {
RateLimiter::hit($rateLimitKey, 600);
return response()->json([
'error' => 'phone_not_found',
'message' => 'هذا الرقم غير مسجل في النظام',
], 404);
}
$mode = SystemSetting::get('auth_otp_mode', 'demo');
if ($mode === 'demo') {
Cache::put("otp:{$phone}", '123456', 300);
} else {
$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
Cache::put("otp:{$phone}", $otp, 300);
// TODO: Send SMS via SmsService when SMS mode is enabled
}
RateLimiter::hit($rateLimitKey, 600);
return response()->json([
'sent' => true,
'mode' => $mode,
'expires_in' => 300,
]);
}
public function verify(Request $request): JsonResponse
{
$request->validate([
'phone' => 'required|string|min:10|max:15',
'otp' => 'required|string|size:6',
]);
$phone = $this->normalizePhone($request->phone);
$cached = Cache::get("otp:{$phone}");
if (!$cached || $cached !== $request->otp) {
return response()->json([
'error' => 'invalid_otp',
'message' => 'رمز التحقق غير صحيح',
], 401);
}
Cache::forget("otp:{$phone}");
$user = User::withoutGlobalScope('academy')
->where('phone', $phone)
->where('status', 'active')
->first();
if (!$user) {
return response()->json([
'error' => 'user_not_found',
'message' => 'المستخدم غير موجود',
], 404);
}
// Revoke old mobile tokens for this user
$user->tokens()->where('name', 'mobile')->delete();
$token = $user->createToken('mobile', ['mobile:*'])->plainTextToken;
$participants = $this->getLinkedParticipants($user);
return response()->json([
'token' => $token,
'user' => new UserResource($user),
'participants' => ParticipantResource::collection($participants),
]);
}
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'تم تسجيل الخروج بنجاح']);
}
public function me(Request $request): JsonResponse
{
$user = $request->user();
$participants = $this->getLinkedParticipants($user);
return response()->json([
'user' => new UserResource($user),
'participants' => ParticipantResource::collection($participants),
]);
}
private function normalizePhone(string $phone): string
{
$phone = preg_replace('/[\s\-]/', '', $phone);
if (str_starts_with($phone, '01') && strlen($phone) === 11) {
$phone = '+2' . $phone;
}
if (str_starts_with($phone, '2') && !str_starts_with($phone, '+')) {
$phone = '+' . $phone;
}
return $phone;
}
private function getLinkedParticipants(User $user): \Illuminate\Support\Collection
{
// Find participants linked through guardian relationship or direct person link
$personId = $user->person_id;
if (!$personId) {
return collect();
}
// Check if user IS a participant (person → participant)
$directParticipants = \App\Domain\Participant\Models\Participant::where('person_id', $personId)
->with(['person', 'activeEnrollments.group.program.activity'])
->get();
// Check if user is a guardian (person → guardian → participants)
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
$guardianParticipants = collect();
if ($guardian) {
$guardianParticipants = $guardian->participants()
->with(['person', 'activeEnrollments.group.program.activity'])
->get();
}
return $directParticipants->merge($guardianParticipants)->unique('id');
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Shared\Models\DeviceToken;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DeviceController extends Controller
{
public function register(Request $request): JsonResponse
{
$request->validate([
'token' => 'required|string|max:255',
'platform' => 'required|in:android,ios',
'device_name' => 'nullable|string|max:100',
'app_version' => 'nullable|string|max:20',
]);
$user = $request->user();
DeviceToken::withoutGlobalScope('academy')->updateOrCreate(
['device_token' => $request->token],
[
'academy_id' => $user->academy_id,
'user_id' => $user->id,
'platform' => $request->platform,
'device_name' => $request->device_name,
'app_version' => $request->app_version,
'is_active' => true,
'last_used_at' => now(),
]
);
return response()->json(['registered' => true]);
}
public function refresh(Request $request): JsonResponse
{
$request->validate([
'old_token' => 'required|string|max:255',
'new_token' => 'required|string|max:255',
]);
$device = DeviceToken::withoutGlobalScope('academy')
->where('device_token', $request->old_token)
->where('user_id', $request->user()->id)
->first();
if ($device) {
$device->update([
'device_token' => $request->new_token,
'last_used_at' => now(),
]);
} else {
return response()->json([
'error' => 'device_not_found',
'message' => 'الجهاز غير مسجل',
], 404);
}
return response()->json(['refreshed' => true]);
}
public function destroy(string $token, Request $request): JsonResponse
{
DeviceToken::withoutGlobalScope('academy')
->where('device_token', $token)
->where('user_id', $request->user()->id)
->delete();
return response()->json(['deleted' => true]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingSession;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\V1\AttendanceResource;
use App\Http\Resources\Api\V1\EnrollmentResource;
use App\Http\Resources\Api\V1\InvoiceResource;
use App\Http\Resources\Api\V1\ParticipantResource;
use App\Http\Resources\Api\V1\SessionResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ParticipantController extends Controller
{
public function children(Request $request): JsonResponse
{
$user = $request->user();
$participants = $this->getAuthorizedParticipants($user);
return response()->json([
'data' => ParticipantResource::collection($participants),
]);
}
public function show(string $uuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($uuid, $request->user());
$participant->load(['person', 'primaryActivity', 'branch', 'activeEnrollments.group.program']);
return response()->json([
'data' => new ParticipantResource($participant),
]);
}
public function summary(string $uuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($uuid, $request->user());
$activeEnrollments = $participant->activeEnrollments()->count();
$attendanceRate = $this->calculateAttendanceRate($participant);
$outstandingBalance = Invoice::where('billable_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('billable_id', $participant->id)
->whereNotIn('status', ['cancelled', 'draft', 'paid'])
->sum('balance_due');
$nextSession = $this->getNextSession($participant);
return response()->json([
'data' => [
'participant_uuid' => $participant->uuid,
'name_ar' => $participant->person->name_ar,
'photo_url' => $participant->photo_path ? asset('storage/' . $participant->photo_path) : null,
'status' => $participant->status->value,
'active_enrollments' => $activeEnrollments,
'attendance_rate' => $attendanceRate,
'outstanding_balance' => $outstandingBalance,
'next_session' => $nextSession ? new SessionResource($nextSession) : null,
],
]);
}
public function schedule(string $uuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($uuid, $request->user());
$groupIds = $participant->activeEnrollments()->pluck('training_group_id');
$sessions = TrainingSession::whereIn('training_group_id', $groupIds)
->where('session_date', '>=', now()->toDateString())
->where('session_date', '<=', now()->addDays(7)->toDateString())
->whereIn('status', ['scheduled', 'in_progress'])
->with(['group.program.activity', 'facility'])
->orderBy('session_date')
->orderBy('start_time')
->get();
return response()->json([
'data' => SessionResource::collection($sessions),
]);
}
public function attendance(string $uuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($uuid, $request->user());
$records = AttendanceRecord::where('subject_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('subject_id', $participant->id)
->with(['session.group.program'])
->orderByDesc('date')
->paginate(20);
$rate = $this->calculateAttendanceRate($participant);
return response()->json([
'rate' => $rate,
'data' => AttendanceResource::collection($records),
'meta' => [
'current_page' => $records->currentPage(),
'last_page' => $records->lastPage(),
'per_page' => $records->perPage(),
'total' => $records->total(),
],
]);
}
public function invoices(string $uuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($uuid, $request->user());
$invoices = Invoice::where('billable_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('billable_id', $participant->id)
->whereNotIn('status', ['cancelled', 'draft'])
->with('items')
->orderByDesc('created_at')
->paginate(15);
return response()->json([
'data' => InvoiceResource::collection($invoices),
'meta' => [
'current_page' => $invoices->currentPage(),
'last_page' => $invoices->lastPage(),
'per_page' => $invoices->perPage(),
'total' => $invoices->total(),
],
]);
}
public function enrollments(string $uuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($uuid, $request->user());
$enrollments = Enrollment::where('participant_id', $participant->id)
->whereIn('status', ['active', 'pending'])
->with(['group.program.activity', 'group.schedules'])
->get();
return response()->json([
'data' => EnrollmentResource::collection($enrollments),
]);
}
private function findAuthorized(string $uuid, $user): Participant
{
$participant = Participant::where('uuid', $uuid)->firstOrFail();
$authorized = $this->getAuthorizedParticipants($user);
if (!$authorized->contains('id', $participant->id)) {
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
return $participant;
}
private function getAuthorizedParticipants($user): \Illuminate\Support\Collection
{
$personId = $user->person_id;
if (!$personId) {
return collect();
}
$direct = Participant::where('person_id', $personId)
->with(['person', 'activeEnrollments.group.program.activity'])
->get();
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
$guardianParticipants = collect();
if ($guardian) {
$guardianParticipants = $guardian->participants()
->with(['person', 'activeEnrollments.group.program.activity'])
->get();
}
return $direct->merge($guardianParticipants)->unique('id');
}
private function calculateAttendanceRate(Participant $participant): float
{
$total = AttendanceRecord::where('subject_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('subject_id', $participant->id)
->whereNotIn('status', ['cancelled', 'exempt'])
->count();
if ($total === 0) {
return 0;
}
$positive = AttendanceRecord::where('subject_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('subject_id', $participant->id)
->whereIn('status', ['present', 'late', 'partial'])
->count();
return round($positive / $total * 100, 1);
}
private function getNextSession(Participant $participant): ?TrainingSession
{
$groupIds = $participant->activeEnrollments()->pluck('training_group_id');
return TrainingSession::whereIn('training_group_id', $groupIds)
->where('session_date', '>=', now()->toDateString())
->whereIn('status', ['scheduled'])
->orderBy('session_date')
->orderBy('start_time')
->first();
}
}
<?php
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AttendanceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'date' => $this->date?->format('Y-m-d'),
'status' => $this->status?->value ?? $this->status,
'check_in_time' => $this->actual_check_in,
'check_out_time' => $this->actual_check_out,
'late_minutes' => $this->late_minutes,
'notes' => $this->notes,
'session' => $this->whenLoaded('session', fn () => [
'id' => $this->session->id,
'date' => $this->session->session_date?->format('Y-m-d'),
'start_time' => $this->session->start_time,
'group_name_ar' => $this->session->group?->name_ar,
'program_name_ar' => $this->session->group?->program?->name_ar,
]),
];
}
}
<?php
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EnrollmentResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'status' => $this->status?->value ?? $this->status,
'enrolled_at' => $this->created_at?->format('Y-m-d'),
'group' => $this->whenLoaded('group', fn () => [
'id' => $this->group->id,
'name_ar' => $this->group->name_ar,
'program' => [
'name_ar' => $this->group->program?->name_ar,
'activity_name_ar' => $this->group->program?->activity?->name_ar,
],
'schedule' => $this->group->schedules?->map(fn ($s) => [
'day_of_week' => $s->day_of_week,
'start_time' => $s->start_time,
'end_time' => $s->end_time,
]) ?? [],
]),
];
}
}
<?php
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class InvoiceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'uuid' => $this->uuid,
'invoice_number' => $this->invoice_number,
'status' => $this->status?->value ?? $this->status,
'total_amount' => $this->total_amount,
'paid_amount' => $this->paid_amount,
'balance_due' => $this->balance_due,
'due_date' => $this->due_date?->format('Y-m-d'),
'created_at' => $this->created_at?->format('Y-m-d'),
'items' => $this->whenLoaded('items', fn () =>
$this->items->map(fn ($item) => [
'description' => $item->description_ar ?? $item->description,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
'total_amount' => $item->total_amount,
])
),
];
}
}
<?php
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ParticipantResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'uuid' => $this->uuid,
'name_ar' => $this->person?->name_ar,
'name' => $this->person?->name,
'photo_url' => $this->photo_path ? asset('storage/' . $this->photo_path) : null,
'status' => $this->status?->value,
'age' => $this->age,
'gender' => $this->person?->gender,
'branch' => $this->whenLoaded('branch', fn () => [
'id' => $this->branch->id,
'name_ar' => $this->branch->name_ar,
]),
'primary_activity' => $this->whenLoaded('primaryActivity', fn () => [
'id' => $this->primaryActivity->id,
'name_ar' => $this->primaryActivity->name_ar,
]),
'active_enrollments' => $this->whenLoaded('activeEnrollments', fn () =>
$this->activeEnrollments->map(fn ($e) => [
'id' => $e->id,
'program_name_ar' => $e->group?->program?->name_ar,
'group_name_ar' => $e->group?->name_ar,
'activity_name_ar' => $e->group?->program?->activity?->name_ar,
])
),
];
}
}
<?php
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SessionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'session_date' => $this->session_date?->format('Y-m-d'),
'start_time' => $this->start_time,
'end_time' => $this->end_time,
'status' => $this->status?->value ?? $this->status,
'group' => $this->whenLoaded('group', fn () => [
'id' => $this->group->id,
'name_ar' => $this->group->name_ar,
'program_name_ar' => $this->group->program?->name_ar,
'activity_name_ar' => $this->group->program?->activity?->name_ar,
]),
'facility' => $this->whenLoaded('facility', fn () => [
'id' => $this->facility->id,
'name_ar' => $this->facility->name_ar,
]),
];
}
}
<?php
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'uuid' => $this->uuid,
'name' => $this->name,
'name_ar' => $this->name_ar,
'email' => $this->email,
'phone' => $this->phone,
'avatar_url' => $this->avatar_path ? asset('storage/' . $this->avatar_path) : null,
];
}
}
......@@ -15,10 +15,11 @@
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasFactory, Notifiable, HasUuid, BelongsToAcademy, SoftDeletes, Auditable;
use HasFactory, Notifiable, HasApiTokens, HasUuid, BelongsToAcademy, SoftDeletes, Auditable;
protected $fillable = [
'academy_id',
......
......@@ -12,6 +12,7 @@
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
......
......@@ -8,6 +8,7 @@
"require": {
"php": "^8.4",
"laravel/framework": "^13.8",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0",
"livewire/livewire": "^4.3"
},
......@@ -23,7 +24,8 @@
"autoload": {
"files": [
"app/Helpers/money.php",
"app/Helpers/whatsapp.php"
"app/Helpers/whatsapp.php",
"app/Helpers/video.php"
],
"psr-4": {
"App\\": "app/",
......
......@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "15d459f8e83fdae144d5144f811f3d68",
"content-hash": "510aa304988e6cd8b1d427dd945253d5",
"packages": [
{
"name": "brick/math",
......@@ -1340,6 +1340,69 @@
},
"time": "2026-06-26T00:11:25+00:00"
},
{
"name": "laravel/sanctum",
"version": "v4.3.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
"reference": "fee27a573d1a013af3721d86153a65e0b11927e6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6",
"reference": "fee27a573d1a013af3721d86153a65e0b11927e6",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/contracts": "^11.0|^12.0|^13.0",
"illuminate/database": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
"symfony/console": "^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sanctum\\SanctumServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sanctum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
"keywords": [
"auth",
"laravel",
"sanctum"
],
"support": {
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
"time": "2026-06-23T18:26:55+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v2.0.13",
......@@ -8364,7 +8427,7 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.3"
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('device_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('academies');
$table->foreignId('user_id')->constrained('users');
$table->string('device_token', 255)->unique();
$table->string('platform', 10);
$table->string('device_name', 100)->nullable();
$table->string('app_version', 20)->nullable();
$table->boolean('is_active')->default(true);
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'is_active']);
$table->index('academy_id');
});
DB::statement("ALTER TABLE device_tokens ADD CONSTRAINT device_tokens_platform_check CHECK (platform IN ('android', 'ios'))");
}
public function down(): void
{
Schema::dropIfExists('device_tokens');
}
};
<?php
use App\Http\Controllers\Api\V1\AppConfigController;
use App\Http\Controllers\Api\V1\AuthOtpController;
use App\Http\Controllers\Api\V1\DeviceController;
use App\Http\Controllers\Api\V1\ParticipantController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::get('/stats', \App\Http\Controllers\Api\QuickStatsController::class)->name('api.stats');
Route::prefix('v1')->group(function () {
// Public (no auth required)
Route::get('app/config', [AppConfigController::class, 'index']);
// Auth
Route::prefix('auth')->group(function () {
Route::post('otp/request', [AuthOtpController::class, 'requestOtp']);
Route::post('otp/verify', [AuthOtpController::class, 'verify']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('logout', [AuthOtpController::class, 'logout']);
Route::get('me', [AuthOtpController::class, 'me']);
});
});
// Protected endpoints
Route::middleware('auth:sanctum')->group(function () {
// Device token registration
Route::post('devices/register', [DeviceController::class, 'register']);
Route::patch('devices/refresh', [DeviceController::class, 'refresh']);
Route::delete('devices/{token}', [DeviceController::class, 'destroy']);
// Guardian's children
Route::get('guardian/children', [ParticipantController::class, 'children']);
// Participant data
Route::prefix('participants/{uuid}')->group(function () {
Route::get('/', [ParticipantController::class, 'show']);
Route::get('summary', [ParticipantController::class, 'summary']);
Route::get('schedule', [ParticipantController::class, 'schedule']);
Route::get('attendance', [ParticipantController::class, 'attendance']);
Route::get('invoices', [ParticipantController::class, 'invoices']);
Route::get('enrollments', [ParticipantController::class, 'enrollments']);
});
});
});
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