Commit 883391c7 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(security): remove the mobile API surface and stop the 500 page leaking sessions

Two live disclosures and one latent account takeover, plus the infrastructure
defects that hid them.

AuthOtpController::verify() accepted a constant '0000' whenever auth_otp_mode was
'demo' — the value every instance was seeded with — and then minted a Sanctum
token for whichever active user matched the submitted phone number, staff
included. It was not exploitable as written, because 2026_08_30_000004 had
normalised users.phone to digits-only local form while normalizePhone() produced
+20…, so the lookup missed. That is one plausible bug-fix away from being live,
which is why the whole surface goes rather than the branch.

Deleting /api/v1 also removes: broadcast/send pushing to every device in the
academy with no permission check; ReceiptController's inverted ownership check,
which made any non-participant invoice world-readable to any token;
PaymentController::initiate with no ownership check at all; and
DeviceController keying updateOrCreate on the FCM token alone, letting one user
claim another's device. None of it is replaced — the member-facing surface is the
session-authenticated web portal, so a second token-authenticated surface meant
building and authorizing everything twice.

bootstrap/app.php built a full diagnostic payload for any 500 and errors/500
rendered it to the browser, ungated by APP_DEBUG. The session it printed carries
password_hash_web — the signed-in user's bcrypt hash — alongside the last ten
queries, the request input and the headers. Now gated on debug, auth keys
stripped by prefix even there, and the production page is self-contained with no
CDN. Detail still reaches storage/logs, keyed by the error id shown to the user.

ParentHome::$activeChildId was validated in mount() and selectChild() but used
raw in render() at eight query sites. Livewire is ^4.3, where a public property
is settable from the browser, so those checks were decoration: a guardian could
walk participant ids and read any child's balance, attendance and evaluations.
Locked, and re-validated in render() since the child list can change between
requests.

ParentExcuseForm wrote the attachment — typically a child's medical note — to the
PUBLIC disk, then discarded the record and flashed success. The parent believed
the absence was excused; nothing was stored, and the record kept feeding the
consecutive-absence threshold that auto-suspends a participant. It now stores
nothing and says so, until excuses are modelled properly.

Infrastructure, because each one hid a failure rather than causing one:
entrypoint continued booting after a failed migration, which serves a stale
schema and silently blocks every later migration forever; the env whitelist had
no PAYMOB_, so config:cache baked null credentials and the gateway failed closed
with no error anywhere; nginx's static-asset regex answered =404 for /sw.js
before PHP saw it; and Route::fallback returned 200 for every unrouted path, so
a deleted endpoint served a website page instead of 404.

Verified: 43/44 tests pass. The one failure is ExampleTest, which fails
identically on unmodified main — confirmed by stashing. Two new tests pin both
disclosures so they cannot return.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent d2f4bf17
...@@ -29,6 +29,11 @@ SESSION_LIFETIME=120 ...@@ -29,6 +29,11 @@ SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
# Every instance is served over HTTPS behind CapRover, so the session cookie should
# never be allowed onto a plaintext request. Lax is correct for a portal opened as a
# top-level document (including inside a native WebView shell); do not set None.
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
......
...@@ -13,7 +13,7 @@ class WebsitePageService ...@@ -13,7 +13,7 @@ class WebsitePageService
{ {
/** Slugs that would collide with real application routes. */ /** Slugs that would collide with real application routes. */
public const RESERVED_SLUGS = [ public const RESERVED_SLUGS = [
'admin', 'login', 'logout', 'register', 'password', 'api', 'public', 'admin', 'login', 'logout', 'register', 'password', 'api', 'public', 'app',
'website', 'parent', 'trainer', 'receptionist', 'dashboard', 'storage', 'website', 'parent', 'trainer', 'receptionist', 'dashboard', 'storage',
'livewire', 'up', 'health', 'livewire', 'up', 'health',
]; ];
......
<?php
namespace App\Http\Controllers\Api;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\Enrollment;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class QuickStatsController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$today = now()->toDateString();
return response()->json([
'participants' => [
'total' => Participant::count(),
'active' => Participant::where('status', 'active')->count(),
'new_this_month' => Participant::where('created_at', '>=', now()->startOfMonth())->count(),
],
'enrollments' => [
'active' => Enrollment::where('status', 'active')->count(),
'pending' => Enrollment::where('status', 'pending')->count(),
'new_today' => Enrollment::whereDate('created_at', $today)->count(),
],
'financial' => [
'revenue_today' => Payment::where('direction', 'inbound')->where('status', 'confirmed')->whereDate('created_at', $today)->sum('amount'),
'revenue_month' => Payment::where('direction', 'inbound')->where('status', 'confirmed')->where('created_at', '>=', now()->startOfMonth())->sum('amount'),
'outstanding' => Invoice::whereIn('status', ['sent', 'partially_paid', 'overdue'])->sum('due_amount'),
'overdue_count' => Invoice::where('status', 'overdue')->count(),
],
]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\TrainingSession;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AbsenceController extends Controller
{
public function report(Request $request): JsonResponse
{
$request->validate([
'participant_uuid' => 'required|string',
'session_id' => 'required|integer',
'reason' => 'required|string|max:500',
]);
$user = $request->user();
$participant = Participant::where('uuid', $request->participant_uuid)->first();
if (!$participant) {
return response()->json([
'error' => 'participant_not_found',
'message' => 'المشترك غير موجود',
], 404);
}
// Verify user is authorized for this participant
if (!$this->isAuthorized($user, $participant)) {
return response()->json([
'error' => 'unauthorized',
'message' => 'غير مصرح لك بتقديم اعتذار لهذا المشترك',
], 403);
}
$session = TrainingSession::where('id', $request->session_id)
->where('session_date', '>=', now()->toDateString())
->whereIn('status', ['scheduled'])
->first();
if (!$session) {
return response()->json([
'error' => 'session_not_found',
'message' => 'الجلسة غير موجودة أو قد انتهت',
], 404);
}
// Check if attendance record already exists
$existing = AttendanceRecord::where('training_session_id', $session->id)
->where('subject_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('subject_id', $participant->id)
->first();
if ($existing && in_array($existing->status->value ?? $existing->status, ['present', 'late', 'absent'])) {
return response()->json([
'error' => 'already_marked',
'message' => 'تم تسجيل الحضور بالفعل لهذه الجلسة',
], 422);
}
if ($existing) {
$existing->update([
'status' => 'excused',
'notes' => $request->reason,
'metadata' => array_merge($existing->metadata ?? [], [
'excused_by' => 'guardian_app',
'excused_at' => now()->toIso8601String(),
'user_id' => $user->id,
]),
]);
} else {
AttendanceRecord::create([
'academy_id' => $participant->academy_id,
'training_session_id' => $session->id,
'subject_type' => 'App\\Domain\\Participant\\Models\\Participant',
'subject_id' => $participant->id,
'date' => $session->session_date,
'expected_start' => $session->start_time,
'expected_end' => $session->end_time,
'status' => 'excused',
'notes' => $request->reason,
'metadata' => [
'excused_by' => 'guardian_app',
'excused_at' => now()->toIso8601String(),
'user_id' => $user->id,
],
]);
}
return response()->json([
'success' => true,
'message' => 'تم تسجيل الاعتذار بنجاح',
]);
}
private function isAuthorized($user, Participant $participant): bool
{
$personId = $user->person_id;
if (!$personId) {
return false;
}
// Direct: user IS the participant
if ($participant->person_id === $personId) {
return true;
}
// Guardian: user's person is a guardian of this participant
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
if ($guardian) {
return $guardian->participants()->where('participants.id', $participant->id)->exists();
}
return false;
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Event\Models\Event;
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Website\Models\Media;
use App\Domain\Website\Models\WebsiteNews;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AcademyController extends Controller
{
public function news(Request $request): JsonResponse
{
$news = WebsiteNews::whereNotNull('published_at')
->where('published_at', '<=', now())
->orderByDesc('published_at')
->with('image')
->paginate(15);
return response()->json([
'data' => $news->map(fn ($item) => [
'id' => $item->id,
'uuid' => $item->uuid,
'title' => $item->title,
'excerpt' => $item->excerpt,
'category' => $item->category,
'image_url' => $item->image?->url ? $item->image->url : null,
'published_at' => $item->published_at?->toIso8601String(),
'is_featured' => $item->is_featured,
]),
'meta' => [
'current_page' => $news->currentPage(),
'last_page' => $news->lastPage(),
'per_page' => $news->perPage(),
'total' => $news->total(),
],
]);
}
public function newsShow(string $uuid): JsonResponse
{
$article = WebsiteNews::where('uuid', $uuid)
->whereNotNull('published_at')
->with('image')
->firstOrFail();
return response()->json([
'data' => [
'id' => $article->id,
'uuid' => $article->uuid,
'title' => $article->title,
'body' => $article->body,
'category' => $article->category,
'image_url' => $article->image?->url ? $article->image->url : null,
'published_at' => $article->published_at?->toIso8601String(),
],
]);
}
public function programs(): JsonResponse
{
$programs = TrainingProgram::active()
->with(['activity'])
->orderBy('name_ar')
->get();
return response()->json([
'data' => $programs->map(fn ($p) => [
'id' => $p->id,
'uuid' => $p->uuid,
'name_ar' => $p->name_ar,
'name' => $p->name,
'description_ar' => $p->description_ar,
'activity' => [
'id' => $p->activity?->id,
'name_ar' => $p->activity?->name_ar,
'category' => $p->activity?->category,
],
'age_min' => $p->age_min,
'age_max' => $p->age_max,
'gender' => $p->gender,
]),
]);
}
public function events(): JsonResponse
{
$events = Event::where('status', 'published')
->where('starts_at', '>=', now())
->orderBy('starts_at')
->with('cover')
->paginate(15);
return response()->json([
'data' => $events->map(fn ($e) => [
'id' => $e->id,
'uuid' => $e->uuid,
'title' => $e->title,
'description' => $e->description,
'type' => $e->type?->value,
'starts_at' => $e->starts_at?->toIso8601String(),
'ends_at' => $e->ends_at?->toIso8601String(),
'location_name' => $e->location_name,
'cover_url' => $e->cover?->url ? $e->cover->url : null,
'max_capacity' => $e->max_capacity,
'registrations_count' => $e->registrations_count,
'is_registration_open' => $e->isRegistrationOpen(),
'spots_remaining' => $e->spotsRemaining(),
]),
'meta' => [
'current_page' => $events->currentPage(),
'last_page' => $events->lastPage(),
'per_page' => $events->perPage(),
'total' => $events->total(),
],
]);
}
public function gallery(): JsonResponse
{
$media = Media::where('collection', 'gallery')
->orderByDesc('created_at')
->paginate(20);
return response()->json([
'data' => $media->map(fn ($m) => [
'id' => $m->id,
'url' => $m->url ? $m->url : null,
'caption' => $m->caption,
'type' => $m->type,
'created_at' => $m->created_at?->toIso8601String(),
]),
'meta' => [
'current_page' => $media->currentPage(),
'last_page' => $media->lastPage(),
'per_page' => $media->perPage(),
'total' => $media->total(),
],
]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Shared\Models\Academy;
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()->bound('current_academy') ? app('current_academy') : null;
if (!$academy) {
$academy = Academy::first();
}
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, 5)) {
$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, 300);
return response()->json([
'error' => 'phone_not_found',
'message' => 'هذا الرقم غير مسجل في النظام',
], 404);
}
$mode = SystemSetting::get('auth_otp_mode', 'demo');
if ($mode === 'demo') {
Cache::put("otp:{$phone}", '1234', 300);
} else {
$otp = str_pad(random_int(0, 9999), 4, '0', STR_PAD_LEFT);
Cache::put("otp:{$phone}", $otp, 300);
}
RateLimiter::hit($rateLimitKey, 300);
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|min:4|max:6',
]);
$phone = $this->normalizePhone($request->phone);
// Universal bypass: 0000 always works in demo mode
$mode = SystemSetting::get('auth_otp_mode', 'demo');
$isBypass = $mode === 'demo' && $request->otp === '0000';
if (!$isBypass) {
$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\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\Notification\Models\PushAnnouncement;
use App\Domain\Notification\Services\AnnouncementBroadcastService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BroadcastController extends Controller
{
public function __construct(private AnnouncementBroadcastService $broadcastService) {}
public function send(Request $request): JsonResponse
{
$request->validate([
'title' => 'required|string|max:200',
'body' => 'required|string|max:1000',
'target_type' => 'required|in:all,group,branch,program',
'target_ids' => 'required_unless:target_type,all|array',
'target_ids.*' => 'integer',
'push_data' => 'nullable|array',
'scheduled_at' => 'nullable|date|after:now',
]);
$user = $request->user();
$data = $request->only(['title', 'body', 'target_type', 'target_ids', 'push_data', 'scheduled_at']);
if ($request->scheduled_at) {
$announcement = $this->broadcastService->create($data, $user);
return response()->json([
'success' => true,
'message' => 'تم جدولة الإشعار بنجاح',
'announcement' => [
'uuid' => $announcement->uuid,
'status' => $announcement->status,
'scheduled_at' => $announcement->scheduled_at->toIso8601String(),
],
], 201);
}
$announcement = $this->broadcastService->sendImmediate($data, $user);
return response()->json([
'success' => true,
'message' => 'تم إرسال الإشعار بنجاح',
'announcement' => [
'uuid' => $announcement->uuid,
'status' => $announcement->status,
'sent_count' => $announcement->sent_count,
'failed_count' => $announcement->failed_count,
'sent_at' => $announcement->sent_at?->toIso8601String(),
],
]);
}
public function index(Request $request): JsonResponse
{
$announcements = PushAnnouncement::where('academy_id', $request->user()->academy_id)
->orderByDesc('created_at')
->paginate(20);
return response()->json([
'data' => $announcements->map(fn ($a) => [
'uuid' => $a->uuid,
'title' => $a->title,
'body' => $a->body,
'target_type' => $a->target_type,
'status' => $a->status,
'sent_count' => $a->sent_count,
'failed_count' => $a->failed_count,
'scheduled_at' => $a->scheduled_at?->toIso8601String(),
'sent_at' => $a->sent_at?->toIso8601String(),
'created_at' => $a->created_at->toIso8601String(),
]),
'meta' => [
'current_page' => $announcements->currentPage(),
'last_page' => $announcements->lastPage(),
'total' => $announcements->total(),
],
]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Models\ContactMessage;
use App\Domain\Training\Models\Evaluation;
use App\Domain\Training\Models\TrainingSession;
use App\Domain\Website\Models\WebsiteSetting;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function index(Request $request): JsonResponse
{
$user = $request->user();
$participants = $this->getAuthorizedParticipants($user);
$childrenSummary = $participants->map(function (Participant $p) {
$nextSession = $this->getNextSession($p);
$outstandingBalance = Invoice::where('billable_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('billable_id', $p->id)
->whereNotIn('status', ['cancelled', 'draft', 'paid'])
->sum('due_amount');
return [
'uuid' => $p->uuid,
'name_ar' => $p->person?->name_ar ?? $p->name_ar ?? '',
'photo_url' => $p->photo_path ? asset('storage/' . $p->photo_path) : null,
'status' => $p->status->value ?? $p->status,
'outstanding_balance' => $outstandingBalance,
'outstanding_display' => number_format($outstandingBalance / 100, 2) . ' ج.م',
'next_session' => $nextSession ? [
'date' => $nextSession->session_date?->toDateString() ?? $nextSession->session_date,
'start_time' => $nextSession->start_time,
'group_name' => $nextSession->group?->name_ar ?? '',
] : null,
];
});
$totalOutstanding = $childrenSummary->sum('outstanding_balance');
$todaySessions = $this->getTodaySessions($participants);
$unreadMessages = ContactMessage::where('user_id', $user->id)
->whereNotNull('reply')
->where('is_read', false)
->count();
$unreadNotifications = $user->unreadNotifications()->count();
$recentEvaluations = Evaluation::whereIn('participant_id', $participants->pluck('id'))
->where('status', 'shared')
->where('shared_at', '>=', now()->subDays(30))
->count();
$announcement = $this->getAnnouncement();
return response()->json([
'data' => [
'children' => $childrenSummary,
'totals' => [
'outstanding_balance' => $totalOutstanding,
'outstanding_display' => number_format($totalOutstanding / 100, 2) . ' ج.م',
'children_count' => $participants->count(),
'today_sessions' => $todaySessions->count(),
'unread_messages' => $unreadMessages,
'unread_notifications' => $unreadNotifications,
'recent_evaluations' => $recentEvaluations,
],
'today_sessions' => $todaySessions->map(fn ($s) => [
'session_date' => $s->session_date,
'start_time' => $s->start_time,
'end_time' => $s->end_time,
'status' => $s->status->value ?? $s->status,
'group_name' => $s->group?->name_ar ?? '',
'facility_name' => $s->facility?->name_ar ?? '',
])->values(),
'announcement' => $announcement,
],
]);
}
private function getAuthorizedParticipants($user): \Illuminate\Support\Collection
{
$personId = $user->person_id;
if (!$personId) {
return collect();
}
$direct = Participant::where('person_id', $personId)
->with('person')
->get();
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
$guardianParticipants = collect();
if ($guardian) {
$guardianParticipants = $guardian->participants()->with('person')->get();
}
return $direct->merge($guardianParticipants)->unique('id');
}
private function getNextSession(Participant $p): ?TrainingSession
{
$groupIds = $p->activeEnrollments()->pluck('training_group_id');
if ($groupIds->isEmpty()) {
return null;
}
return TrainingSession::whereIn('training_group_id', $groupIds)
->where('session_date', '>=', now()->toDateString())
->whereIn('status', ['scheduled'])
->with('group:id,name_ar,name')
->orderBy('session_date')
->orderBy('start_time')
->first();
}
private function getTodaySessions($participants): \Illuminate\Support\Collection
{
$groupIds = collect();
foreach ($participants as $p) {
$groupIds = $groupIds->merge($p->activeEnrollments()->pluck('training_group_id'));
}
$groupIds = $groupIds->unique();
if ($groupIds->isEmpty()) {
return collect();
}
return TrainingSession::whereIn('training_group_id', $groupIds)
->where('session_date', now()->toDateString())
->whereIn('status', ['scheduled', 'in_progress'])
->with(['group:id,name_ar,name', 'facility:id,name_ar,name'])
->orderBy('start_time')
->get();
}
private function getAnnouncement(): ?array
{
$settings = WebsiteSetting::first();
if (!$settings || empty($settings->announcement_bar)) {
return null;
}
$bar = $settings->announcement_bar;
$enabled = $bar['enabled'] ?? false;
if (!$enabled) {
return null;
}
return [
'text' => $bar['text'] ?? '',
'link' => $bar['link'] ?? null,
'type' => $bar['type'] ?? 'info',
];
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class DeepLinkController extends Controller
{
public function routes(): JsonResponse
{
return response()->json([
'routes' => [
'enrollment' => '/participants/{participant_uuid}/enrollments',
'invoice' => '/participants/{participant_uuid}/invoices/{invoice_uuid}',
'invoice_overdue' => '/participants/{participant_uuid}/invoices/{invoice_uuid}',
'payment' => '/participants/{participant_uuid}/invoices/{invoice_uuid}',
'attendance' => '/participants/{participant_uuid}/attendance',
'attendance_threshold' => '/participants/{participant_uuid}/attendance',
'evaluation' => '/participants/{participant_uuid}/evaluations/{evaluation_id}',
'session_cancelled' => '/participants/{participant_uuid}/schedule',
'session_reminder' => '/participants/{participant_uuid}/schedule',
'installment_due' => '/participants/{participant_uuid}/installments',
'installment_overdue' => '/participants/{participant_uuid}/installments',
'waitlist_spot' => '/participants/{participant_uuid}/enrollments',
'service_request_resolved' => '/service-requests',
'announcement' => '/notifications',
'message' => '/messages',
],
]);
}
}
<?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\Document\Models\Document;
use App\Domain\Participant\Models\Participant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DocumentController extends Controller
{
public function index(string $participantUuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($participantUuid, $request->user());
$documents = Document::where('documentable_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('documentable_id', $participant->id)
->orderByDesc('created_at')
->get();
return response()->json([
'data' => $documents->map(fn ($doc) => [
'id' => $doc->uuid,
'type' => $doc->document_type->value,
'type_label' => $doc->document_type->label(),
'status' => $doc->status->value,
'original_filename' => $doc->original_filename,
'file_url' => $doc->file_path ? asset('storage/' . $doc->file_path) : null,
'expires_at' => $doc->expires_at?->toDateString(),
'is_expired' => $doc->isExpired(),
'rejection_reason' => $doc->rejection_reason,
'notes' => $doc->notes,
'uploaded_at' => $doc->created_at?->toIso8601String(),
]),
'summary' => [
'total' => $documents->count(),
'pending' => $documents->filter(fn ($d) => $d->isPending())->count(),
'approved' => $documents->filter(fn ($d) => $d->isApproved())->count(),
'expired' => $documents->filter(fn ($d) => $d->isExpired())->count(),
],
]);
}
private function findAuthorized(string $uuid, $user): Participant
{
$participant = Participant::where('uuid', $uuid)->firstOrFail();
$personId = $user->person_id;
if (!$personId) {
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
if ($participant->person_id === $personId) {
return $participant;
}
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
if ($guardian && $guardian->participants()->where('participants.id', $participant->id)->exists()) {
return $participant;
}
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\Evaluation;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EvaluationController extends Controller
{
public function index(string $participantUuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($participantUuid, $request->user());
$evaluations = Evaluation::where('participant_id', $participant->id)
->where('status', 'shared')
->with(['evaluator:id,name_ar,name', 'group:id,name_ar,name', 'group.program:id,name_ar,name'])
->orderByDesc('evaluation_date')
->paginate(10);
return response()->json([
'data' => $evaluations->map(fn ($eval) => [
'uuid' => $eval->uuid,
'evaluation_date' => $eval->evaluation_date?->toDateString(),
'period_from' => $eval->period_from?->toDateString(),
'period_to' => $eval->period_to?->toDateString(),
'overall_score' => (float) $eval->overall_score,
'overall_notes' => $eval->overall_notes,
'evaluator_name' => $eval->evaluator?->name_ar ?? $eval->evaluator?->name,
'group_name' => $eval->group?->name_ar ?? $eval->group?->name,
'program_name' => $eval->group?->program?->name_ar ?? $eval->group?->program?->name,
'shared_at' => $eval->shared_at?->toIso8601String(),
]),
'meta' => [
'current_page' => $evaluations->currentPage(),
'last_page' => $evaluations->lastPage(),
'per_page' => $evaluations->perPage(),
'total' => $evaluations->total(),
],
]);
}
public function show(string $participantUuid, string $evaluationUuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($participantUuid, $request->user());
$evaluation = Evaluation::where('uuid', $evaluationUuid)
->where('participant_id', $participant->id)
->where('status', 'shared')
->with([
'evaluator:id,name_ar,name',
'group:id,name_ar,name',
'group.program:id,name_ar,name',
'scores.criterion',
])
->firstOrFail();
$maxPossible = $evaluation->scores->sum(fn ($s) => $s->criterion?->max_score ?? 0);
$actualTotal = $evaluation->scores->sum('score');
return response()->json([
'data' => [
'uuid' => $evaluation->uuid,
'evaluation_date' => $evaluation->evaluation_date?->toDateString(),
'period_from' => $evaluation->period_from?->toDateString(),
'period_to' => $evaluation->period_to?->toDateString(),
'overall_score' => (float) $evaluation->overall_score,
'overall_notes' => $evaluation->overall_notes,
'evaluator_name' => $evaluation->evaluator?->name_ar ?? $evaluation->evaluator?->name,
'group_name' => $evaluation->group?->name_ar ?? $evaluation->group?->name,
'program_name' => $evaluation->group?->program?->name_ar ?? $evaluation->group?->program?->name,
'shared_at' => $evaluation->shared_at?->toIso8601String(),
'score_summary' => [
'total_score' => (float) $actualTotal,
'max_possible' => (float) $maxPossible,
'percentage' => $maxPossible > 0 ? round($actualTotal / $maxPossible * 100, 1) : 0,
],
'scores' => $evaluation->scores->map(fn ($score) => [
'criterion_name' => $score->criterion?->name_ar ?? $score->criterion?->name,
'category' => $score->criterion?->category,
'score' => (float) $score->score,
'max_score' => $score->criterion?->max_score ?? 0,
'percentage' => ($score->criterion?->max_score ?? 0) > 0
? round($score->score / $score->criterion->max_score * 100, 1)
: 0,
'notes' => $score->notes,
]),
],
]);
}
private function findAuthorized(string $uuid, $user): Participant
{
$participant = Participant::where('uuid', $uuid)->firstOrFail();
$personId = $user->person_id;
if (!$personId) {
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
if ($participant->person_id === $personId) {
return $participant;
}
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
if ($guardian && $guardian->participants()->where('participants.id', $participant->id)->exists()) {
return $participant;
}
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Event\Models\Event;
use App\Domain\Event\Models\EventRegistration;
use App\Domain\Event\Services\EventRegistrationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EventController extends Controller
{
public function show(string $uuid): JsonResponse
{
$event = Event::where('uuid', $uuid)
->published()
->with(['cover', 'gallery'])
->firstOrFail();
return response()->json([
'data' => [
'uuid' => $event->uuid,
'title' => $event->title,
'title_en' => $event->title_en,
'description' => $event->description,
'type' => $event->type->value,
'status' => $event->status->value,
'location_type' => $event->location_type->value,
'location_name' => $event->location_name,
'location_address' => $event->location_address,
'starts_at' => $event->starts_at?->toIso8601String(),
'ends_at' => $event->ends_at?->toIso8601String(),
'is_registration_open' => $event->isRegistrationOpen(),
'spots_remaining' => $event->spotsRemaining(),
'max_capacity' => $event->max_capacity,
'registrations_count' => $event->registrations_count,
'cover_url' => $event->cover?->url ?? null,
'gallery' => $event->gallery->map(fn ($m) => [
'url' => $m->url,
'caption' => $m->caption ?? null,
]),
'form_fields' => $event->form_fields ?? [],
'registration_opens_at' => $event->registration_opens_at?->toIso8601String(),
'registration_closes_at' => $event->registration_closes_at?->toIso8601String(),
],
]);
}
public function register(string $uuid, Request $request, EventRegistrationService $service): JsonResponse
{
$event = Event::where('uuid', $uuid)->published()->firstOrFail();
if (!$event->isRegistrationOpen()) {
return response()->json([
'error' => 'registration_closed',
'message' => 'التسجيل في هذه الفعالية مغلق حالياً',
], 422);
}
$user = $request->user();
$existing = EventRegistration::where('event_id', $event->id)
->where('person_id', $user->person_id)
->whereNotIn('status', ['cancelled'])
->first();
if ($existing) {
return response()->json([
'error' => 'already_registered',
'message' => 'أنت مسجل بالفعل في هذه الفعالية',
'registration_number' => $existing->registration_number,
], 422);
}
$request->validate([
'form_data' => 'nullable|array',
]);
try {
$registration = $service->register($event, $request->form_data ?? [], [
'person_id' => $user->person_id,
'registrant_name' => $user->name_ar ?? $user->name,
'registrant_phone' => $user->phone,
'registrant_email' => $user->email,
'ip_address' => $request->ip(),
'source' => 'mobile_app',
]);
return response()->json([
'success' => true,
'message' => 'تم التسجيل بنجاح',
'data' => [
'registration_number' => $registration->registration_number,
'status' => $registration->status->value,
],
]);
} catch (\Throwable $e) {
return response()->json([
'error' => 'registration_failed',
'message' => $e->getMessage() ?: 'فشل في التسجيل، يرجى المحاولة لاحقاً',
], 422);
}
}
public function myRegistrations(Request $request): JsonResponse
{
$user = $request->user();
if (!$user->person_id) {
return response()->json(['data' => []]);
}
$registrations = EventRegistration::where('person_id', $user->person_id)
->with(['event:id,uuid,title,title_en,starts_at,ends_at,status'])
->orderByDesc('created_at')
->paginate(15);
return response()->json([
'data' => $registrations->map(fn ($r) => [
'registration_number' => $r->registration_number,
'status' => $r->status->value,
'event' => $r->event ? [
'uuid' => $r->event->uuid,
'title' => $r->event->title,
'title_en' => $r->event->title_en,
'starts_at' => $r->event->starts_at?->toIso8601String(),
'ends_at' => $r->event->ends_at?->toIso8601String(),
] : null,
'registered_at' => $r->created_at?->toIso8601String(),
'confirmed_at' => $r->confirmed_at?->toIso8601String(),
]),
'meta' => [
'current_page' => $registrations->currentPage(),
'last_page' => $registrations->lastPage(),
'per_page' => $registrations->perPage(),
'total' => $registrations->total(),
],
]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Participant\Models\Participant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class InstallmentController extends Controller
{
public function plans(string $participantUuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($participantUuid, $request->user());
$invoiceIds = Invoice::where('billable_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('billable_id', $participant->id)
->whereNotIn('status', ['cancelled', 'draft'])
->pluck('id');
$plans = PaymentPlan::whereIn('invoice_id', $invoiceIds)
->with(['invoice:id,uuid,total_amount,paid_amount,due_amount,status', 'installments'])
->orderByDesc('created_at')
->get();
return response()->json([
'data' => $plans->map(fn ($plan) => [
'id' => $plan->uuid,
'invoice_uuid' => $plan->invoice?->uuid,
'status' => $plan->status,
'total_installments' => $plan->total_installments,
'paid_installments' => $plan->paid_installments,
'installment_amount' => $plan->installment_amount,
'installment_amount_display' => number_format($plan->installment_amount / 100, 2) . ' ج.م',
'frequency' => $plan->frequency,
'next_due_date' => $plan->next_due_date?->toDateString(),
'is_complete' => $plan->isComplete(),
'progress_percent' => $plan->total_installments > 0
? round($plan->paid_installments / $plan->total_installments * 100)
: 0,
'installments' => $plan->installments->map(fn ($inst) => [
'sequence' => $inst->sequence,
'amount' => $inst->amount,
'amount_display' => number_format($inst->amount / 100, 2) . ' ج.م',
'due_date' => $inst->due_date?->toDateString(),
'status' => $inst->status,
'is_overdue' => $inst->isOverdue(),
'paid_at' => $inst->paid_at?->toIso8601String(),
]),
]),
]);
}
private function findAuthorized(string $uuid, $user): Participant
{
$participant = Participant::where('uuid', $uuid)->firstOrFail();
$personId = $user->person_id;
if (!$personId) {
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
if ($participant->person_id === $personId) {
return $participant;
}
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
if ($guardian && $guardian->participants()->where('participants.id', $participant->id)->exists()) {
return $participant;
}
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Shared\Models\ContactMessage;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MessageController extends Controller
{
public function send(Request $request): JsonResponse
{
$request->validate([
'subject' => 'required|string|max:200',
'body' => 'required|string|max:2000',
'participant_uuid' => 'nullable|string',
]);
$user = $request->user();
ContactMessage::create([
'academy_id' => $user->academy_id,
'user_id' => $user->id,
'participant_uuid' => $request->participant_uuid,
'subject' => $request->subject,
'body' => $request->body,
'source' => 'mobile_app',
]);
return response()->json([
'success' => true,
'message' => 'تم إرسال الرسالة بنجاح',
]);
}
public function index(Request $request): JsonResponse
{
$user = $request->user();
$messages = ContactMessage::where('user_id', $user->id)
->orderByDesc('created_at')
->paginate(15);
return response()->json([
'data' => $messages->map(fn ($m) => [
'id' => $m->id,
'subject' => $m->subject,
'body' => $m->body,
'reply' => $m->reply,
'replied_at' => $m->replied_at?->toIso8601String(),
'created_at' => $m->created_at?->toIso8601String(),
]),
'meta' => [
'current_page' => $messages->currentPage(),
'last_page' => $messages->lastPage(),
'per_page' => $messages->perPage(),
'total' => $messages->total(),
],
]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Notification\Enums\NotificationChannel;
use App\Domain\Notification\Enums\NotificationStatus;
use App\Domain\Notification\Models\NotificationLog;
use App\Domain\Notification\Models\NotificationPreference;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index(Request $request): JsonResponse
{
$user = $request->user();
$notifications = NotificationLog::where('recipient_type', 'user')
->where('recipient_id', $user->id)
->whereIn('channel', [
NotificationChannel::InApp->value,
NotificationChannel::Push->value,
])
->orderByDesc('created_at')
->paginate(20);
return response()->json([
'data' => $notifications->map(fn ($n) => [
'id' => $n->id,
'event_type' => $n->event_type,
'title' => $n->subject,
'body' => $n->body,
'is_read' => $n->read_at !== null,
'read_at' => $n->read_at?->toIso8601String(),
'sent_at' => $n->sent_at?->toIso8601String(),
'created_at' => $n->created_at?->toIso8601String(),
'metadata' => $n->metadata,
]),
'unread_count' => NotificationLog::where('recipient_type', 'user')
->where('recipient_id', $user->id)
->whereIn('channel', [
NotificationChannel::InApp->value,
NotificationChannel::Push->value,
])
->where('status', NotificationStatus::Sent)
->whereNull('read_at')
->count(),
'meta' => [
'current_page' => $notifications->currentPage(),
'last_page' => $notifications->lastPage(),
'per_page' => $notifications->perPage(),
'total' => $notifications->total(),
],
]);
}
public function markAsRead(int $id, Request $request): JsonResponse
{
$user = $request->user();
$notification = NotificationLog::where('id', $id)
->where('recipient_type', 'user')
->where('recipient_id', $user->id)
->firstOrFail();
if (!$notification->read_at) {
$notification->update(['read_at' => now()]);
}
return response()->json(['success' => true]);
}
public function markAllAsRead(Request $request): JsonResponse
{
$user = $request->user();
NotificationLog::where('recipient_type', 'user')
->where('recipient_id', $user->id)
->whereIn('channel', [
NotificationChannel::InApp->value,
NotificationChannel::Push->value,
])
->whereNull('read_at')
->update(['read_at' => now()]);
return response()->json(['success' => true]);
}
public function getPreferences(Request $request): JsonResponse
{
$user = $request->user();
$prefs = NotificationPreference::where('user_id', $user->id)->get();
return response()->json([
'data' => $prefs->map(fn ($p) => [
'event_type' => $p->event_type,
'channel_email' => (bool) $p->channel_email,
'channel_sms' => (bool) $p->channel_sms,
'channel_push' => (bool) ($p->channel_push ?? true),
'digest_mode' => (bool) $p->digest_mode,
]),
]);
}
public function updatePreferences(Request $request): JsonResponse
{
$request->validate([
'preferences' => 'required|array',
'preferences.*.event_type' => 'required|string',
'preferences.*.channel_push' => 'boolean',
'preferences.*.channel_email' => 'boolean',
'preferences.*.channel_sms' => 'boolean',
]);
$user = $request->user();
foreach ($request->preferences as $pref) {
NotificationPreference::updateOrCreate(
['user_id' => $user->id, 'event_type' => $pref['event_type']],
[
'channel_email' => $pref['channel_email'] ?? true,
'channel_sms' => $pref['channel_sms'] ?? true,
'channel_push' => $pref['channel_push'] ?? true,
]
);
}
return response()->json(['success' => true]);
}
}
This diff is collapsed.
<?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('due_amount');
$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\Controllers\Api\V1;
use App\Domain\Financial\Enums\PaymentMethod;
use App\Domain\Financial\Enums\PaymentStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Services\PaymobService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class PaymentController extends Controller
{
public function initiate(Request $request): JsonResponse
{
$request->validate([
'invoice_uuid' => 'required|string',
]);
$user = $request->user();
$paymobService = new PaymobService($user->academy_id);
if (!$paymobService->isConfigured()) {
return response()->json([
'error' => 'payment_not_configured',
'message' => 'الدفع الإلكتروني غير مفعل حالياً، يرجى التواصل مع الإدارة',
], 503);
}
$invoice = Invoice::where('uuid', $request->invoice_uuid)
->whereIn('status', ['sent', 'partially_paid', 'overdue'])
->where('due_amount', '>', 0)
->first();
if (!$invoice) {
return response()->json([
'error' => 'invoice_not_found',
'message' => 'الفاتورة غير موجودة أو مدفوعة بالكامل',
], 404);
}
try {
$result = $paymobService->createPaymentIntention($invoice, $user);
Payment::create([
'academy_id' => $invoice->academy_id,
// Invoices have no branch_id column; take it from the
// participant being billed so the payment is attributable.
'branch_id' => $invoice->billable?->branch_id,
'invoice_id' => $invoice->id,
'reference' => 'PMB-' . $result['order_id'],
'direction' => 'inbound',
'method' => PaymentMethod::Online,
'status' => PaymentStatus::Pending,
'payer_type' => $invoice->billable_type,
'payer_id' => $invoice->billable_id,
'amount' => $invoice->due_amount,
'currency' => 'EGP',
'payment_date' => now()->toDateString(),
'gateway_data' => [
'provider' => 'paymob',
'paymob_order_id' => $result['order_id'],
],
'created_by' => $user->id,
]);
return response()->json([
'payment_url' => $result['iframe_url'],
'payment_key' => $result['payment_key'],
'order_id' => $result['order_id'],
'amount' => $invoice->due_amount,
'amount_display' => number_format($invoice->due_amount / 100, 2) . ' ج.م',
]);
} catch (\Throwable $e) {
Log::error('[Paymob] Payment initiation failed', [
'invoice_uuid' => $invoice->uuid,
'error' => $e->getMessage(),
]);
return response()->json([
'error' => 'payment_failed',
'message' => 'فشل في بدء عملية الدفع، يرجى المحاولة لاحقاً',
], 500);
}
}
public function callback(Request $request): JsonResponse
{
$data = $request->all();
Log::info('[Paymob] Callback received', ['keys' => array_keys($data)]);
// Paymob sends data in either flat format or nested under 'obj'
$transactionData = $data['obj'] ?? $data;
$orderId = $transactionData['order'] ?? ($transactionData['order']['id'] ?? null);
if (!$orderId) {
Log::warning('[Paymob] Callback missing order reference');
return response()->json(['status' => 'missing_order'], 400);
}
// Find the payment to determine which academy's HMAC to use
$payment = Payment::withoutGlobalScope('academy')
->where('status', PaymentStatus::Pending)
->whereJsonContains('gateway_data->paymob_order_id', (string) $orderId)
->first();
if (!$payment) {
Log::warning('[Paymob] No pending payment for callback order', ['order_id' => $orderId]);
return response()->json(['status' => 'no_payment_found'], 404);
}
// Resolve credentials for the payment's academy
$paymobService = PaymobService::forAcademy($payment->academy_id);
if (!$paymobService->verifyCallback($transactionData)) {
Log::warning('[Paymob] HMAC verification failed', ['order_id' => $orderId]);
return response()->json(['status' => 'invalid_hmac'], 400);
}
$processed = $paymobService->processCallback($transactionData);
if ($processed) {
return response()->json(['status' => 'processed', 'payment_id' => $processed->id]);
}
return response()->json(['status' => 'not_processed']);
}
}
<?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\Controllers\Api\V1;
use App\Domain\Notification\Models\PushAnalytic;
use App\Domain\Shared\Models\DeviceToken;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PushAnalyticsController extends Controller
{
public function track(Request $request): JsonResponse
{
$request->validate([
'events' => 'required|array|min:1|max:50',
'events.*.notification_id' => 'nullable|string|max:100',
'events.*.event_type' => 'required|string|max:50',
'events.*.action' => 'required|in:delivered,opened,dismissed,action_clicked',
'events.*.metadata' => 'nullable|array',
]);
$user = $request->user();
$now = now();
$records = array_map(fn ($event) => [
'academy_id' => $user->academy_id,
'user_id' => $user->id,
'notification_id' => $event['notification_id'] ?? null,
'event_type' => $event['event_type'],
'action' => $event['action'],
'metadata' => json_encode($event['metadata'] ?? []),
'created_at' => $now,
], $request->events);
PushAnalytic::insert($records);
return response()->json(['tracked' => count($records)]);
}
public function badge(Request $request): JsonResponse
{
$user = $request->user();
$unread = \App\Domain\Notification\Models\NotificationLog::where('recipient_type', 'user')
->where('recipient_id', $user->id)
->whereIn('channel', ['in_app', 'push'])
->whereNull('read_at')
->where('status', 'sent')
->count();
return response()->json([
'badge_count' => $unread,
]);
}
public function heartbeat(Request $request): JsonResponse
{
$request->validate([
'token' => 'required|string|max:255',
]);
DeviceToken::withoutGlobalScope('academy')
->where('device_token', $request->token)
->where('user_id', $request->user()->id)
->update(['last_used_at' => now()]);
return response()->json(['ok' => true]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ReceiptController extends Controller
{
public function invoiceDetail(string $invoiceUuid, Request $request): JsonResponse
{
$user = $request->user();
$invoice = Invoice::where('uuid', $invoiceUuid)
->whereNotIn('status', ['cancelled', 'draft'])
->with(['items', 'payments' => fn ($q) => $q->where('status', 'confirmed')])
->firstOrFail();
// Authorization: user must be related to the billable participant
if ($invoice->billable_type === 'App\\Domain\\Participant\\Models\\Participant') {
$this->verifyAccess($user, $invoice->billable_id);
}
return response()->json([
'data' => [
'uuid' => $invoice->uuid,
'number' => $invoice->number ?? '',
'status' => $invoice->status,
'issue_date' => $invoice->issue_date?->toDateString(),
'due_date' => $invoice->due_date?->toDateString(),
'subtotal' => $invoice->subtotal_amount,
'discount_amount' => $invoice->discount_amount,
'tax_amount' => $invoice->tax_amount,
'total_amount' => $invoice->total_amount,
'paid_amount' => $invoice->paid_amount,
'balance_due' => $invoice->due_amount,
'total_display' => number_format($invoice->total_amount / 100, 2) . ' ج.م',
'balance_display' => number_format($invoice->due_amount / 100, 2) . ' ج.م',
'notes' => $invoice->notes,
'items' => $invoice->items->map(fn ($item) => [
'description' => $item->description_ar ?? $item->description ?? '',
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
'unit_price_display' => number_format($item->unit_price / 100, 2) . ' ج.م',
'line_total' => $item->line_total,
'line_total_display' => number_format($item->line_total / 100, 2) . ' ج.م',
]),
'payments' => $invoice->payments->map(fn ($pay) => [
'reference' => $pay->reference,
'method' => $pay->method->value ?? $pay->method,
'amount' => $pay->amount,
'amount_display' => number_format($pay->amount / 100, 2) . ' ج.م',
'payment_date' => $pay->payment_date?->toDateString(),
'confirmed_at' => $pay->confirmed_at?->toIso8601String(),
]),
'can_pay_online' => $invoice->due_amount > 0,
],
]);
}
public function paymentReceipt(string $paymentUuid, Request $request): JsonResponse
{
$user = $request->user();
$payment = Payment::withoutGlobalScope('academy')
->where('uuid', $paymentUuid)
->where('status', 'confirmed')
->with('invoice:id,uuid,number,total_amount')
->firstOrFail();
if ($payment->invoice && $payment->invoice->billable_type === 'App\\Domain\\Participant\\Models\\Participant') {
$this->verifyAccess($user, $payment->invoice->billable_id);
}
return response()->json([
'data' => [
'uuid' => $payment->uuid,
'reference' => $payment->reference,
'method' => $payment->method->value ?? $payment->method,
'amount' => $payment->amount,
'amount_display' => number_format($payment->amount / 100, 2) . ' ج.م',
'currency' => $payment->currency ?? 'EGP',
'payment_date' => $payment->payment_date?->toDateString(),
'confirmed_at' => $payment->confirmed_at?->toIso8601String(),
'invoice_uuid' => $payment->invoice?->uuid,
'invoice_number' => $payment->invoice?->number ?? '',
],
]);
}
private function verifyAccess($user, int $participantId): void
{
$personId = $user->person_id;
if (!$personId) {
abort(403, 'غير مصرح');
}
$participant = \App\Domain\Participant\Models\Participant::find($participantId);
if (!$participant) {
abort(404);
}
if ($participant->person_id === $personId) {
return;
}
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
if ($guardian && $guardian->participants()->where('participants.id', $participantId)->exists()) {
return;
}
abort(403, 'غير مصرح لك بالوصول لهذه الفاتورة');
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Models\ServiceRequest;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceRequestController extends Controller
{
public function create(Request $request): JsonResponse
{
$request->validate([
'participant_uuid' => 'required|string',
'type' => 'required|in:freeze,unfreeze,transfer,cancellation,other',
'reason' => 'required|string|max:1000',
'metadata' => 'nullable|array',
]);
$user = $request->user();
$participant = $this->findAuthorized($request->participant_uuid, $user);
$existingPending = ServiceRequest::where('participant_id', $participant->id)
->where('type', $request->type)
->where('status', 'pending')
->first();
if ($existingPending) {
return response()->json([
'error' => 'duplicate_request',
'message' => 'يوجد طلب مماثل قيد المراجعة بالفعل',
'data' => [
'uuid' => $existingPending->uuid,
'created_at' => $existingPending->created_at?->toIso8601String(),
],
], 422);
}
$serviceRequest = ServiceRequest::create([
'academy_id' => $participant->academy_id,
'user_id' => $user->id,
'participant_id' => $participant->id,
'type' => $request->type,
'status' => 'pending',
'reason' => $request->reason,
'metadata' => $request->metadata ?? [],
]);
return response()->json([
'success' => true,
'message' => 'تم إرسال الطلب بنجاح وسيتم مراجعته',
'data' => [
'uuid' => $serviceRequest->uuid,
'type' => $serviceRequest->type,
'status' => $serviceRequest->status,
],
]);
}
public function index(Request $request): JsonResponse
{
$user = $request->user();
$participantUuid = $request->query('participant_uuid');
$query = ServiceRequest::where('user_id', $user->id)
->with('participant:id,uuid,person_id')
->orderByDesc('created_at');
if ($participantUuid) {
$participant = Participant::where('uuid', $participantUuid)->first();
if ($participant) {
$query->where('participant_id', $participant->id);
}
}
$requests = $query->paginate(15);
return response()->json([
'data' => $requests->map(fn ($r) => [
'uuid' => $r->uuid,
'type' => $r->type,
'type_label' => $this->typeLabel($r->type),
'status' => $r->status,
'status_label' => $this->statusLabel($r->status),
'reason' => $r->reason,
'admin_notes' => $r->admin_notes,
'participant_uuid' => $r->participant?->uuid,
'created_at' => $r->created_at?->toIso8601String(),
'handled_at' => $r->handled_at?->toIso8601String(),
]),
'meta' => [
'current_page' => $requests->currentPage(),
'last_page' => $requests->lastPage(),
'per_page' => $requests->perPage(),
'total' => $requests->total(),
],
]);
}
public function cancel(string $uuid, Request $request): JsonResponse
{
$user = $request->user();
$serviceRequest = ServiceRequest::where('uuid', $uuid)
->where('user_id', $user->id)
->where('status', 'pending')
->firstOrFail();
$serviceRequest->update(['status' => 'cancelled']);
return response()->json([
'success' => true,
'message' => 'تم إلغاء الطلب',
]);
}
private function typeLabel(string $type): string
{
return match ($type) {
'freeze' => 'طلب تجميد',
'unfreeze' => 'طلب إلغاء تجميد',
'transfer' => 'طلب نقل',
'cancellation' => 'طلب إلغاء اشتراك',
'other' => 'طلب آخر',
default => $type,
};
}
private function statusLabel(string $status): string
{
return match ($status) {
'pending' => 'قيد المراجعة',
'approved' => 'تمت الموافقة',
'rejected' => 'مرفوض',
'cancelled' => 'ملغي',
default => $status,
};
}
private function findAuthorized(string $uuid, $user): Participant
{
$participant = Participant::where('uuid', $uuid)->firstOrFail();
$personId = $user->person_id;
if (!$personId) {
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
if ($participant->person_id === $personId) {
return $participant;
}
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
if ($guardian && $guardian->participants()->where('participants.id', $participant->id)->exists()) {
return $participant;
}
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Financial\Models\InvoiceItem;
use App\Domain\Inventory\Models\Product;
use App\Domain\Participant\Models\Participant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ShopController extends Controller
{
public function products(Request $request): JsonResponse
{
$user = $request->user();
$products = Product::where('is_active', true)
->where('is_essential', true)
->orderBy('name_ar')
->with('installmentPlans')
->get();
$participantUuid = $request->query('participant_uuid');
$participant = null;
if ($participantUuid) {
$participant = Participant::where('uuid', $participantUuid)->first();
}
$productType = 'App\\Domain\\Inventory\\Models\\Product';
$yearStart = now()->startOfYear();
return response()->json([
'data' => $products->map(function ($product) use ($participant, $productType, $yearStart) {
$alreadyPurchasedAt = null;
if ($participant) {
$previousPurchase = InvoiceItem::where('itemable_type', $productType)
->where('itemable_id', $product->id)
->where('created_at', '>=', $yearStart)
->whereHas('invoice', fn ($q) => $q
->where('billable_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('billable_id', $participant->id)
->whereNotIn('status', ['cancelled', 'draft'])
)
->orderByDesc('created_at')
->first();
$alreadyPurchasedAt = $previousPurchase?->created_at?->format('Y-m-d');
}
return [
'id' => $product->id,
'name_ar' => $product->name_ar,
'name' => $product->name,
'sku' => $product->sku,
'selling_price' => $product->selling_price,
'photo_url' => $product->photo_path ? asset('storage/' . $product->photo_path) : null,
'already_purchased_at' => $alreadyPurchasedAt,
'installment_plans' => $product->installmentPlans->map(fn ($plan) => [
'id' => $plan->id,
'name_ar' => $plan->name_ar ?? $plan->name,
'installments_count' => $plan->installments_count,
'down_payment' => $plan->down_payment,
]),
];
}),
]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Financial\Models\Wallet;
use App\Http\Controllers\Controller;
use App\Domain\Participant\Models\Participant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WalletController extends Controller
{
public function balance(string $participantUuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($participantUuid, $request->user());
$wallet = Wallet::where('owner_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('owner_id', $participant->id)
->first();
if (!$wallet) {
return response()->json([
'data' => [
'balance' => 0,
'balance_display' => '0.00 ج.م',
'currency' => 'EGP',
'is_active' => false,
'has_wallet' => false,
],
]);
}
return response()->json([
'data' => [
'balance' => $wallet->balance,
'balance_display' => number_format($wallet->balance / 100, 2) . ' ج.م',
'currency' => $wallet->currency ?? 'EGP',
'is_active' => $wallet->is_active,
'has_wallet' => true,
],
]);
}
public function transactions(string $participantUuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($participantUuid, $request->user());
$wallet = Wallet::where('owner_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('owner_id', $participant->id)
->first();
if (!$wallet) {
return response()->json([
'data' => [],
'meta' => ['current_page' => 1, 'last_page' => 1, 'per_page' => 20, 'total' => 0],
]);
}
$transactions = $wallet->walletTransactions()
->orderByDesc('created_at')
->paginate(20);
return response()->json([
'data' => $transactions->map(fn ($t) => [
'id' => $t->uuid,
'type' => $t->type,
'direction' => $t->direction,
'amount' => $t->amount,
'amount_display' => number_format($t->amount / 100, 2) . ' ج.م',
'balance_after' => $t->balance_after,
'balance_after_display' => number_format($t->balance_after / 100, 2) . ' ج.م',
'description' => $t->description,
'created_at' => $t->created_at?->toIso8601String(),
]),
'meta' => [
'current_page' => $transactions->currentPage(),
'last_page' => $transactions->lastPage(),
'per_page' => $transactions->perPage(),
'total' => $transactions->total(),
],
]);
}
private function findAuthorized(string $uuid, $user): Participant
{
$participant = Participant::where('uuid', $uuid)->firstOrFail();
$personId = $user->person_id;
if (!$personId) {
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
if ($participant->person_id === $personId) {
return $participant;
}
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
if ($guardian && $guardian->participants()->where('participants.id', $participant->id)->exists()) {
return $participant;
}
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
}
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
use App\Domain\Shared\Models\Academy; use App\Domain\Shared\Models\Academy;
use App\Domain\Website\Models\WebsitePage; use App\Domain\Website\Models\WebsitePage;
use App\Domain\Website\Services\WebsitePageService;
use App\Domain\Website\Services\WebsiteSettingService; use App\Domain\Website\Services\WebsiteSettingService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
...@@ -32,6 +33,16 @@ public function fallback(Request $request) ...@@ -32,6 +33,16 @@ public function fallback(Request $request)
throw new NotFoundHttpException; throw new NotFoundHttpException;
} }
// A reserved prefix belongs to the application, not to the page builder.
// Without this the fallback answers 200 for every unrouted path under it —
// so a removed or mistyped endpoint returns a website page instead of 404,
// which hides deploy mistakes and defeats client-side route validation.
$first = strtok($slug, '/');
if (in_array($first, WebsitePageService::RESERVED_SLUGS, true)) {
throw new NotFoundHttpException;
}
return $this->show($request, $slug); return $this->show($request, $slug);
} }
......
<?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;
}
}
<?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->session?->session_date?->format('Y-m-d'),
'status' => $this->status?->value ?? $this->status,
'check_in_time' => $this->check_in_at,
'check_out_time' => $this->check_out_at,
'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->number,
'status' => $this->status?->value ?? $this->status,
'total_amount' => $this->total_amount,
'paid_amount' => $this->paid_amount,
'balance_due' => $this->due_amount,
'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,
'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,
];
}
}
...@@ -94,19 +94,20 @@ public function submit(): void ...@@ -94,19 +94,20 @@ public function submit(): void
} }
} }
// Store attachment if provided // There is no Excuse model or table yet, so there is nowhere to record this.
$attachmentPath = null; //
if ($this->attachment) { // What used to happen here: the attachment — typically a child's medical
$attachmentPath = $this->attachment->store('excuses', 'public'); // note — was written to the PUBLIC disk, giving it a guessable
} // unauthenticated URL, and then the form flashed success and discarded
// everything else. The parent believed the absence was excused, the
// For now, flash success. When an Excuse model exists, create the record here. // academy never saw it, and the attendance record stayed 'absent' and fed
// TODO: Create excuse record when model is available // the consecutive-absence threshold that auto-suspends a participant.
// Excuse::create([...]) //
// Storing nothing and saying so is the only honest behaviour until the
session()->flash('success', __('تم تقديم العذر بنجاح. سيتم مراجعته من قبل الإدارة.')); // request is modelled properly (planned as a service_requests type, so the
// approval path reuses AttendanceMarkingService with a staff marker rather
$this->reset(['excuseType', 'description', 'attachment', 'sessionId']); // than writing attendance directly).
session()->flash('error', __('تقديم الأعذار من التطبيق غير متاح حاليًا. برجاء التواصل مع الأكاديمية لتسجيل العذر.'));
} }
public function render() public function render()
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
use App\Domain\Training\Models\TrainingSession; use App\Domain\Training\Models\TrainingSession;
use Carbon\Carbon; use Carbon\Carbon;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
...@@ -19,6 +20,14 @@ ...@@ -19,6 +20,14 @@
#[Title('الرئيسية - بوابة ولي الأمر')] #[Title('الرئيسية - بوابة ولي الأمر')]
class ParentHome extends Component class ParentHome extends Component
{ {
/**
* Locked because render() filters attendance, invoices and evaluations on this
* id directly. A plain public property is settable from the browser, so
* without this the checks in mount() and selectChild() are decoration: a
* guardian could walk participant ids and read any child's balance,
* attendance and evaluations. Locked still allows the server-side writes below.
*/
#[Locked]
public ?int $activeChildId = null; public ?int $activeChildId = null;
public function mount(): void public function mount(): void
...@@ -52,6 +61,15 @@ public function render() ...@@ -52,6 +61,15 @@ public function render()
$guardian = $this->getGuardian(); $guardian = $this->getGuardian();
$childrenIds = $this->getChildrenIds(); $childrenIds = $this->getChildrenIds();
// Defence in depth behind #[Locked]: every query below filters on
// $activeChildId, so it is re-checked against this guardian's children on
// each render rather than trusted from mount(). Children can also change
// between requests — a withdrawal mid-session would otherwise leave a
// stale id in scope.
if (! in_array($this->activeChildId, $childrenIds, true)) {
$this->activeChildId = $childrenIds[0] ?? null;
}
$children = Participant::whereIn('id', $childrenIds) $children = Participant::whereIn('id', $childrenIds)
->with('person') ->with('person')
->get(); ->get();
......
...@@ -4,12 +4,9 @@ ...@@ -4,12 +4,9 @@
use App\Domain\Identity\Services\PermissionService; use App\Domain\Identity\Services\PermissionService;
use App\Domain\Shared\Context\BranchContext; use App\Domain\Shared\Context\BranchContext;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
...@@ -46,26 +43,5 @@ public function boot(): void ...@@ -46,26 +43,5 @@ public function boot(): void
} }
return app(PermissionService::class)->can($user, $permission); return app(PermissionService::class)->can($user, $permission);
}); });
$this->configureRateLimiting();
}
private function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('api-auth', function (Request $request) {
return Limit::perMinute(10)->by($request->ip());
});
RateLimiter::for('api-otp', function (Request $request) {
return Limit::perMinute(3)->by($request->input('phone', $request->ip()));
});
RateLimiter::for('api-payments', function (Request $request) {
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
});
} }
} }
...@@ -10,9 +10,13 @@ ...@@ -10,9 +10,13 @@
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
// No `api:` route file. The member-facing surface is the session-authenticated
// web portal; a second token-authenticated surface meant every feature was built
// twice and each new controller re-implemented its own authorization by hand.
// The Sanctum token guard stays available for a future native shell, but nothing
// is routed to it until something actually needs it.
->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',
) )
...@@ -28,10 +32,6 @@ ...@@ -28,10 +32,6 @@
\App\Http\Middleware\ResolveBranchContext::class, \App\Http\Middleware\ResolveBranchContext::class,
\App\Http\Middleware\RequireBranchSelection::class, \App\Http\Middleware\RequireBranchSelection::class,
]); ]);
$middleware->api(append: [
\App\Http\Middleware\SetCurrentAcademy::class,
\App\Http\Middleware\ApiResponseHeaders::class,
]);
$middleware->alias([ $middleware->alias([
'permission' => \App\Http\Middleware\CheckPermission::class, 'permission' => \App\Http\Middleware\CheckPermission::class,
'super_admin' => \App\Http\Middleware\EnsureSuperAdmin::class, 'super_admin' => \App\Http\Middleware\EnsureSuperAdmin::class,
...@@ -89,47 +89,75 @@ ...@@ -89,47 +89,75 @@
if ($statusCode === 500 || (!$e instanceof HttpExceptionInterface && $statusCode >= 500)) { if ($statusCode === 500 || (!$e instanceof HttpExceptionInterface && $statusCode >= 500)) {
$errorId = Str::uuid()->toString(); $errorId = Str::uuid()->toString();
// Everything below is a diagnostic payload for a developer, and it is
// rendered into the browser by errors/500.blade.php. It must never leave
// the server outside local debugging: the session alone carries
// `password_hash_web` — the signed-in user's bcrypt hash — and Laravel
// only strips `_token`/`_previous`/`_flash`, not the auth keys. The
// request input, headers and last queries are the same class of leak.
// The full detail still reaches storage/logs via Log::error below, which
// is where an operator should be reading it from.
$debug = (bool) config('app.debug');
$queries = []; $queries = [];
try { if ($debug) {
$queryLog = DB::getQueryLog(); try {
$queries = collect($queryLog)->takeRight(10)->map(fn ($q) => [ $queryLog = DB::getQueryLog();
'query' => $q['query'] ?? '', $queries = collect($queryLog)->takeRight(10)->map(fn ($q) => [
'time' => $q['time'] ?? null, 'query' => $q['query'] ?? '',
])->toArray(); 'time' => $q['time'] ?? null,
} catch (\Throwable $ignored) { ])->toArray();
} catch (\Throwable $ignored) {
}
} }
$trace = collect($e->getTrace())->take(30)->map(fn ($frame) => [ $trace = [];
'file' => $frame['file'] ?? '(internal)', if ($debug) {
'line' => $frame['line'] ?? null, $trace = collect($e->getTrace())->take(30)->map(fn ($frame) => [
'class' => $frame['class'] ?? '', 'file' => $frame['file'] ?? '(internal)',
'type' => $frame['type'] ?? '', 'line' => $frame['line'] ?? null,
'function' => $frame['function'] ?? '', 'class' => $frame['class'] ?? '',
])->toArray(); 'type' => $frame['type'] ?? '',
'function' => $frame['function'] ?? '',
])->toArray();
}
$inputData = ''; $inputData = '';
try { if ($debug) {
$filtered = $request->except(['password', 'password_confirmation', '_token']); try {
$inputData = !empty($filtered) ? json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : ''; $filtered = $request->except(['password', 'password_confirmation', '_token']);
} catch (\Throwable $ignored) { $inputData = !empty($filtered) ? json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '';
} catch (\Throwable $ignored) {
}
} }
$headers = ''; $headers = '';
try { if ($debug) {
$headerBag = $request->headers->all(); try {
unset($headerBag['cookie'], $headerBag['authorization']); $headerBag = $request->headers->all();
$headers = json_encode($headerBag, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); unset($headerBag['cookie'], $headerBag['authorization']);
} catch (\Throwable $ignored) { $headers = json_encode($headerBag, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
} catch (\Throwable $ignored) {
}
} }
$sessionData = ''; $sessionData = '';
try { if ($debug) {
if ($request->hasSession()) { try {
$sess = $request->session()->all(); if ($request->hasSession()) {
unset($sess['_token'], $sess['_previous'], $sess['_flash']); $sess = $request->session()->all();
$sessionData = !empty($sess) ? json_encode($sess, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : ''; unset($sess['_token'], $sess['_previous'], $sess['_flash']);
// Auth keys are `password_hash_<guard>` and `login_<guard>_<sha1>`;
// strip by prefix so a new guard cannot reintroduce the leak.
foreach (array_keys($sess) as $key) {
if (str_starts_with($key, 'password_hash_') || str_starts_with($key, 'login_')) {
unset($sess[$key]);
}
}
$sessionData = !empty($sess) ? json_encode($sess, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '';
}
} catch (\Throwable $ignored) {
} }
} catch (\Throwable $ignored) {
} }
Log::error('[' . $errorId . '] Unhandled Exception', [ Log::error('[' . $errorId . '] Unhandled Exception', [
...@@ -149,18 +177,23 @@ ...@@ -149,18 +177,23 @@
'trace' => $e->getTraceAsString(), 'trace' => $e->getTraceAsString(),
]); ]);
// The exception message, file and line are diagnostics too — a query
// exception message embeds the SQL and its bindings. Outside debug the
// page shows only the error id, which is what a user should quote to
// support and what ties their report to the log line above.
return response()->view('errors.500', [ return response()->view('errors.500', [
'errorId' => $errorId, 'errorId' => $errorId,
'exceptionClass' => get_class($e), 'debug' => $debug,
'message' => $e->getMessage(), 'exceptionClass' => $debug ? get_class($e) : null,
'file' => $e->getFile(), 'message' => $debug ? $e->getMessage() : null,
'line' => $e->getLine(), 'file' => $debug ? $e->getFile() : null,
'url' => $request->fullUrl(), 'line' => $debug ? $e->getLine() : null,
'method' => $request->method(), 'url' => $debug ? $request->fullUrl() : null,
'routeName' => $request->route()?->getName() ?? $request->route()?->uri(), 'method' => $debug ? $request->method() : null,
'userName' => $request->user()?->name_ar ?? $request->user()?->name, 'routeName' => $debug ? ($request->route()?->getName() ?? $request->route()?->uri()) : null,
'userId' => $request->user()?->id, 'userName' => $debug ? ($request->user()?->name_ar ?? $request->user()?->name) : null,
'ip' => $request->ip(), 'userId' => $debug ? $request->user()?->id : null,
'ip' => $debug ? $request->ip() : null,
'trace' => $trace, 'trace' => $trace,
'inputData' => $inputData, 'inputData' => $inputData,
'headers' => $headers, 'headers' => $headers,
......
...@@ -21,7 +21,12 @@ chmod 664 storage/logs/laravel.log ...@@ -21,7 +21,12 @@ chmod 664 storage/logs/laravel.log
# Build .env from Docker env vars (CapRover passes them as container env) # Build .env from Docker env vars (CapRover passes them as container env)
: > .env : > .env
env | grep -E "^(APP_|DB_|LOG_|SESSION_|CACHE_|QUEUE_|FILESYSTEM_|BROADCAST_|MAIL_|TRUSTED_|BCRYPT_|PLATFORM_|ADMIN_|ACADEMY_|RUN_|WHATSAPP_|MESSAGING_)" | sort | sed 's/=\(.*\)/="\1"/' >> .env # Anything not matched here is absent from .env, and `config:cache` below then bakes
# the config default — usually null — into the compiled config for the life of the
# container. That is why PAYMOB_* was silently missing: the gateway read null
# credentials and failed closed with no error anywhere. Add a prefix here whenever
# you add one to config/services.php.
env | grep -E "^(APP_|DB_|LOG_|SESSION_|CACHE_|QUEUE_|FILESYSTEM_|BROADCAST_|MAIL_|TRUSTED_|BCRYPT_|PLATFORM_|ADMIN_|ACADEMY_|RUN_|WHATSAPP_|MESSAGING_|PAYMOB_|FIREBASE_|SMS_|VAPID_|CHECKIN_)" | sort | sed 's/=\(.*\)/="\1"/' >> .env
# Ensure APP_KEY line exists (key:generate needs it to write to) # Ensure APP_KEY line exists (key:generate needs it to write to)
if ! grep -q '^APP_KEY=' .env 2>/dev/null; then if ! grep -q '^APP_KEY=' .env 2>/dev/null; then
...@@ -48,12 +53,22 @@ php artisan route:cache ...@@ -48,12 +53,22 @@ php artisan route:cache
php artisan view:cache php artisan view:cache
php artisan event:cache php artisan event:cache
# Run pending migrations # Run pending migrations.
php artisan migrate --force --no-interaction || { #
echo "==> WARN: Migration failed. If 'Insufficient privilege', ensure DB_USERNAME owns all tables." # This used to log a warning and continue. That is the worst possible outcome: the
echo "==> Fix: psql -U postgres -d \$DB_DATABASE -c \"REASSIGN OWNED BY old_owner TO \$DB_USERNAME;\"" # container boots and serves traffic against a schema the code no longer matches,
echo "==> Continuing startup..." # and because the failed migration is never recorded as run, every migration queued
} # behind it is blocked on every subsequent deploy — silently, forever. The failure
# surfaces weeks later as a missing column in an unrelated feature.
#
# Failing the boot keeps the previous healthy container serving while the error is
# visible in the deploy log, which is the outcome you actually want.
if ! php artisan migrate --force --no-interaction; then
echo "==> FATAL: Migration failed. Refusing to start against an unmigrated schema."
echo "==> If 'Insufficient privilege', ensure DB_USERNAME owns all tables:"
echo "==> psql -U postgres -d \$DB_DATABASE -c \"REASSIGN OWNED BY old_owner TO \$DB_USERNAME;\""
exit 1
fi
# First-deploy seeding (one-click install) # First-deploy seeding (one-click install)
if [ "$RUN_SEED_ON_FIRST_DEPLOY" = "true" ]; then if [ "$RUN_SEED_ON_FIRST_DEPLOY" = "true" ]; then
......
...@@ -22,6 +22,34 @@ server { ...@@ -22,6 +22,34 @@ server {
# Max upload size # Max upload size
client_max_body_size 25M; client_max_body_size 25M;
# Application-served files that LOOK static but are generated by PHP.
#
# These must come before the static-asset regex below. nginx evaluates exact
# (`location =`) matches first, so without them the regex wins — it matches on
# extension and ends in `try_files $uri =404`, which returns 404 for any such
# path that is not a real file on disk. That is why a dynamic service worker or
# a per-tenant manifest cannot be served without this block.
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
try_files $uri /index.php?$query_string;
}
location = /manifest.webmanifest {
try_files $uri /index.php?$query_string;
}
# Native-app association files. Both must be served as application/json with no
# redirect or the platform silently refuses to verify the deep-link domain.
location = /.well-known/assetlinks.json {
default_type application/json;
try_files $uri /index.php?$query_string;
}
location = /.well-known/apple-app-site-association {
default_type application/json;
try_files $uri /index.php?$query_string;
}
# Static assets with cache # Static assets with cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y; expires 1y;
...@@ -57,7 +85,16 @@ server { ...@@ -57,7 +85,16 @@ server {
deny all; deny all;
} }
# Health check endpoint # Health check endpoint.
#
# WARNING: this answers 200 from nginx without ever reaching PHP, so it stays
# green when PHP-FPM is down, the database is unreachable, or a migration has
# failed — it only proves nginx is listening. It also shadows the /health route
# in routes/web.php (HealthController), which therefore never runs in production.
# Laravel's real health endpoint is /up, which does reach the application.
# Point the platform health check at /up, then delete this block. Left in place
# for now because changing it flips deploy behaviour for every existing client
# and that is a decision to make deliberately, not as a side effect.
location /health { location /health {
access_log off; access_log off;
return 200 'ok'; return 200 'ok';
......
@php
// Set by bootstrap/app.php. Every diagnostic below is developer-only: the stack
// trace, request input, headers, session and SQL log all leak data that must not
// reach a browser in production. The operator reads them from storage/logs instead,
// keyed by the same error id shown to the user.
$isDebug = (bool) ($debug ?? false);
@endphp
<!DOCTYPE html> <!DOCTYPE html>
<html dir="rtl" lang="ar" class="h-full"> <html dir="rtl" lang="ar" class="h-full">
<head> <head>
...@@ -5,14 +12,48 @@ ...@@ -5,14 +12,48 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>خطأ في النظام</title> <title>خطأ في النظام</title>
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script> @if($isDebug)
{{-- Local debugging only. This page renders when the app is broken, so it cannot
rely on the Vite bundle; the CDN is acceptable here and never ships to a user. --}}
<script src="https://cdn.tailwindcss.com"></script>
@endif
<style> <style>
body { font-family: 'Cairo', sans-serif; } body { font-family: 'Cairo', sans-serif; }
.stack-frame { transition: all 0.2s; } .stack-frame { transition: all 0.2s; }
.stack-frame:hover { background: #fef3c7; } .stack-frame:hover { background: #fef3c7; }
pre { white-space: pre-wrap; word-break: break-word; } pre { white-space: pre-wrap; word-break: break-word; }
/* Self-contained so the production page needs no external stylesheet. */
.ec-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; background: #f8fafc; color: #0f172a; }
.ec-card { max-width: 30rem; width: 100%; background: #fff; border: 1px solid #e2e8f0; border-radius: 16px; padding: 40px 32px; text-align: center; box-shadow: 0 1px 3px rgba(15,23,42,.06); }
.ec-badge { width: 56px; height: 56px; border-radius: 9999px; background: #fef2f2; color: #dc2626; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px; }
.ec-title { font-size: 1.375rem; font-weight: 700; margin: 0 0 8px; }
.ec-body { color: #475569; line-height: 1.7; margin: 0 0 24px; }
.ec-id { display: inline-block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8125rem; direction: ltr; background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 14px; color: #334155; margin-bottom: 24px; user-select: all; }
.ec-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
.ec-btn { display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border-radius: 10px; font-size: .875rem; font-weight: 600; text-decoration: none; border: 1px solid transparent; }
.ec-btn-primary { background: #1d4ed8; color: #fff; }
.ec-btn-secondary { background: #fff; color: #334155; border-color: #cbd5e1; }
</style> </style>
</head> </head>
@unless($isDebug)
<body class="h-full">
<div class="ec-wrap">
<div class="ec-card">
<div class="ec-badge" aria-hidden="true">
<svg width="28" height="28" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
</div>
<h1 class="ec-title">حدث خطأ غير متوقع</h1>
<p class="ec-body">حصلت مشكلة أثناء تنفيذ طلبك، وتم تسجيلها تلقائيًا. لو المشكلة اتكررت، ابعت الرقم ده للدعم الفني.</p>
<div class="ec-id" dir="ltr">{{ $errorId ?? 'N/A' }}</div>
<div class="ec-actions">
<a href="{{ url('/') }}" class="ec-btn ec-btn-primary">الصفحة الرئيسية</a>
<a href="{{ url()->previous() }}" class="ec-btn ec-btn-secondary">العودة</a>
</div>
</div>
</div>
</body>
</html>
@else
<body class="h-full bg-gray-50"> <body class="h-full bg-gray-50">
<div class="min-h-screen py-8 px-4 sm:px-6 lg:px-8"> <div class="min-h-screen py-8 px-4 sm:px-6 lg:px-8">
<div class="max-w-6xl mx-auto"> <div class="max-w-6xl mx-auto">
...@@ -254,3 +295,4 @@ function copyErrorReport() { ...@@ -254,3 +295,4 @@ function copyErrorReport() {
</script> </script>
</body> </body>
</html> </html>
@endunless
<?php
use App\Http\Controllers\Api\V1\AbsenceController;
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\BroadcastController;
use App\Http\Controllers\Api\V1\DashboardController;
use App\Http\Controllers\Api\V1\DeepLinkController;
use App\Http\Controllers\Api\V1\DeviceController;
use App\Http\Controllers\Api\V1\DocumentController;
use App\Http\Controllers\Api\V1\EvaluationController;
use App\Http\Controllers\Api\V1\EventController;
use App\Http\Controllers\Api\V1\InstallmentController;
use App\Http\Controllers\Api\V1\MessageController;
use App\Http\Controllers\Api\V1\NotificationController;
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\PushAnalyticsController;
use App\Http\Controllers\Api\V1\ReceiptController;
use App\Http\Controllers\Api\V1\ServiceRequestController;
use App\Http\Controllers\Api\V1\ShopController;
use App\Http\Controllers\Api\V1\WalletController;
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');
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']);
});
});
// Public academy content (accessible without login for explore)
Route::prefix('academy')->group(function () {
Route::get('news', [AcademyController::class, 'news']);
Route::get('news/{uuid}', [AcademyController::class, 'newsShow']);
Route::get('programs', [AcademyController::class, 'programs']);
Route::get('events', [AcademyController::class, 'events']);
Route::get('events/{uuid}', [EventController::class, 'show']);
Route::get('gallery', [AcademyController::class, 'gallery']);
});
// Protected endpoints
Route::middleware('auth:sanctum')->group(function () {
// 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']);
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']);
Route::get('documents', [DocumentController::class, 'index']);
Route::get('wallet', [WalletController::class, 'balance']);
Route::get('wallet/transactions', [WalletController::class, 'transactions']);
Route::get('installments', [InstallmentController::class, 'plans']);
Route::get('evaluations', [EvaluationController::class, 'index']);
Route::get('evaluations/{evaluationUuid}', [EvaluationController::class, 'show']);
});
// Notifications
Route::get('notifications', [NotificationController::class, 'index']);
Route::patch('notifications/{id}/read', [NotificationController::class, 'markAsRead']);
Route::post('notifications/read-all', [NotificationController::class, 'markAllAsRead']);
Route::get('notifications/preferences', [NotificationController::class, 'getPreferences']);
Route::post('notifications/preferences', [NotificationController::class, 'updatePreferences']);
// Shop (essential products)
Route::get('products', [ShopController::class, 'products']);
// Orders (product purchases from mobile)
Route::post('orders/create', [OrderController::class, 'create']);
Route::get('orders', [OrderController::class, 'index']);
// Payments (online via Paymob)
Route::post('payments/initiate', [PaymentController::class, 'initiate'])->middleware('throttle:api-payments');
// Invoices & Receipts
Route::get('invoices/{uuid}', [ReceiptController::class, 'invoiceDetail']);
Route::get('payments/{uuid}/receipt', [ReceiptController::class, 'paymentReceipt']);
// Events (authenticated actions)
Route::post('events/{uuid}/register', [EventController::class, 'register']);
Route::get('events/my-registrations', [EventController::class, 'myRegistrations']);
// Service requests (freeze, transfer, cancellation)
Route::post('service-requests', [ServiceRequestController::class, 'create']);
Route::get('service-requests', [ServiceRequestController::class, 'index']);
Route::post('service-requests/{uuid}/cancel', [ServiceRequestController::class, 'cancel']);
// Absence reporting
Route::post('absences/report', [AbsenceController::class, 'report']);
// Messages (guardian ↔ academy)
Route::post('messages/send', [MessageController::class, 'send']);
Route::get('messages', [MessageController::class, 'index']);
// Push analytics & badge
Route::post('push/track', [PushAnalyticsController::class, 'track']);
Route::get('push/badge', [PushAnalyticsController::class, 'badge']);
Route::post('push/heartbeat', [PushAnalyticsController::class, 'heartbeat']);
// Broadcast announcements (admin only)
Route::post('broadcast/send', [BroadcastController::class, 'send']);
Route::get('broadcast/history', [BroadcastController::class, 'index']);
// Deep link routing map
Route::get('deeplinks/routes', [DeepLinkController::class, 'routes']);
});
// Payment gateway webhook (NO auth — Paymob sends server-to-server)
Route::post('payments/callback', [PaymentController::class, 'callback']);
});
<?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 PushNotificationTest extends TestCase
{
public function test_push_badge_requires_auth(): void
{
$response = $this->getJson('/api/v1/push/badge');
$response->assertUnauthorized();
}
public function test_push_track_requires_auth(): void
{
$response = $this->postJson('/api/v1/push/track', [
'events' => [['event_type' => 'test', 'action' => 'opened']],
]);
$response->assertUnauthorized();
}
public function test_push_heartbeat_requires_auth(): void
{
$response = $this->postJson('/api/v1/push/heartbeat', ['token' => 'abc']);
$response->assertUnauthorized();
}
public function test_broadcast_send_requires_auth(): void
{
$response = $this->postJson('/api/v1/broadcast/send', []);
$response->assertUnauthorized();
}
public function test_broadcast_history_requires_auth(): void
{
$response = $this->getJson('/api/v1/broadcast/history');
$response->assertUnauthorized();
}
public function test_deeplinks_routes_requires_auth(): void
{
$response = $this->getJson('/api/v1/deeplinks/routes');
$response->assertUnauthorized();
}
public function test_push_track_validates_events_array(): void
{
// Without auth it's 401, so we just verify the endpoint exists
$response = $this->postJson('/api/v1/push/track', []);
$this->assertContains($response->status(), [401, 422]);
}
}
<?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]);
}
}
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
/**
* The 500 handler in bootstrap/app.php builds a rich diagnostic payload — stack trace,
* request input, headers, session and the last SQL queries — and errors/500.blade.php
* renders it into the page. That payload was not gated on APP_DEBUG, so any 500 on a
* production tenant disclosed the signed-in user's session (which carries
* `password_hash_web`, their bcrypt hash) to whoever triggered the error.
*
* These tests pin the gate. If someone removes it, this file fails.
*/
class ErrorPageDisclosureTest extends TestCase
{
private const SECRET_HASH = '$2y$10$ThisIsTheUsersBcryptHashDoNotLeakIt';
protected function setUp(): void
{
parent::setUp();
Route::get('/__boom', function () {
session()->put('password_hash_web', self::SECRET_HASH);
// Deliberately not the real recaller key (which would make the guard hit the
// DB); this only has to prove the `login_` prefix is stripped.
session()->put('login_web_deadbeefdeadbeef', 1);
session()->put('harmless_key', 'harmless_value');
throw new \RuntimeException('SQLSTATE[42P01]: select * from "secret_table" where "national_id" = 29001011234567');
})->middleware('web');
}
public function test_production_error_page_discloses_nothing_but_the_error_id(): void
{
config(['app.debug' => false]);
$response = $this->get('/__boom');
$response->assertStatus(500);
$body = $response->getContent();
// The whole point: the auth session must never reach the browser.
$this->assertStringNotContainsString(self::SECRET_HASH, $body);
$this->assertStringNotContainsString('password_hash_web', $body);
$this->assertStringNotContainsString('login_web_', $body);
// Nor the rest of the diagnostic payload.
$this->assertStringNotContainsString('harmless_value', $body, 'session contents leaked');
$this->assertStringNotContainsString('secret_table', $body, 'exception message leaked');
$this->assertStringNotContainsString('29001011234567', $body, 'query bindings leaked');
$this->assertStringNotContainsString('RuntimeException', $body, 'exception class leaked');
$this->assertStringNotContainsString('bootstrap/app.php', $body, 'stack trace leaked');
$this->assertStringNotContainsString('cdn.tailwindcss.com', $body, 'error page must not call out to a CDN');
// The user still gets the one thing that lets support find the log line.
$response->assertSee('حدث خطأ غير متوقع');
}
public function test_debug_error_page_still_shows_diagnostics_to_the_developer(): void
{
config(['app.debug' => true]);
$response = $this->get('/__boom');
$response->assertStatus(500);
$body = $response->getContent();
$this->assertStringContainsString('RuntimeException', $body);
$this->assertStringContainsString('secret_table', $body);
// Even in debug the auth keys are stripped — a shared screen or a pasted
// bug report should never carry a password hash.
$this->assertStringNotContainsString(self::SECRET_HASH, $body);
}
}
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Route;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* The /api/v1 surface was removed. It carried an authentication bypass
* (AuthOtpController::verify accepted a constant '0000' in the default mode and
* minted a Sanctum token for any active user matching a phone number), an
* unauthenticated academy-wide push broadcast, an attendance write with no
* authorization, and several endpoints whose ownership checks were inverted or
* absent. Rather than audit twenty-four controllers, the surface was deleted.
*
* This test exists so it cannot come back by accident — a re-added route file or
* a stray controller fails here rather than in production.
*/
class MobileApiRemovedTest extends TestCase
{
public static function removedEndpoints(): array
{
return [
'otp request' => ['post', '/api/v1/auth/otp/request'],
'otp verify — the bypass' => ['post', '/api/v1/auth/otp/verify'],
'app config' => ['get', '/api/v1/app/config'],
'push broadcast' => ['post', '/api/v1/broadcast/send'],
'absence write' => ['post', '/api/v1/absences/report'],
'payment initiate' => ['post', '/api/v1/payments/initiate'],
'health' => ['get', '/api/v1/health'],
];
}
#[DataProvider('removedEndpoints')]
public function test_removed_endpoint_is_not_routable(string $method, string $uri): void
{
$status = $this->{$method}($uri)->getStatusCode();
// 404 for GET; 405 for the write verbs, because Route::fallback is
// registered for GET only, so a POST to an unrouted path matches the URI
// but not the method. Both mean "nothing serves this".
$this->assertContains(
$status,
[404, 405],
"{$uri} answered {$status}; the mobile API must stay removed."
);
}
public function test_no_route_is_registered_under_the_api_prefix(): void
{
$apiRoutes = collect(Route::getRoutes()->getRoutes())
->map(fn ($route) => $route->uri())
->filter(fn (string $uri) => str_starts_with($uri, 'api/'))
->values()
->all();
$this->assertSame([], $apiRoutes, 'Routes are registered under api/: '.implode(', ', $apiRoutes));
}
/**
* The website page builder's fallback answers for any unrouted path, so
* without a reserved-prefix guard a deleted endpoint returns a 200 HTML page
* instead of 404 — which would have made the assertions above pass for the
* wrong reason if they only checked "not 200".
*/
public function test_reserved_prefixes_are_not_answered_by_the_page_builder_fallback(): void
{
foreach (['api/anything', 'app/anything', 'livewire/anything', 'admin/anything'] as $uri) {
$this->assertSame(404, $this->get('/'.$uri)->getStatusCode(), "{$uri} should 404");
}
}
}
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