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
This diff is collapsed.
<?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 @@ ...@@ -15,10 +15,11 @@
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable class User extends Authenticatable
{ {
use HasFactory, Notifiable, HasUuid, BelongsToAcademy, SoftDeletes, Auditable; use HasFactory, Notifiable, HasApiTokens, HasUuid, BelongsToAcademy, SoftDeletes, Auditable;
protected $fillable = [ protected $fillable = [
'academy_id', 'academy_id',
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
"require": { "require": {
"php": "^8.4", "php": "^8.4",
"laravel/framework": "^13.8", "laravel/framework": "^13.8",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"livewire/livewire": "^4.3" "livewire/livewire": "^4.3"
}, },
...@@ -23,7 +24,8 @@ ...@@ -23,7 +24,8 @@
"autoload": { "autoload": {
"files": [ "files": [
"app/Helpers/money.php", "app/Helpers/money.php",
"app/Helpers/whatsapp.php" "app/Helpers/whatsapp.php",
"app/Helpers/video.php"
], ],
"psr-4": { "psr-4": {
"App\\": "app/", "App\\": "app/",
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "15d459f8e83fdae144d5144f811f3d68", "content-hash": "510aa304988e6cd8b1d427dd945253d5",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
...@@ -1340,6 +1340,69 @@ ...@@ -1340,6 +1340,69 @@
}, },
"time": "2026-06-26T00:11:25+00:00" "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", "name": "laravel/serializable-closure",
"version": "v2.0.13", "version": "v2.0.13",
...@@ -8364,7 +8427,7 @@ ...@@ -8364,7 +8427,7 @@
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.3" "php": "^8.4"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "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 <?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; use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () { Route::prefix('v1')->group(function () {
Route::get('/stats', \App\Http\Controllers\Api\QuickStatsController::class)->name('api.stats'); // 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