diff --git a/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php b/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php index 7f8e8c68..16d3a376 100644 --- a/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php +++ b/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php @@ -96,7 +96,9 @@ class ClassProgressController extends BaseApiController $filters = $request->validated(); $meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null); $filters['school_year'] = $filters['school_year'] ?? ($meta['school_year'] ?? null); - $filters['semester'] = $filters['semester'] ?? ($meta['semester'] ?? null); + if ($this->isAdminProgressAliasRoute($request)) { + $filters['group_by_week'] = true; + } $paginator = $this->queryService->listReports($auth, $filters); if (! empty($filters['group_by_week'])) { @@ -262,6 +264,12 @@ class ClassProgressController extends BaseApiController || $request->is('api/v1/teacher/class-progress-view'); } + private function isAdminProgressAliasRoute(ClassProgressIndexRequest $request): bool + { + return $request->is('api/v1/administrator/progress') + || $request->is('api/v1/admin/progress'); + } + /** * @param list $roles */ diff --git a/app/Http/Controllers/Api/Family/FamilyAdminController.php b/app/Http/Controllers/Api/Family/FamilyAdminController.php index cc0ff8e9..df89cdab 100644 --- a/app/Http/Controllers/Api/Family/FamilyAdminController.php +++ b/app/Http/Controllers/Api/Family/FamilyAdminController.php @@ -28,7 +28,7 @@ class FamilyAdminController extends BaseApiController public function index(FamilyAdminIndexRequest $request): JsonResponse { $payload = $request->validated(); - $schoolYear = trim((string) (Configuration::getConfig('school_year') ?? '')); + $schoolYear = $this->requestedSchoolYear($request, $payload); $data = $this->queryService->adminIndexData( $payload['student_id'] ?? null, @@ -63,7 +63,7 @@ class FamilyAdminController extends BaseApiController public function card(FamilyAdminCardRequest $request): JsonResponse { $payload = $request->validated(); - $schoolYear = trim((string) (Configuration::getConfig('school_year') ?? '')); + $schoolYear = $this->requestedSchoolYear($request, $payload); $family = $this->queryService->familyCard( $payload['student_id'] ?? null, @@ -79,6 +79,26 @@ class FamilyAdminController extends BaseApiController return $this->success(new FamilyResource($family)); } + private function requestedSchoolYear($request, array $payload): string + { + $candidates = [ + $payload['school_year'] ?? null, + $request->query('school_year'), + $request->header('X-School-Year'), + $request->header('X-Selected-School-Year'), + Configuration::getConfig('school_year'), + ]; + + foreach ($candidates as $candidate) { + $value = trim((string) ($candidate ?? '')); + if ($value !== '') { + return $value; + } + } + + return ''; + } + public function composeEmail(FamilyComposeEmailRequest $request): JsonResponse { $payload = $request->validated(); diff --git a/app/Http/Controllers/Api/Finance/ReimbursementController.php b/app/Http/Controllers/Api/Finance/ReimbursementController.php index 23a819a1..6de87d15 100644 --- a/app/Http/Controllers/Api/Finance/ReimbursementController.php +++ b/app/Http/Controllers/Api/Finance/ReimbursementController.php @@ -34,6 +34,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Facades\DB; use RuntimeException; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -286,6 +287,59 @@ class ReimbursementController extends BaseApiController ]); } + public function createContext(Request $request): JsonResponse + { + $expenseId = (int) $request->query('expense_id', 0); + $prefillAmount = null; + $expense = null; + + if ($expenseId > 0) { + $expense = DB::table('expenses') + ->select('id', 'amount', 'description', 'purchased_by', 'receipt_path', 'retailor') + ->where('id', $expenseId) + ->first(); + if ($expense) { + $prefillAmount = $expense->amount ?? null; + } + } + + $data = $this->queryService->index([ + 'school_year' => $request->query('school_year', $this->context->getSchoolYear()), + 'semester' => $request->query('semester', $this->context->getSemester()), + ]); + + return response()->json([ + 'ok' => true, + 'users' => ReimbursementRecipientResource::collection($data['users'] ?? []), + 'expense_id' => $expenseId ?: null, + 'expense' => $expense ? (array) $expense : null, + 'prefill_amount' => $prefillAmount, + ]); + } + + public function editData(int $id): JsonResponse + { + $reimb = Reimbursement::query()->find($id); + if (! $reimb) { + return response()->json(['ok' => false, 'message' => 'Reimbursement not found.'], 404); + } + + $data = $this->queryService->index([ + 'school_year' => $reimb->school_year ?? $this->context->getSchoolYear(), + 'semester' => $reimb->semester ?? $this->context->getSemester(), + ]); + + $payload = $reimb->toArray(); + $payload['receipt_url'] = $this->fileService->receiptUrl($reimb->receipt_path ?? null); + + return response()->json([ + 'ok' => true, + 'reimbursement' => new ReimbursementResource($payload), + 'users' => ReimbursementRecipientResource::collection($data['users'] ?? []), + 'receipt_url' => $payload['receipt_url'], + ]); + } + public function sendBatchEmail(ReimbursementBatchEmailRequest $request): JsonResponse { $payload = $request->validated(); diff --git a/app/Http/Controllers/Api/Inventory/InventoryMovementController.php b/app/Http/Controllers/Api/Inventory/InventoryMovementController.php index ec77787c..2eb7ba76 100644 --- a/app/Http/Controllers/Api/Inventory/InventoryMovementController.php +++ b/app/Http/Controllers/Api/Inventory/InventoryMovementController.php @@ -9,6 +9,7 @@ use App\Http\Requests\Inventory\InventoryMovementStoreRequest; use App\Http\Requests\Inventory\InventoryMovementUpdateRequest; use App\Http\Resources\Inventory\InventoryMovementResource; use App\Services\Inventory\InventoryMovementService; +use Illuminate\Support\Facades\DB; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -23,9 +24,65 @@ class InventoryMovementController extends BaseApiController { $rows = $this->movements->list($request->validated()); - return $this->success([ + return $this->success(array_merge([ 'movements' => InventoryMovementResource::collection($rows), - ]); + ], $this->movementFormPayload())); + } + + public function editForm(int $id): JsonResponse + { + $movement = collect($this->movements->list(['include_voided' => 1])) + ->first(fn ($row) => (int) ($row['id'] ?? 0) === $id); + + if (! $movement) { + return $this->error('Movement not found.', Response::HTTP_NOT_FOUND); + } + + return $this->success(array_merge($this->movementFormPayload(), [ + 'movement' => new InventoryMovementResource($movement), + ])); + } + + private function movementFormPayload(): array + { + $items = DB::table('inventory_items') + ->select('id', 'name', 'type', 'quantity') + ->orderBy('name') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + $teachers = DB::table('users') + ->select('id', 'firstname', 'lastname', 'email') + ->orderBy('firstname') + ->orderBy('lastname') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + $students = DB::table('students') + ->select('id', 'firstname', 'lastname') + ->orderBy('firstname') + ->orderBy('lastname') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + $classSections = DB::table('classSection') + ->select('class_section_id as id', 'class_section_id', 'class_section_name') + ->orderBy('class_section_name') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + return [ + 'items' => $items, + 'teachers' => $teachers, + 'students' => $students, + 'classSections' => $classSections, + 'movementTypes' => ['initial', 'in', 'out', 'adjust', 'distribution'], + 'semesters' => ['Fall', 'Spring'], + ]; } public function store(InventoryMovementStoreRequest $request): JsonResponse diff --git a/app/Http/Controllers/Api/Messaging/WhatsappController.php b/app/Http/Controllers/Api/Messaging/WhatsappController.php index 0a225c8d..798fc501 100644 --- a/app/Http/Controllers/Api/Messaging/WhatsappController.php +++ b/app/Http/Controllers/Api/Messaging/WhatsappController.php @@ -37,11 +37,67 @@ class WhatsappController extends BaseApiController public function index(WhatsappLinkIndexRequest $request): JsonResponse { - $links = $this->linkService->paginate($request->validated()); + $payload = $request->validated(); + $schoolYear = $this->context->schoolYear($payload['school_year'] ?? null); + $semester = array_key_exists('semester', $payload) + ? (string) ($payload['semester'] ?? '') + : $this->context->semester(); + + $payload['school_year'] = $schoolYear; + $payload['semester'] = $semester; + $payload['per_page'] = max((int) ($payload['per_page'] ?? 200), 200); + + $links = $this->linkService->paginate($payload); $collection = new WhatsappGroupLinkCollection($links); + $linkRows = $collection->toArray($request); + + $sectionsRaw = $this->contactService->listParentContactsByClass($schoolYear, $semester); + $sections = WhatsappClassSectionContactResource::collection($sectionsRaw)->resolve($request); + + $linksBySection = []; + foreach ($linkRows as $link) { + $sid = (int) ($link['class_section_id'] ?? 0); + if ($sid > 0) { + $linksBySection[(string) $sid] = $link; + } + } + + $parents = []; + foreach ($sections as $section) { + foreach (($section['parents'] ?? []) as $parent) { + $primaryId = (int) ($parent['primary_id'] ?? 0); + if ($primaryId > 0 && ! isset($parents['primary:'.$primaryId])) { + [$lastname, $firstname] = $this->splitDisplayName((string) ($parent['primary_name'] ?? '')); + $parents['primary:'.$primaryId] = [ + 'id' => $primaryId, + 'firstname' => $firstname, + 'lastname' => $lastname, + 'email' => (string) ($parent['primary_email'] ?? ''), + 'source' => 'primary', + ]; + } + + $secondId = (int) ($parent['second_id'] ?? 0); + if ($secondId > 0 && ! isset($parents['second:'.$secondId])) { + [$lastname, $firstname] = $this->splitDisplayName((string) ($parent['second_name'] ?? '')); + $parents['second:'.$secondId] = [ + 'id' => $secondId, + 'firstname' => $firstname, + 'lastname' => $lastname, + 'email' => (string) ($parent['second_email'] ?? ''), + 'source' => 'parents', + ]; + } + } + } return $this->success([ - 'links' => $collection->toArray($request), + 'schoolYear' => $schoolYear, + 'semester' => $semester, + 'links' => $linkRows, + 'linksBySection' => $linksBySection, + 'sections' => $sections, + 'parents' => array_values($parents), 'meta' => $collection->with($request)['meta'], ]); } @@ -93,6 +149,8 @@ class WhatsappController extends BaseApiController $contacts = $this->contactService->listParentContacts($schoolYear, $semester); return $this->success([ + 'schoolYear' => $schoolYear, + 'semester' => $semester, 'contacts' => WhatsappParentContactResource::collection($contacts), ]); } @@ -160,6 +218,21 @@ class WhatsappController extends BaseApiController ], 'Membership saved.'); } + private function splitDisplayName(string $name): array + { + $name = trim($name); + if ($name === '') { + return ['', '']; + } + + if (str_contains($name, ',')) { + [$last, $first] = array_pad(array_map('trim', explode(',', $name, 2)), 2, ''); + return [$last, $first]; + } + + return [$name, '']; + } + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse { $userId = (int) (auth()->id() ?? 0); diff --git a/app/Http/Controllers/Api/Parents/ParentController.php b/app/Http/Controllers/Api/Parents/ParentController.php index 4ff4d761..77e45e0b 100644 --- a/app/Http/Controllers/Api/Parents/ParentController.php +++ b/app/Http/Controllers/Api/Parents/ParentController.php @@ -13,17 +13,23 @@ use App\Http\Requests\Parents\ParentProfileUpdateRequest; use App\Http\Requests\Parents\ParentRegistrationRequest; use App\Http\Requests\Parents\ParentStudentUpdateRequest; use App\Http\Resources\Invoices\InvoiceResource; +use App\Http\Resources\ClassProgress\ClassProgressShowResource; use App\Http\Resources\Parents\ParentAttendanceResource; use App\Http\Resources\Parents\ParentEmergencyContactResource; use App\Http\Resources\Parents\ParentStudentResource; +use App\Models\ClassProgressReport; use App\Services\Parents\ParentAttendanceService; use App\Services\Parents\ParentEmergencyContactService; use App\Services\Parents\ParentEnrollmentService; use App\Services\Parents\ParentEventParticipationService; +use App\Services\Parents\ParentProgressQueryService; use App\Services\Parents\ParentInvoiceService; use App\Services\Parents\ParentProfileService; use App\Services\Parents\ParentRegistrationService; +use App\Services\SchoolYears\SchoolYearContextService; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; class ParentController extends BaseApiController { @@ -34,7 +40,9 @@ class ParentController extends BaseApiController private ParentRegistrationService $registrationService, private ParentEmergencyContactService $emergencyContactService, private ParentProfileService $profileService, - private ParentEventParticipationService $eventService + private ParentEventParticipationService $eventService, + private ParentProgressQueryService $progressService, + private SchoolYearContextService $schoolYears ) { parent::__construct(); } @@ -92,6 +100,62 @@ class ParentController extends BaseApiController ]); } + public function progress(Request $request): JsonResponse + { + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $schoolYear = $this->schoolYears->currentSchoolYear($request->query('school_year')); + $semester = $request->query->has('semester') + ? $this->schoolYears->currentSemester($request->query('semester')) + : null; + $sectionIds = $this->progressService->parentSectionIds($guard); + $reports = $this->progressService->reportsForSections($sectionIds, $schoolYear, $semester); + $groups = array_values($this->progressService->groupReportsByWeek($reports)); + + return response()->json([ + 'ok' => true, + 'data' => [ + 'items' => $groups, + 'groups' => $groups, + 'students' => $this->progressService->parentStudents($guard), + 'meta' => $this->schoolYears->options($schoolYear, $semester), + ], + ]); + } + + public function progressDetail(Request $request, ClassProgressReport $class_progress): JsonResponse + { + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + if (Gate::denies('view', $class_progress)) { + return response()->json(['ok' => false, 'status' => false, 'message' => 'Forbidden.'], 403); + } + + $weekStart = $class_progress->week_start?->format('Y-m-d') ?? ''; + $weeklyReports = $this->progressService->weeklyReportsForSectionWeek( + (int) $class_progress->class_section_id, + $weekStart + ); + + $data = (new ClassProgressShowResource([ + 'report' => $class_progress, + 'weekly_reports' => $weeklyReports, + 'status_options' => config('progress.status_options', []), + ]))->toArray($request); + + return response()->json([ + 'ok' => true, + 'status' => true, + 'data' => $data, + ]); + } + public function updateEnrollments(ParentEnrollmentActionRequest $request): JsonResponse { $guard = $this->parentIdOrUnauthorized(); diff --git a/app/Http/Controllers/Api/Reports/ReportCardsController.php b/app/Http/Controllers/Api/Reports/ReportCardsController.php index d9145948..d732a757 100644 --- a/app/Http/Controllers/Api/Reports/ReportCardsController.php +++ b/app/Http/Controllers/Api/Reports/ReportCardsController.php @@ -11,6 +11,8 @@ use App\Http\Resources\Reports\ReportCards\ReportCardClassSectionResource; use App\Http\Resources\Reports\ReportCards\ReportCardCompletenessStudentResource; use App\Http\Resources\Reports\ReportCards\ReportCardStudentMetaResource; use App\Models\Configuration; +use App\Models\User; +use App\Services\Parents\ParentReportCardService; use App\Services\Reports\ReportCards\ReportCardService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -20,8 +22,10 @@ use Symfony\Component\HttpFoundation\Response; class ReportCardsController extends BaseApiController { - public function __construct(private ReportCardService $service) - { + public function __construct( + private ReportCardService $service, + private ParentReportCardService $parentReportCards, + ) { parent::__construct(); } @@ -31,7 +35,8 @@ class ReportCardsController extends BaseApiController $result = $this->service->meta( $payload['school_year'] ?? null, - $payload['semester'] ?? null + $payload['semester'] ?? null, + $this->parentScopeId($request) ); return $this->success([ @@ -88,6 +93,10 @@ class ReportCardsController extends BaseApiController $validated = $validator->validated(); $studentId = (int) ($validated['student_id'] ?? 0); + if (! $this->parentCanAccessStudent($request, $studentId)) { + return $this->error('Forbidden.', Response::HTTP_FORBIDDEN); + } + $schoolYear = $this->resolveSchoolYear($validated['school_year'] ?? null); $semester = $this->resolveSemester($validated['semester'] ?? null); @@ -98,6 +107,10 @@ class ReportCardsController extends BaseApiController public function studentReport(ReportCardPdfRequest $request, int $studentId) { + if (! $this->parentCanAccessStudent($request, $studentId)) { + return $this->error('Forbidden.', Response::HTTP_FORBIDDEN); + } + try { $result = $this->service->generateSingleReport($studentId, $request->validated()); } catch (\Throwable $e) { @@ -122,6 +135,10 @@ class ReportCardsController extends BaseApiController public function classReport(ReportCardPdfRequest $request, int $classSectionId) { + if ($this->parentScopeId($request) !== null) { + return $this->error('Forbidden.', Response::HTTP_FORBIDDEN); + } + try { $result = $this->service->generateClassReport($classSectionId, $request->validated()); } catch (\Throwable $e) { @@ -163,4 +180,45 @@ class ReportCardsController extends BaseApiController return trim((string) (Configuration::getConfig('semester') ?? '')); } + + private function parentCanAccessStudent(Request $request, int $studentId): bool + { + $parentId = $this->parentScopeId($request); + if ($parentId === null) { + return true; + } + + return $this->parentReportCards->studentBelongsToPrimaryParent($studentId, $parentId); + } + + private function parentScopeId(Request $request): ?int + { + /** @var User|null $user */ + $user = $request->user(); + if (! $this->isParentFamilyUser($user)) { + return null; + } + + $parentId = $this->parentReportCards->resolvePrimaryParentUserId($user); + + return $parentId !== null && $parentId > 0 ? $parentId : null; + } + + private function isParentFamilyUser(?User $user): bool + { + if (! $user) { + return false; + } + + $roles = $user->roles() + ->pluck('roles.name') + ->map(fn ($name) => strtolower((string) $name)) + ->toArray(); + + if (in_array('parent', $roles, true)) { + return true; + } + + return in_array(strtolower(trim((string) ($user->user_type ?? ''))), ['secondary', 'tertiary'], true); + } } diff --git a/app/Http/Controllers/Api/Reports/StickersController.php b/app/Http/Controllers/Api/Reports/StickersController.php index 8cfa11d2..c749395f 100644 --- a/app/Http/Controllers/Api/Reports/StickersController.php +++ b/app/Http/Controllers/Api/Reports/StickersController.php @@ -59,6 +59,37 @@ class StickersController extends BaseApiController ]); } + public function preview(StickerFormDataRequest $request): JsonResponse + { + $payload = $request->validated(); + $schoolYear = $this->queryService->resolveSchoolYear($payload['school_year'] ?? null); + $classSectionId = (int) ($payload['class_section_id'] ?? 0); + + $students = $classSectionId > 0 + ? $this->queryService->listStudentsByClass($classSectionId, $schoolYear) + : $this->queryService->listStudentsForYear($schoolYear); + + $rows = []; + foreach ($students as $student) { + $id = (int) ($student['id'] ?? $student['student_id'] ?? 0); + if ($id <= 0) { + continue; + } + $rows[] = [ + 'student_id' => $id, + 'primary_count' => 1, + ]; + } + + return $this->success([ + 'students' => $rows, + 'totals' => [ + 'students' => count($rows), + 'stickers' => count($rows), + ], + ]); + } + public function print(StickerPrintRequest $request) { try { diff --git a/app/Http/Controllers/Api/SchoolYears/SchoolYearController.php b/app/Http/Controllers/Api/SchoolYears/SchoolYearController.php index 17524a98..34aad5d7 100644 --- a/app/Http/Controllers/Api/SchoolYears/SchoolYearController.php +++ b/app/Http/Controllers/Api/SchoolYears/SchoolYearController.php @@ -131,4 +131,12 @@ class SchoolYearController extends BaseApiController 'data' => $this->service->promotionPreview($schoolYear, $request->query('to_school_year')), ]); } + + public function closingReport(int $schoolYear): JsonResponse + { + return response()->json([ + 'ok' => true, + 'data' => $this->service->closingReport($schoolYear), + ]); + } } diff --git a/app/Http/Controllers/Api/Settings/SchoolCalendarController.php b/app/Http/Controllers/Api/Settings/SchoolCalendarController.php index 53eccb0a..7c0dc304 100644 --- a/app/Http/Controllers/Api/Settings/SchoolCalendarController.php +++ b/app/Http/Controllers/Api/Settings/SchoolCalendarController.php @@ -43,7 +43,12 @@ class SchoolCalendarController extends BaseApiController public function index(SchoolCalendarIndexRequest $request): JsonResponse { - return $this->respondWithEvents($request->validated()); + $filters = $request->validated(); + if (! $this->canListEventsForAudience($filters['audience'] ?? null)) { + return $this->error('Forbidden.', Response::HTTP_FORBIDDEN); + } + + return $this->respondWithEvents($filters); } /** @@ -52,6 +57,10 @@ class SchoolCalendarController extends BaseApiController */ public function teacherCalendarLegacy(SchoolCalendarIndexRequest $request): JsonResponse { + if (! $this->canListEventsForAudience('teacher')) { + return $this->error('Forbidden.', Response::HTTP_FORBIDDEN); + } + return $this->respondWithEvents([ ...$request->validated(), 'audience' => 'teacher', @@ -187,4 +196,56 @@ class SchoolCalendarController extends BaseApiController return $userId; } + + private function canListEventsForAudience(?string $audience): bool + { + if ($this->hasAnyRole(['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'])) { + return true; + } + + $audience = strtolower(trim((string) $audience)); + if ($audience === 'parent') { + return $this->hasAnyRole(['parent']) || $this->hasUserType(['secondary', 'tertiary']); + } + + if ($audience === 'teacher') { + return $this->hasAnyRole(['teacher']); + } + + return false; + } + + /** + * @param list $roles + */ + private function hasAnyRole(array $roles): bool + { + $user = auth()->user(); + if (! $user) { + return false; + } + + $actual = $user->roles() + ->pluck('roles.name') + ->map(fn ($name) => strtolower((string) $name)) + ->toArray(); + + foreach ($roles as $role) { + if (in_array(strtolower($role), $actual, true)) { + return true; + } + } + + return false; + } + + /** + * @param list $types + */ + private function hasUserType(array $types): bool + { + $type = strtolower(trim((string) (auth()->user()?->user_type ?? ''))); + + return in_array($type, array_map('strtolower', $types), true); + } } diff --git a/app/Http/Controllers/Api/Staff/StaffController.php b/app/Http/Controllers/Api/Staff/StaffController.php index 38e53478..548e6f44 100644 --- a/app/Http/Controllers/Api/Staff/StaffController.php +++ b/app/Http/Controllers/Api/Staff/StaffController.php @@ -8,6 +8,7 @@ use App\Http\Requests\Staff\StaffStoreRequest; use App\Http\Requests\Staff\StaffUpdateRequest; use App\Http\Resources\Staff\StaffCollection; use App\Http\Resources\Staff\StaffResource; +use App\Models\Role; use App\Models\Staff; use App\Models\User; use App\Services\Staff\StaffCommandService; @@ -45,6 +46,24 @@ class StaffController extends BaseApiController ]); } + public function formOptions(): JsonResponse + { + $roles = Role::query() + ->select('id', 'name') + ->orderBy('priority') + ->orderBy('name') + ->get() + ->map(fn (Role $role) => [ + 'id' => (int) $role->id, + 'name' => (string) $role->name, + ]) + ->all(); + + return $this->success([ + 'roles' => $roles, + ]); + } + public function show(int $id): JsonResponse { $staff = $this->queryService->find($id); diff --git a/app/Http/Middleware/ApiDocsAuth.php b/app/Http/Middleware/ApiDocsAuth.php index 14ee1c02..26a848df 100644 --- a/app/Http/Middleware/ApiDocsAuth.php +++ b/app/Http/Middleware/ApiDocsAuth.php @@ -38,6 +38,10 @@ class ApiDocsAuth return $this->deny('Unauthorized access to documentation.', 401); } + if ($response = $this->denyUnavailableAccount($user)) { + return $response; + } + if (! $this->userIsAdmin((int) $user->id)) { return $this->deny('Admin privileges required.', 403); } @@ -55,6 +59,19 @@ class ApiDocsAuth ->exists(); } + private function denyUnavailableAccount(object $user): ?Response + { + $status = strtolower(trim((string) ($user->status ?? ''))); + $isVerified = filter_var($user->is_verified ?? false, FILTER_VALIDATE_BOOLEAN); + $isSuspended = filter_var($user->is_suspended ?? false, FILTER_VALIDATE_BOOLEAN); + + if (! $isVerified || $status !== 'active' || $isSuspended) { + return response()->json(['message' => 'Account unavailable.'], 403); + } + + return null; + } + private function deny(string $message, int $status): Response { return response()->json([ diff --git a/app/Http/Middleware/ApiJwtAuth.php b/app/Http/Middleware/ApiJwtAuth.php index afcfaa48..5437c10a 100644 --- a/app/Http/Middleware/ApiJwtAuth.php +++ b/app/Http/Middleware/ApiJwtAuth.php @@ -20,10 +20,27 @@ class ApiJwtAuth } Auth::setUser($user); + + if ($response = $this->denyUnavailableAccount($user)) { + return $response; + } } catch (JWTException $e) { return response()->json(['status' => false, 'message' => 'Unauthorized.'], 401); } return $next($request); } + + private function denyUnavailableAccount(object $user): ?Response + { + $status = strtolower(trim((string) ($user->status ?? ''))); + $isVerified = filter_var($user->is_verified ?? false, FILTER_VALIDATE_BOOLEAN); + $isSuspended = filter_var($user->is_suspended ?? false, FILTER_VALIDATE_BOOLEAN); + + if (! $isVerified || $status !== 'active' || $isSuspended) { + return response()->json(['message' => 'Account unavailable.'], 403); + } + + return null; + } } diff --git a/app/Http/Middleware/EnsurePrintRequestsAdminAccess.php b/app/Http/Middleware/EnsurePrintRequestsAdminAccess.php index a5a9e057..467089e5 100644 --- a/app/Http/Middleware/EnsurePrintRequestsAdminAccess.php +++ b/app/Http/Middleware/EnsurePrintRequestsAdminAccess.php @@ -22,15 +22,6 @@ class EnsurePrintRequestsAdminAccess return response()->json(['message' => 'Unauthorized.'], 401); } - if (app()->runningUnitTests()) { - try { - if (Auth::guard('sanctum')->user()) { - return $next($request); - } - } catch (\InvalidArgumentException) { - } - } - $roles = $user->roles() ->pluck('roles.name') ->map(fn ($name) => strtolower((string) $name)) diff --git a/app/Http/Middleware/MultiAuth.php b/app/Http/Middleware/MultiAuth.php index 5faad86e..832de176 100644 --- a/app/Http/Middleware/MultiAuth.php +++ b/app/Http/Middleware/MultiAuth.php @@ -22,6 +22,10 @@ class MultiAuth if ($sanctumUser) { Auth::setUser($sanctumUser); + if ($response = $this->denyUnavailableAccount($sanctumUser)) { + return $response; + } + return $next($request); } @@ -29,6 +33,10 @@ class MultiAuth if ($apiUser = Auth::guard('api')->user()) { Auth::setUser($apiUser); + if ($response = $this->denyUnavailableAccount($apiUser)) { + return $response; + } + return $next($request); } } catch (\Throwable) { @@ -41,6 +49,10 @@ class MultiAuth if ($jwtUser) { Auth::setUser($jwtUser); + if ($response = $this->denyUnavailableAccount($jwtUser)) { + return $response; + } + return $next($request); } } catch (JWTException $e) { @@ -49,4 +61,17 @@ class MultiAuth return response()->json(['message' => 'Unauthorized.'], 401); } + + private function denyUnavailableAccount(object $user): ?Response + { + $status = strtolower(trim((string) ($user->status ?? ''))); + $isVerified = filter_var($user->is_verified ?? false, FILTER_VALIDATE_BOOLEAN); + $isSuspended = filter_var($user->is_suspended ?? false, FILTER_VALIDATE_BOOLEAN); + + if (! $isVerified || $status !== 'active' || $isSuspended) { + return response()->json(['message' => 'Account unavailable.'], 403); + } + + return null; + } } diff --git a/app/Http/Middleware/RequirePermission.php b/app/Http/Middleware/RequirePermission.php index b78d9f0a..38a8a06c 100644 --- a/app/Http/Middleware/RequirePermission.php +++ b/app/Http/Middleware/RequirePermission.php @@ -18,6 +18,15 @@ class RequirePermission return response()->json(['status' => false, 'message' => 'Unauthorized.'], 401); } + $user = $request->user(); + $status = strtolower(trim((string) ($user->status ?? ''))); + $isVerified = filter_var($user->is_verified ?? false, FILTER_VALIDATE_BOOLEAN); + $isSuspended = filter_var($user->is_suspended ?? false, FILTER_VALIDATE_BOOLEAN); + + if (! $isVerified || $status !== 'active' || $isSuspended) { + return response()->json(['message' => 'Account unavailable.'], 403); + } + if (! $this->permissions->hasPermission($userId, $permission)) { if ($request->expectsJson()) { return response()->json(['status' => false, 'message' => 'Access denied.'], 403); diff --git a/app/Http/Middleware/TeacherPortalAuthenticate.php b/app/Http/Middleware/TeacherPortalAuthenticate.php index 8974f9a0..577d22a2 100644 --- a/app/Http/Middleware/TeacherPortalAuthenticate.php +++ b/app/Http/Middleware/TeacherPortalAuthenticate.php @@ -17,7 +17,12 @@ class TeacherPortalAuthenticate public function handle(Request $request, Closure $next): Response { if (Auth::guard('web')->check()) { - Auth::setUser(Auth::guard('web')->user()); + $user = Auth::guard('web')->user(); + Auth::setUser($user); + + if ($response = $this->denyUnavailableAccount($user)) { + return $response; + } return $next($request); } @@ -30,6 +35,10 @@ class TeacherPortalAuthenticate if ($sanctumUser) { Auth::setUser($sanctumUser); + if ($response = $this->denyUnavailableAccount($sanctumUser)) { + return $response; + } + return $next($request); } @@ -40,6 +49,10 @@ class TeacherPortalAuthenticate if ($jwtUser) { Auth::setUser($jwtUser); + if ($response = $this->denyUnavailableAccount($jwtUser)) { + return $response; + } + return $next($request); } } catch (JWTException $e) { @@ -49,4 +62,17 @@ class TeacherPortalAuthenticate return response()->json(['message' => 'Unauthorized.'], 401); } + + private function denyUnavailableAccount(object $user): ?Response + { + $status = strtolower(trim((string) ($user->status ?? ''))); + $isVerified = filter_var($user->is_verified ?? false, FILTER_VALIDATE_BOOLEAN); + $isSuspended = filter_var($user->is_suspended ?? false, FILTER_VALIDATE_BOOLEAN); + + if (! $isVerified || $status !== 'active' || $isSuspended) { + return response()->json(['message' => 'Account unavailable.'], 403); + } + + return null; + } } diff --git a/app/Http/Requests/ClassProgress/ClassProgressIndexRequest.php b/app/Http/Requests/ClassProgress/ClassProgressIndexRequest.php index fd6ac3e0..946b3c55 100644 --- a/app/Http/Requests/ClassProgress/ClassProgressIndexRequest.php +++ b/app/Http/Requests/ClassProgress/ClassProgressIndexRequest.php @@ -16,6 +16,7 @@ class ClassProgressIndexRequest extends ClassProgressFormRequest return [ 'class_section_id' => ['nullable', 'integer', 'min:1'], 'teacher_id' => ['nullable', 'integer', 'min:1'], + 'school_year_id' => ['nullable', 'integer', 'min:1'], 'school_year' => ['nullable', 'string', 'max:20'], 'semester' => ['nullable', 'string', 'max:20'], 'week_start' => ['nullable', 'date_format:Y-m-d'], diff --git a/app/Http/Requests/Families/FamilyAdminCardRequest.php b/app/Http/Requests/Families/FamilyAdminCardRequest.php index a9582988..4afee6de 100644 --- a/app/Http/Requests/Families/FamilyAdminCardRequest.php +++ b/app/Http/Requests/Families/FamilyAdminCardRequest.php @@ -16,6 +16,7 @@ class FamilyAdminCardRequest extends ApiFormRequest return [ 'student_id' => ['nullable', 'integer', 'min:1'], 'guardian_id' => ['nullable', 'integer', 'min:1'], + 'school_year' => ['nullable', 'string', 'max:20'], 'family_id' => ['nullable', 'integer', 'min:1'], ]; } diff --git a/app/Http/Requests/Families/FamilyAdminIndexRequest.php b/app/Http/Requests/Families/FamilyAdminIndexRequest.php index 442051e6..250ee9ed 100644 --- a/app/Http/Requests/Families/FamilyAdminIndexRequest.php +++ b/app/Http/Requests/Families/FamilyAdminIndexRequest.php @@ -16,6 +16,7 @@ class FamilyAdminIndexRequest extends ApiFormRequest return [ 'student_id' => ['nullable', 'integer', 'min:1'], 'guardian_id' => ['nullable', 'integer', 'min:1'], + 'school_year' => ['nullable', 'string', 'max:20'], ]; } } diff --git a/app/Http/Requests/Parents/ParentAttendanceRequest.php b/app/Http/Requests/Parents/ParentAttendanceRequest.php index f12c2da1..41efe5ec 100644 --- a/app/Http/Requests/Parents/ParentAttendanceRequest.php +++ b/app/Http/Requests/Parents/ParentAttendanceRequest.php @@ -10,6 +10,7 @@ class ParentAttendanceRequest extends ApiFormRequest { return [ 'school_year' => ['nullable', 'string', 'max:20'], + 'school_year_id' => ['nullable', 'integer'], ]; } } diff --git a/app/Http/Resources/Admin/Progress/ProgressGroupResource.php b/app/Http/Resources/Admin/Progress/ProgressGroupResource.php index 8b1c25d9..dd5f94ec 100644 --- a/app/Http/Resources/Admin/Progress/ProgressGroupResource.php +++ b/app/Http/Resources/Admin/Progress/ProgressGroupResource.php @@ -12,11 +12,19 @@ class ProgressGroupResource extends JsonResource $reports = $this['reports'] ?? []; return [ + 'id' => $this['id'] ?? $this['report_id'] ?? null, + 'report_id' => $this['report_id'] ?? $this['id'] ?? null, + 'view_url' => $this['view_url'] ?? null, + 'parent_view_url' => $this['parent_view_url'] ?? null, 'week_start' => $this['week_start'] ?? null, 'week_end' => $this['week_end'] ?? null, 'class_section_id' => $this['class_section_id'] ?? null, 'class_section_name' => $this['class_section_name'] ?? '', 'reports' => $reports, // keyed by subject for compatibility + 'weekly_reports' => $this['weekly_reports'] ?? [], + 'reports_array' => $this['reports_array'] ?? [], + 'report_list' => $this['report_list'] ?? [], + 'subjects' => $this['subjects'] ?? array_keys($reports), ]; } } diff --git a/app/Http/Resources/ClassProgress/ClassProgressGroupCollection.php b/app/Http/Resources/ClassProgress/ClassProgressGroupCollection.php index 8ba9c2df..5e2b4862 100644 --- a/app/Http/Resources/ClassProgress/ClassProgressGroupCollection.php +++ b/app/Http/Resources/ClassProgress/ClassProgressGroupCollection.php @@ -26,20 +26,37 @@ class ClassProgressGroupCollection extends ResourceCollection $key = $sectionId.'|'.$weekStart; if (! isset($groups[$key])) { + $reportId = (int) $report->id; $groups[$key] = [ + 'id' => $reportId, + 'report_id' => $reportId, + 'view_url' => url('/api/v1/class-progress/'.$reportId), + 'parent_view_url' => url('/api/v1/parents/progress/'.$reportId), 'week_start' => $weekStart, 'week_end' => $report->week_end?->format('Y-m-d'), 'class_section_id' => $sectionId, 'class_section_name' => $report->classSection?->class_section_name ?? '', 'reports' => [], + 'weekly_reports' => [], + 'reports_array' => [], + 'report_list' => [], + 'subjects' => [], ]; } - $groups[$key]['reports'][$report->subject] = (new ClassProgressReportResource($report))->toArray(request()); + $reportArray = (new ClassProgressReportResource($report))->toArray(request()); + $groups[$key]['reports'][$report->subject] = $reportArray; + $groups[$key]['weekly_reports'][] = $reportArray; + $groups[$key]['reports_array'][] = $reportArray; + $groups[$key]['report_list'][] = $reportArray; + $groups[$key]['subjects'][] = $report->subject; } + $items = ProgressGroupResource::collection(collect(array_values($groups))); + return [ - 'items' => ProgressGroupResource::collection(collect(array_values($groups))), + 'items' => $items, + 'groups' => $items, 'pagination' => [ 'current_page' => $paginator->currentPage(), 'per_page' => $paginator->perPage(), diff --git a/app/Http/Resources/ClassProgress/ClassProgressShowResource.php b/app/Http/Resources/ClassProgress/ClassProgressShowResource.php index ae42438d..81a0588c 100644 --- a/app/Http/Resources/ClassProgress/ClassProgressShowResource.php +++ b/app/Http/Resources/ClassProgress/ClassProgressShowResource.php @@ -10,10 +10,59 @@ class ClassProgressShowResource extends JsonResource public function toArray(Request $request): array { $weekly = $this['weekly_reports'] ?? []; + $report = $this['report']; + $reportId = (int) ($report->id ?? 0); + $weeklyCollection = collect($weekly); + $matchedReport = $weeklyCollection->first(fn ($weeklyReport) => (int) ($weeklyReport->id ?? 0) === $reportId); + if ($matchedReport) { + $report = $matchedReport; + } + + $weekStart = $report->week_start?->format('Y-m-d') ?? ''; + $weekEnd = $report->week_end?->format('Y-m-d'); + $sectionId = (int) ($report->class_section_id ?? 0); + $sectionName = $report->class_section_name ?? $report->classSection?->class_section_name ?? ''; + $weeklyReports = ClassProgressReportResource::collection($weeklyCollection); + $reports = []; + $reportsArray = []; + + foreach ($weekly as $weeklyReport) { + $subject = (string) ($weeklyReport->subject ?? ''); + if ($subject === '') { + continue; + } + + $reportArray = (new ClassProgressReportResource($weeklyReport))->toArray($request); + $reports[$subject] = $reportArray; + $reportsArray[] = $reportArray; + } return [ - 'report' => new ClassProgressReportResource($this['report']), - 'weekly_reports' => ClassProgressReportResource::collection(collect($weekly)), + 'id' => $reportId, + 'report_id' => $reportId, + 'view_url' => url('/api/v1/class-progress/'.$reportId), + 'parent_view_url' => url('/api/v1/parents/progress/'.$reportId), + 'report' => new ClassProgressReportResource($report), + 'weekly_reports' => $weeklyReports, + 'items' => $weeklyReports, + 'reports_array' => $reportsArray, + 'report_list' => $reportsArray, + 'reports' => $reports, + 'group' => [ + 'id' => $reportId, + 'report_id' => $reportId, + 'view_url' => url('/api/v1/class-progress/'.$reportId), + 'parent_view_url' => url('/api/v1/parents/progress/'.$reportId), + 'week_start' => $weekStart, + 'week_end' => $weekEnd, + 'class_section_id' => $sectionId, + 'class_section_name' => $sectionName, + 'reports' => $reports, + 'weekly_reports' => $weeklyReports, + 'reports_array' => $reportsArray, + 'report_list' => $reportsArray, + 'subjects' => array_keys($reports), + ], 'status_options' => $this['status_options'] ?? [], ]; } diff --git a/app/Models/BaseModel.php b/app/Models/BaseModel.php index bcdf9fa0..37635e1d 100644 --- a/app/Models/BaseModel.php +++ b/app/Models/BaseModel.php @@ -3,7 +3,6 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; -use Illuminate\Foundation\Application; class BaseModel extends Model { @@ -11,17 +10,6 @@ class BaseModel extends Model public function getFillable(): array { - if ($this->isTestingEnv()) { - $columns = $this->tableColumns(); - if (! empty($columns)) { - $key = $this->getKeyName(); - - return array_values(array_filter($columns, static function ($col) use ($key) { - return $col !== $key; - })); - } - } - $fillable = parent::getFillable(); if ($this->timestamps) { if (! in_array('created_at', $fillable, true)) { @@ -35,33 +23,6 @@ class BaseModel extends Model return $fillable; } - private function isTestingEnv(): bool - { - $env = getenv('APP_ENV') - ?: ($_SERVER['APP_ENV'] ?? ($_ENV['APP_ENV'] ?? null)); - - if ($env === 'testing') { - return true; - } - - if (defined('PHPUNIT_COMPOSER_INSTALL') || defined('__PHPUNIT_PHAR__')) { - return true; - } - - if (function_exists('app')) { - try { - $app = app(); - if ($app instanceof Application && $app->runningUnitTests()) { - return true; - } - } catch (\Throwable $e) { - // Fallback to env checks when the container isn't booted. - } - } - - return false; - } - private function tableColumns(): array { if (self::$tableColumnsCache === null) { diff --git a/app/Policies/ClassProgressReportPolicy.php b/app/Policies/ClassProgressReportPolicy.php index c762c8e9..2fdb8f4c 100644 --- a/app/Policies/ClassProgressReportPolicy.php +++ b/app/Policies/ClassProgressReportPolicy.php @@ -5,6 +5,8 @@ namespace App\Policies; use App\Models\ClassProgressReport; use App\Models\TeacherClass; use App\Models\User; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; class ClassProgressReportPolicy { @@ -19,7 +21,9 @@ class ClassProgressReportPolicy return true; } - return $this->isAuthor($user, $report) || $this->teachesSection($user, $report); + return $this->isAuthor($user, $report) + || $this->teachesSection($user, $report) + || $this->parentHasEnrolledSection($user, $report); } public function create(User $user): bool @@ -67,4 +71,34 @@ class ClassProgressReportPolicy return false; } + + private function parentHasEnrolledSection(User $user, ClassProgressReport $report): bool + { + if (! Schema::hasTable('enrollments')) { + return false; + } + + $parentId = (int) (request()?->attributes?->get('primary_parent_id') ?? 0); + if ($parentId <= 0) { + if (! $this->isParent($user)) { + return false; + } + + $parentId = (int) $user->id; + } + + return DB::table('enrollments') + ->where('parent_id', $parentId) + ->where('class_section_id', (int) $report->class_section_id) + ->where('is_withdrawn', 0) + ->exists(); + } + + private function isParent(User $user): bool + { + return $user->roles() + ->pluck('roles.name') + ->map(fn ($name) => strtolower((string) $name)) + ->contains('parent'); + } } diff --git a/app/Services/ClassProgress/ClassProgressQueryService.php b/app/Services/ClassProgress/ClassProgressQueryService.php index 24c9ded0..454fc3c5 100644 --- a/app/Services/ClassProgress/ClassProgressQueryService.php +++ b/app/Services/ClassProgress/ClassProgressQueryService.php @@ -9,6 +9,7 @@ use App\Models\TeacherClass; use App\Models\User; use App\Services\SchoolYears\SchoolYearContextService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; class ClassProgressQueryService @@ -29,22 +30,37 @@ class ClassProgressQueryService ->orderBy($filters['sort_by'] ?? 'week_start', $filters['sort_dir'] ?? 'desc'); if (! $this->isAdmin($user)) { - $relaxedSectionIds = TeacherClass::distinctSectionIdsForTeacher((int) $user->id); + if ($this->isParent($user)) { + $parentSectionIds = $this->parentSectionIds((int) $user->id); - if (! empty($filters['class_section_id'])) { - $sid = (int) $filters['class_section_id']; - // Section filter: if this user has ever taught the section, show all rows for that section - // (legacy DB often has `class_progress_reports` without matching current-year `teacher_class`). - if (! in_array($sid, $relaxedSectionIds, true)) { - $query->where('teacher_id', (int) $user->id); + if (! empty($filters['class_section_id'])) { + $sid = (int) $filters['class_section_id']; + if (! in_array($sid, $parentSectionIds, true)) { + $query->whereRaw('1 = 0'); + } + } elseif ($parentSectionIds !== []) { + $query->whereIn('class_progress_reports.class_section_id', $parentSectionIds); + } else { + $query->whereRaw('1 = 0'); } } else { - $query->where(function ($q) use ($user, $relaxedSectionIds) { - $q->where('teacher_id', (int) $user->id); - if ($relaxedSectionIds !== []) { - $q->orWhereIn('class_section_id', $relaxedSectionIds); + $relaxedSectionIds = TeacherClass::distinctSectionIdsForTeacher((int) $user->id); + + if (! empty($filters['class_section_id'])) { + $sid = (int) $filters['class_section_id']; + // Section filter: if this user has ever taught the section, show all rows for that section + // (legacy DB often has `class_progress_reports` without matching current-year `teacher_class`). + if (! in_array($sid, $relaxedSectionIds, true)) { + $query->where('teacher_id', (int) $user->id); } - }); + } else { + $query->where(function ($q) use ($user, $relaxedSectionIds) { + $q->where('teacher_id', (int) $user->id); + if ($relaxedSectionIds !== []) { + $q->orWhereIn('class_section_id', $relaxedSectionIds); + } + }); + } } } elseif (! empty($filters['teacher_id'])) { $query->where('teacher_id', (int) $filters['teacher_id']); @@ -62,12 +78,50 @@ class ClassProgressQueryService $query->where('status', (string) $filters['status']); } - if (! empty($filters['school_year']) && Schema::hasColumn('class_progress_reports', 'school_year')) { - $query->where('school_year', (string) $filters['school_year']); + $schoolYear = $this->resolveSchoolYearFilter($filters); + $schoolYearRange = $this->schoolYearDateRange($schoolYear); + if ($schoolYear !== '' && Schema::hasColumn('class_progress_reports', 'school_year')) { + $query->where(function ($yearQuery) use ($schoolYear, $schoolYearRange) { + $yearQuery->where('class_progress_reports.school_year', $schoolYear) + ->orWhere(function ($legacyQuery) use ($schoolYear, $schoolYearRange) { + $legacyQuery->where(function ($blankQuery) { + $blankQuery->whereNull('class_progress_reports.school_year') + ->orWhere('class_progress_reports.school_year', ''); + }); + + if ($schoolYearRange !== null) { + $legacyQuery->whereBetween('class_progress_reports.week_start', [ + $schoolYearRange['start'], + $schoolYearRange['end'], + ]); + } else { + $legacyQuery->whereExists(function ($sectionQuery) use ($schoolYear) { + $sectionQuery->select(DB::raw(1)) + ->from('classSection as progress_filter_cs') + ->whereColumn('progress_filter_cs.class_section_id', 'class_progress_reports.class_section_id') + ->where('progress_filter_cs.school_year', $schoolYear); + }); + } + }); + }); } if (! empty($filters['semester']) && Schema::hasColumn('class_progress_reports', 'semester')) { - $query->where('semester', (string) $filters['semester']); + $semester = (string) $filters['semester']; + $query->where(function ($semesterQuery) use ($semester) { + $semesterQuery->where('class_progress_reports.semester', $semester) + ->orWhere(function ($legacyQuery) use ($semester) { + $legacyQuery->where(function ($blankQuery) { + $blankQuery->whereNull('class_progress_reports.semester') + ->orWhere('class_progress_reports.semester', ''); + })->whereExists(function ($sectionQuery) use ($semester) { + $sectionQuery->select(DB::raw(1)) + ->from('classSection as progress_filter_cs') + ->whereColumn('progress_filter_cs.class_section_id', 'class_progress_reports.class_section_id') + ->where('progress_filter_cs.semester', $semester); + }); + }); + }); } if (! empty($filters['week_start'])) { @@ -157,6 +211,60 @@ class ClassProgressQueryService return $this->schoolYears->options($schoolYear, $semester); } + private function resolveSchoolYearFilter(array $filters): string + { + $schoolYear = trim((string) ($filters['school_year'] ?? '')); + if ($schoolYear !== '') { + return $schoolYear; + } + + $schoolYearId = (int) ($filters['school_year_id'] ?? 0); + if ($schoolYearId <= 0 || ! Schema::hasTable('school_years')) { + return ''; + } + + try { + return trim((string) DB::table('school_years')->where('id', $schoolYearId)->value('name')); + } catch (\Throwable) { + return ''; + } + } + + /** + * @return array{start:string,end:string}|null + */ + private function schoolYearDateRange(string $schoolYear): ?array + { + $schoolYear = trim($schoolYear); + if ($schoolYear === '') { + return null; + } + + if (Schema::hasTable('school_years')) { + try { + $row = DB::table('school_years') + ->where('name', $schoolYear) + ->first(['start_date', 'end_date']); + if ($row && $row->start_date && $row->end_date) { + return [ + 'start' => substr((string) $row->start_date, 0, 10), + 'end' => substr((string) $row->end_date, 0, 10), + ]; + } + } catch (\Throwable) { + } + } + + if (preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches) === 1) { + return [ + 'start' => $matches[1].'-08-01', + 'end' => $matches[2].'-07-31', + ]; + } + + return null; + } + private function statusLabel(?string $status): string { $options = (array) config('progress.status_options', []); @@ -180,6 +288,35 @@ class ClassProgressQueryService return false; } + private function isParent(User $user): bool + { + return $user->roles() + ->pluck('roles.name') + ->map(fn ($name) => strtolower((string) $name)) + ->contains('parent'); + } + + /** + * @return list + */ + private function parentSectionIds(int $parentId): array + { + if ($parentId <= 0 || ! Schema::hasTable('enrollments')) { + return []; + } + + return DB::table('enrollments') + ->where('parent_id', $parentId) + ->where('is_withdrawn', 0) + ->whereNotNull('class_section_id') + ->distinct() + ->pluck('class_section_id') + ->map(fn ($id) => (int) $id) + ->filter(fn ($id) => $id > 0) + ->values() + ->all(); + } + /** * legacy `ClassProgressController::resolveAssignedTeacherIds`. * diff --git a/app/Services/Parents/ParentAttendanceService.php b/app/Services/Parents/ParentAttendanceService.php index 09cf93f5..52723f9c 100644 --- a/app/Services/Parents/ParentAttendanceService.php +++ b/app/Services/Parents/ParentAttendanceService.php @@ -18,6 +18,7 @@ class ParentAttendanceService ->join('students', 'students.id', '=', 'attendance_data.student_id') ->where('students.parent_id', $parentId) ->where('attendance_data.school_year', $selectedYear) + ->whereRaw("LOWER(TRIM(attendance_data.status)) IN ('absent', 'late')") ->orderBy('attendance_data.date', 'desc') ->get() ->map(fn ($row) => (array) $row) diff --git a/app/Services/Parents/ParentProgressQueryService.php b/app/Services/Parents/ParentProgressQueryService.php index 8773cc86..dd230489 100644 --- a/app/Services/Parents/ParentProgressQueryService.php +++ b/app/Services/Parents/ParentProgressQueryService.php @@ -9,6 +9,7 @@ use App\Services\ClassProgress\ClassProgressQueryService; use Carbon\CarbonInterface; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; /** * legacy {@see ParentProgressController} read-side logic. @@ -101,24 +102,104 @@ class ParentProgressQueryService * * @param list $sectionIds */ - public function reportsForSections(array $sectionIds): Collection + public function reportsForSections(array $sectionIds, ?string $schoolYear = null, ?string $semester = null): Collection { $sectionIds = array_values(array_filter(array_map('intval', $sectionIds))); if ($sectionIds === []) { return collect(); } - return ClassProgressReport::query() + $attachmentMap = []; + $query = ClassProgressReport::query() ->select('class_progress_reports.*') ->addSelect([ 'cs.class_section_name', - DB::raw('CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name'), + 'u.firstname as teacher_firstname', + 'u.lastname as teacher_lastname', ]) ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'class_progress_reports.class_section_id') ->leftJoin('users as u', 'u.id', '=', 'class_progress_reports.teacher_id') - ->whereIn('class_progress_reports.class_section_id', $sectionIds) - ->orderByDesc('class_progress_reports.week_start') - ->get(); + ->whereIn('class_progress_reports.class_section_id', $sectionIds); + + $schoolYear = trim((string) $schoolYear); + if ($schoolYear !== '' && Schema::hasColumn('class_progress_reports', 'school_year')) { + $schoolYearRange = $this->schoolYearDateRange($schoolYear); + $query->where(function ($yearQuery) use ($schoolYear, $schoolYearRange) { + $yearQuery->where('class_progress_reports.school_year', $schoolYear) + ->orWhere(function ($legacyQuery) use ($schoolYear, $schoolYearRange) { + $legacyQuery->where(function ($blankQuery) { + $blankQuery->whereNull('class_progress_reports.school_year') + ->orWhere('class_progress_reports.school_year', ''); + }); + + if ($schoolYearRange !== null) { + $legacyQuery->whereBetween('class_progress_reports.week_start', [ + $schoolYearRange['start'], + $schoolYearRange['end'], + ]); + } else { + $legacyQuery->where('cs.school_year', $schoolYear); + } + }); + }); + } + + $semester = trim((string) $semester); + if ($semester !== '' && Schema::hasColumn('class_progress_reports', 'semester')) { + $query->where(function ($semesterQuery) use ($semester) { + $semesterQuery->where('class_progress_reports.semester', $semester) + ->orWhere(function ($legacyQuery) use ($semester) { + $legacyQuery->where(function ($blankQuery) { + $blankQuery->whereNull('class_progress_reports.semester') + ->orWhere('class_progress_reports.semester', ''); + })->where('cs.semester', $semester); + }); + }); + } + + $rows = $query->orderByDesc('class_progress_reports.week_start')->get(); + $attachmentMap = $this->classProgressQuery->attachmentMap($rows->pluck('id')->all()); + + return $rows + ->each(function (ClassProgressReport $row) use ($attachmentMap) { + $row->setAttribute('teacher_name', trim((string) ($row->teacher_firstname ?? '').' '.(string) ($row->teacher_lastname ?? ''))); + $row->setAttribute('attachments', $attachmentMap[$row->id] ?? []); + }); + } + + /** + * @return array{start:string,end:string}|null + */ + private function schoolYearDateRange(string $schoolYear): ?array + { + $schoolYear = trim($schoolYear); + if ($schoolYear === '') { + return null; + } + + if (Schema::hasTable('school_years')) { + try { + $row = DB::table('school_years') + ->where('name', $schoolYear) + ->first(['start_date', 'end_date']); + if ($row && $row->start_date && $row->end_date) { + return [ + 'start' => substr((string) $row->start_date, 0, 10), + 'end' => substr((string) $row->end_date, 0, 10), + ]; + } + } catch (\Throwable) { + } + } + + if (preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches) === 1) { + return [ + 'start' => $matches[1].'-08-01', + 'end' => $matches[2].'-07-31', + ]; + } + + return null; } /** @@ -146,7 +227,12 @@ class ParentProgressQueryService $key = $weekStart.':'.$sectionId; if (! isset($reportGroups[$key])) { + $reportId = (int) $row->id; $reportGroups[$key] = [ + 'id' => $reportId, + 'report_id' => $reportId, + 'view_url' => url('/api/v1/class-progress/'.$reportId), + 'parent_view_url' => url('/api/v1/parents/progress/'.$reportId), 'week_start' => $weekStart, 'week_end' => $row->week_end instanceof CarbonInterface ? $row->week_end->format('Y-m-d') @@ -154,6 +240,10 @@ class ParentProgressQueryService 'class_section_name' => $row->class_section_name ?? '', 'class_section_id' => $sectionId, 'reports' => [], + 'weekly_reports' => [], + 'reports_array' => [], + 'report_list' => [], + 'subjects' => [], ]; } @@ -162,7 +252,12 @@ class ParentProgressQueryService continue; } - $reportGroups[$key]['reports'][$subject] = (new ClassProgressReportResource($row))->toArray(request()); + $report = (new ClassProgressReportResource($row))->toArray(request()); + $reportGroups[$key]['reports'][$subject] = $report; + $reportGroups[$key]['weekly_reports'][] = $report; + $reportGroups[$key]['reports_array'][] = $report; + $reportGroups[$key]['report_list'][] = $report; + $reportGroups[$key]['subjects'][] = $subject; } return $reportGroups; @@ -175,13 +270,21 @@ class ParentProgressQueryService */ public function weeklyReportsForSectionWeek(int $classSectionId, string $weekStartYmd): array { - return ClassProgressReport::query() + $rows = ClassProgressReport::query() ->with(['classSection', 'teacher']) ->where('class_section_id', $classSectionId) ->whereDate('week_start', $weekStartYmd) ->orderBy('subject') ->get() ->all(); + + $attachments = $this->attachmentMapForReportIds(array_map(fn (ClassProgressReport $row) => (int) $row->id, $rows)); + + foreach ($rows as $row) { + $row->setAttribute('attachments', $attachments[$row->id] ?? []); + } + + return $rows; } /** diff --git a/app/Services/Reports/ReportCards/ReportCardService.php b/app/Services/Reports/ReportCards/ReportCardService.php index d39aa176..120f0432 100644 --- a/app/Services/Reports/ReportCards/ReportCardService.php +++ b/app/Services/Reports/ReportCards/ReportCardService.php @@ -27,10 +27,11 @@ class ReportCardService $this->semester = trim((string) (Configuration::getConfig('semester') ?? '')); } - public function meta(?string $schoolYear, ?string $semester): array + public function meta(?string $schoolYear, ?string $semester, ?int $parentId = null): array { $year = trim((string) ($schoolYear ?? $this->schoolYear)); $sem = trim((string) ($semester ?? $this->semester)); + $parentId = (int) ($parentId ?? 0); if ($year === '') { $yr = DB::table('classSection')->select('school_year')->orderBy('school_year', 'DESC')->first(); @@ -117,6 +118,9 @@ class ReportCardService ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') ->where('s.is_active', 1) ->where('ss.school_year', $year); + if ($parentId > 0) { + $builder->where('s.parent_id', $parentId); + } if ($sem !== '') { $this->applySemesterFilter($builder, $sem, 'ss.semester'); } @@ -141,6 +145,9 @@ class ReportCardService if ($year !== '') { $fallback->where('sc.school_year', $year); } + if ($parentId > 0) { + $fallback->where('students.parent_id', $parentId); + } $students = $fallback ->where('students.is_active', 1) ->groupBy('students.id', 'students.firstname', 'students.lastname') @@ -160,6 +167,9 @@ class ReportCardService ->join('students as s', 's.id', '=', 'sc.student_id') ->where('s.is_active', 1) ->where('sc.school_year', $year); + if ($parentId > 0) { + $builder->where('s.parent_id', $parentId); + } $classSections = $builder ->groupBy('cs.id', 'cs.class_section_id', 'cs.class_section_name', 'cs.school_year') ->orderBy('cs.class_section_name', 'ASC') @@ -174,6 +184,7 @@ class ReportCardService ->join('students as s', 's.id', '=', 'sc.student_id') ->where('s.is_active', 1) ->where('sc.school_year', $year) + ->when($parentId > 0, fn ($query) => $query->where('s.parent_id', $parentId)) ->groupBy('cs.id', 'cs.class_section_id', 'cs.class_section_name', 'cs.school_year') ->orderBy('cs.class_section_name', 'ASC') ->get() @@ -571,6 +582,10 @@ class ReportCardService $data['report_date'] = $reportDate['iso']; $data['report_date_display'] = $reportDate['display']; + if (! $this->ensureFpdfAvailable()) { + return ['ok' => false, 'message' => 'Report card PDF renderer is unavailable.']; + } + $pdf = new \FPDF('P', 'mm', 'Letter'); $this->formatReportPDF($pdf, $data); @@ -613,6 +628,10 @@ class ReportCardService return ['ok' => false, 'message' => 'No students found for this class section.']; } + if (! $this->ensureFpdfAvailable()) { + return ['ok' => false, 'message' => 'Report card PDF renderer is unavailable.']; + } + $pdf = new \FPDF('P', 'mm', 'Letter'); foreach ($scores as $score) { @@ -749,6 +768,22 @@ class ReportCardService ]; } + private function ensureFpdfAvailable(): bool + { + if (class_exists('FPDF', false)) { + return true; + } + + $path = base_path('app/ThirdParty/fpdf/fpdf.php'); + if (! is_file($path)) { + return false; + } + + require_once $path; + + return class_exists('FPDF', false); + } + private function resolveAnchorSunday(): string { $tzName = (string) (config('School')->attendance['timezone'] ?? config('app.timezone')); diff --git a/app/Services/SchoolYears/SchoolYearClosureService.php b/app/Services/SchoolYears/SchoolYearClosureService.php index 505a72c0..6c0e805c 100644 --- a/app/Services/SchoolYears/SchoolYearClosureService.php +++ b/app/Services/SchoolYears/SchoolYearClosureService.php @@ -151,6 +151,61 @@ class SchoolYearClosureService ]; } + public function closingReport(int $id): array + { + $year = $this->findOrFail($id); + $audit = $this->latestClosureAudit((int) $year->id); + $metadata = is_array($audit?->metadata) ? $audit->metadata : []; + + $newYearName = trim((string) ($metadata['new_school_year'] ?? '')); + if ($newYearName === '') { + $newYearName = $this->resolveClosedTargetYearName($year->name); + } + + $newYear = $newYearName !== '' + ? SchoolYear::query()->where('name', $newYearName)->first() + : null; + + $promotionCounts = is_array($metadata['promotion_counts'] ?? null) + ? $metadata['promotion_counts'] + : []; + $balanceCounts = is_array($metadata['balance_counts'] ?? null) + ? $metadata['balance_counts'] + : []; + $transferTotals = $this->transferTotalsForClosedYear($year->name, $newYearName !== '' ? $newYearName : null); + + $warnings = []; + if (! $audit) { + $warnings[] = 'No closure audit log was found for this school year. Totals were reconstructed from current records where possible.'; + } + if ($year->status !== SchoolYear::STATUS_CLOSED) { + $warnings[] = sprintf('School year %s is currently %s, so this is a live preview rather than a final closure report.', $year->name, $year->status); + } + if ($newYearName !== '' && ! $newYear) { + $warnings[] = sprintf('The closure target school year %s was referenced but no matching school_years row was found.', $newYearName); + } + + return [ + 'old_school_year' => $this->presentSchoolYear($year), + 'new_school_year' => $this->presentSchoolYear($newYear), + 'closed_by' => $this->resolveUserName((int) ($year->closed_by ?? $audit?->user_id ?? 0)), + 'closed_at' => optional($year->closed_at ?? $audit?->created_at)->toDateTimeString(), + 'audit_reference' => $audit ? sprintf('audit_logs:%d', (int) $audit->id) : null, + 'totals' => [ + 'students_promoted' => (int) ($promotionCounts['promote'] ?? 0), + 'students_repeated' => (int) ($promotionCounts['repeat'] ?? 0), + 'students_graduated' => (int) ($promotionCounts['graduate'] ?? 0), + 'students_transferred' => (int) ($promotionCounts['transfer'] ?? 0), + 'students_withdrawn' => (int) ($promotionCounts['withdraw'] ?? 0), + 'parents_with_unpaid_balances' => (int) ($balanceCounts['transferred_parents'] ?? $transferTotals['parents_with_unpaid_balances']), + 'total_unpaid_balance_transferred' => round((float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred']), 2), + 'parents_with_credit_balances' => (int) ($balanceCounts['credit_parents'] ?? $transferTotals['parents_with_credit_balances']), + 'total_credit_transferred' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_transferred']), 2), + ], + 'warnings' => $warnings, + ]; + } + public function validateClose(int $id, array $payload): array { $year = $this->findOrFail($id); @@ -1016,6 +1071,103 @@ class SchoolYearClosureService ->all(); } + private function latestClosureAudit(int $schoolYearId): ?AuditLog + { + if (! Schema::hasTable('audit_logs')) { + return null; + } + + return AuditLog::query() + ->where('action', 'school_year_closed') + ->where(function ($query) use ($schoolYearId) { + $query->where('record_id', $schoolYearId) + ->orWhere(function ($nested) use ($schoolYearId) { + $nested->where('table_name', 'school_years') + ->where('record_id', $schoolYearId); + }); + }) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + } + + private function resolveClosedTargetYearName(string $fromSchoolYear): string + { + if (Schema::hasTable('parent_balance_transfers')) { + $target = DB::table('parent_balance_transfers') + ->where('from_school_year', $fromSchoolYear) + ->whereNotNull('to_school_year') + ->orderByDesc('created_at') + ->value('to_school_year'); + + if (is_string($target) && trim($target) !== '') { + return trim($target); + } + } + + return $this->resolver->suggestNextName($fromSchoolYear) ?? ''; + } + + private function transferTotalsForClosedYear(string $fromSchoolYear, ?string $toSchoolYear = null): array + { + $totals = [ + 'parents_with_unpaid_balances' => 0, + 'total_unpaid_balance_transferred' => 0.0, + 'parents_with_credit_balances' => 0, + 'total_credit_transferred' => 0.0, + ]; + + if (! Schema::hasTable('parent_balance_transfers')) { + return $totals; + } + + $rows = DB::table('parent_balance_transfers') + ->where('from_school_year', $fromSchoolYear) + ->when($toSchoolYear !== null && trim($toSchoolYear) !== '', fn ($query) => $query->where('to_school_year', $toSchoolYear)) + ->get(['amount']); + + foreach ($rows as $row) { + $amount = round((float) ($row->amount ?? 0), 2); + if ($amount > 0.01) { + $totals['parents_with_unpaid_balances']++; + $totals['total_unpaid_balance_transferred'] += $amount; + } elseif ($amount < -0.01) { + $totals['parents_with_credit_balances']++; + $totals['total_credit_transferred'] += abs($amount); + } + } + + $totals['total_unpaid_balance_transferred'] = round($totals['total_unpaid_balance_transferred'], 2); + $totals['total_credit_transferred'] = round($totals['total_credit_transferred'], 2); + + return $totals; + } + + private function resolveUserName(int $userId): string|int|null + { + if ($userId <= 0) { + return null; + } + + if (! Schema::hasTable('users')) { + return $userId; + } + + $user = DB::table('users')->where('id', $userId)->first(); + if (! $user) { + return $userId; + } + + $name = trim((string) ($user->firstname ?? '').' '.(string) ($user->lastname ?? '')); + if ($name !== '') { + return $name; + } + + $email = trim((string) ($user->email ?? '')); + + return $email !== '' ? $email : $userId; + } + private function presentSchoolYear(?SchoolYear $year): ?array { if (! $year) { diff --git a/app/Services/Settings/SchoolCalendar/SchoolCalendarMutationService.php b/app/Services/Settings/SchoolCalendar/SchoolCalendarMutationService.php index faed63ea..7e5edfcf 100644 --- a/app/Services/Settings/SchoolCalendar/SchoolCalendarMutationService.php +++ b/app/Services/Settings/SchoolCalendar/SchoolCalendarMutationService.php @@ -36,7 +36,7 @@ class SchoolCalendarMutationService public function update(CalendarEvent $event, array $payload, array $targets = []): array { - $data = $this->normalizePayload($payload); + $data = $this->normalizePayload($payload, true); try { DB::transaction(function () use ($event, $data) { @@ -69,8 +69,41 @@ class SchoolCalendarMutationService } } - private function normalizePayload(array $payload): array + private function normalizePayload(array $payload, bool $partial = false): array { + if ($partial) { + $data = []; + foreach ([ + 'title', + 'description', + 'event_type', + 'date', + 'notify_parent', + 'notify_teacher', + 'notify_admin', + 'no_school', + 'school_year', + 'semester', + ] as $key) { + if (! array_key_exists($key, $payload)) { + continue; + } + + if (in_array($key, ['notify_parent', 'notify_teacher', 'notify_admin', 'no_school'], true)) { + $data[$key] = ! empty($payload[$key]) ? 1 : 0; + continue; + } + + $data[$key] = $payload[$key]; + } + + if (! CalendarEvent::supportsEventType()) { + unset($data['event_type']); + } + + return $data; + } + $data = [ 'title' => (string) ($payload['title'] ?? ''), 'description' => $payload['description'] ?? null, diff --git a/app/Services/Whatsapp/WhatsappContextService.php b/app/Services/Whatsapp/WhatsappContextService.php index 37a8db4d..35f287ef 100644 --- a/app/Services/Whatsapp/WhatsappContextService.php +++ b/app/Services/Whatsapp/WhatsappContextService.php @@ -12,22 +12,38 @@ class WhatsappContextService public function schoolYear(?string $override = null): string { - $override = trim((string) $override); - if ($override !== '') { - return $override; + foreach ([ + $override, + request()->query('school_year'), + request()->header('X-School-Year'), + request()->header('X-Selected-School-Year'), + Configuration::getConfig('school_year'), + ] as $candidate) { + $value = trim((string) ($candidate ?? '')); + if ($value !== '') { + return $value; + } } - return trim((string) (Configuration::getConfig('school_year') ?? '')); + return ''; } public function semester(?string $override = null): string { - $override = trim((string) $override); - if ($override !== '') { - return $override; + foreach ([ + $override, + request()->query('semester'), + request()->header('X-Semester'), + request()->header('X-Selected-Semester'), + Configuration::getConfig('semester'), + ] as $candidate) { + $value = trim((string) ($candidate ?? '')); + if ($value !== '') { + return $value; + } } - return trim((string) (Configuration::getConfig('semester') ?? '')); + return ''; } /** diff --git a/docs/laravel_security_remediation_plan_deep_review.md b/docs/laravel_security_remediation_plan_deep_review.md new file mode 100644 index 00000000..a45095dd --- /dev/null +++ b/docs/laravel_security_remediation_plan_deep_review.md @@ -0,0 +1,2547 @@ +# Laravel Application Security Remediation Plan + +## Purpose + +This plan consolidates the findings from the code, route, Composer, and deep security review. + +The application has a repeated structural flaw: + +```text +Authentication is being treated as authorization. +``` + +A logged-in user is not automatically allowed to manage finance, expenses, student records, attendance, grades, communications, messages, files, reports, badges, users, roles, notifications, WhatsApp contacts, competitions, exam drafts, curriculum, school IDs, or system settings. + +This plan fixes the issue at four levels: + +```text +1. Routes: sensitive routes must have explicit middleware. +2. Requests: sensitive FormRequest classes must authorize properly. +3. Controllers/services: every mutation and sensitive read must enforce ownership/scope. +4. Tests: every sensitive action must have negative authorization tests. +``` + +--- + +# Phase 0: Baseline, Inventory, and Guardrails + +## 0.1 Create a security remediation branch + +```bash +git checkout -b security/deep-access-control-remediation +``` + +## 0.2 Generate route inventory + +```bash +php artisan route:list --json > storage/security-route-list-before.json +php artisan route:list > storage/security-route-list-before.txt +``` + +## 0.3 Build a route authorization matrix + +Create a table for every API and web route. + +Required columns: + +```text +HTTP method +URI +Controller@method +Current middleware +Required middleware +Required permission +Required ownership/scope rule +Mutation? yes/no +PII? yes/no +Student data? yes/no +Finance? yes/no +Academic record? yes/no +File/report output? yes/no +Public? yes/no +Rate limited? yes/no +Test exists? yes/no +``` + +Mandatory route groups to classify: + +```text +auth +scanner +badge_scan +attendance +attendance-tracking +students +parents +families +users +role-permissions +finance +expenses +email +communications +messages +notifications +whatsapp +scores +grading +reports +files +subjects/curriculum +system/school-ids +settings +ip-bans +staff +badges +print-requests +certificates +class-progress +class-prep +incidents +assignments +competitions +exam-drafts +proofread +contact +authorized-user invite/setup +docs/swagger +system health +``` + +## 0.4 Verify middleware registration + +Inspect: + +```text +bootstrap/app.php +app/Http/Kernel.php +``` + +Confirm these middleware aliases exist and do what their names imply: + +```text +multi.auth +auth:api +admin.access +parent.access +permission:* +school_year.editable +scanner.auth +``` + +If any alias is missing, weak, or not registered, fix it before relying on route changes. + +## 0.5 Add failing security tests before fixes + +Add negative tests first. They should fail before remediation and pass after remediation. + +Required test categories: + +```text +Guest cannot mutate protected resources. +Normal authenticated user cannot perform admin/staff/finance actions. +Parent can only access own child data. +Teacher can only access assigned students/classes/sections. +Finance staff can only perform finance actions granted by permission. +Admin can perform administrative actions. +Suspended/inactive/unverified users are blocked even with existing tokens. +Public endpoints are rate-limited and intentionally public. +``` + +## 0.6 Audit all FormRequest authorization + +Run: + +```bash +grep -R "function authorize" app/Http/Requests -n +``` + +Every sensitive request must not blindly return `true`. + +Sensitive requests include: + +```text +finance mutations +expense mutations +student data access +grade/report-card mutations +attendance mutations +exam draft admin review/upload +competition management +user/role/permission mutations +email/communication/message/notification/WhatsApp sends +file uploads/downloads +badge/report/certificate generation +system settings +school ID assignment +public invite/password setup flows +``` + +Acceptable patterns: + +```text +The request authorize() checks a policy/gate/permission. +OR route/controller/service authorization is explicit and documented. +``` + +## 0.7 Remove test-only security bypasses + +Search for test-specific behavior: + +```bash +grep -R "runningUnitTests" app routes -n +``` + +Rules: + +```text +Routes must not weaken auth during tests. +Models must not loosen mass-assignment rules during tests. +Tests must authenticate real users with real roles/permissions. +``` + +Special attention: + +```text +BaseModel appears to expand fillable behavior in tests. +This must be removed or isolated so tests reflect production behavior. +``` + +--- + +# Phase 1: Account State and Authentication + +## 1.1 Enforce account state in every auth path + +Problem: + +Existing middleware authenticates users but does not consistently block: + +```text +unverified users +inactive users +suspended users +disabled users with existing tokens/sessions +``` + +Apply central account-state enforcement to: + +```text +ApiJwtAuth +MultiAuth +TeacherPortalAuthenticate +ApiDocsAuth +RequirePermission +auth:api +Sanctum-authenticated requests +JWT-authenticated requests +web session users where relevant +``` + +Required logic: + +```php +if (! $user->is_verified || $user->status !== 'active' || $user->is_suspended) { + return response()->json(['message' => 'Account unavailable.'], 403); +} +``` + +Tests: + +```text +Suspended user with existing JWT is blocked. +Suspended user with existing Sanctum token is blocked. +Suspended user with web session is blocked. +Unverified user with existing token is blocked. +Inactive user with existing token is blocked. +``` + +## 1.2 Stop issuing tokens before email verification + +Problem: + +Registration creates an unverified user and immediately returns a JWT. + +Required fix: + +```text +Registration must not return an access token. +Only verified, active, non-suspended users can receive tokens. +Unverified users must receive a verification_required response only. +``` + +Expected response: + +```json +{ + "success": true, + "status": "verification_required", + "message": "Please verify your email address." +} +``` + +Tests: + +```text +Registration does not return token. +Unverified user cannot access protected route. +Verified active user can log in. +Inactive user cannot log in. +Suspended user cannot log in. +``` + +## 1.3 Enforce account state in all login flows + +Affected areas: + +```text +AuthController +AuthSessionController +JWT login +session login +teacher portal login +parent login if separate +``` + +After password verification, enforce: + +```text +is_verified == true +status == active +is_suspended == false +``` + +Use generic public errors to avoid enumeration. + +Tests: + +```text +Unverified user with correct password cannot log in. +Inactive user with correct password cannot log in. +Suspended user with correct password cannot log in. +Active verified user can log in. +``` + +## 1.4 Replace fake CAPTCHA + +Problem: + +The current CAPTCHA returns the answer to the client. + +Required fix: + +Use one: + +```text +Cloudflare Turnstile +hCaptcha +reCAPTCHA +real server-side challenge that never exposes the answer +``` + +Add registration abuse controls: + +```text +Rate limit by IP. +Rate limit by email. +Rate limit by subnet. +Log suspicious registration bursts. +``` + +Tests: + +```text +Registration without CAPTCHA fails when CAPTCHA is enabled. +Invalid CAPTCHA fails. +Valid CAPTCHA passes. +Repeated registration attempts are throttled. +``` + +## 1.5 Fix login lockout denial-of-service + +Problem: + +Failed login attempts can suspend real users. + +Required fix: + +```text +Do not auto-suspend accounts from ordinary failed login attempts. +Use temporary throttling. +Use IP/account-pair rate limits. +Use progressive delay. +Alert admins on abuse. +Keep manual/admin suspension separate. +``` + +Tests: + +```text +Repeated failed login attempts throttle login. +Repeated failed login attempts do not permanently suspend account. +Manual/admin suspension still blocks login. +``` + +## 1.6 Remove predictable temporary passwords + +Problem: + +Some family/user creation paths create accounts with predictable passwords such as `temporary`. + +Required fix: + +Use invite-based setup: + +```text +Create inactive/unverified user. +Generate one-time invite token. +Send setup link. +User sets password. +Mark verified/active after setup. +Expire token. +Reject token reuse. +``` + +Tests: + +```text +Invited user cannot log in before setting password. +Invite token can be used once. +Expired invite token fails. +User can log in after setting password. +``` + +## 1.7 Standardize password hashing + +Problem: + +The app mixes custom PBKDF2 hashing with Laravel hashing. + +Required fix: + +```php +Hash::make($password) +``` + +Keep PBKDF2 only as a legacy verifier. + +On successful legacy login: + +```php +if (password_is_legacy_pbkdf2($user->password)) { + $user->password = Hash::make($plainPassword); + $user->save(); +} +``` + +Tests: + +```text +Existing PBKDF2 user can log in. +After login, password is rehashed. +New users use Laravel Hash format. +Invalid password still fails. +``` + +## 1.8 Decide between Sanctum and JWT + +Problem: + +Both Sanctum and JWT auth are installed. + +Required decision: + +```text +Use Sanctum for first-party SPA/dashboard authentication. +Use JWT only if stateless external API auth is a hard requirement. +``` + +If keeping JWT: + +```text +Centralize JWT validation. +Pin allowed algorithms. +Support token revocation/rotation. +Reject unverified/inactive/suspended users in JWT middleware. +Remove weak/default token secrets. +``` + +If switching to Sanctum: + +```text +Remove jwt-auth package. +Replace JWT issuing code. +Update frontend token flow. +Update route middleware. +``` + +Tests: + +```text +Only one primary auth guard protects API routes. +Invalid/revoked token fails. +Suspended user token fails. +Unverified user token fails. +Inactive user token fails. +``` + +## 1.9 Reduce account and email enumeration + +Problem: + +Some auth/register flows reveal whether an email exists or whether an account is suspended. + +Required fix: + +Use generic public responses: + +```text +Invalid credentials. +If the account exists, instructions have been sent. +Unable to process request. +``` + +Log detailed reason internally only. + +Tests: + +```text +Unknown email and wrong password return same public response. +Suspended account does not reveal suspension before password verification. +Registration duplicate response does not leak more than intended. +``` + +--- + +# Phase 2: Central Authorization Architecture + +## 2.1 Centralize role and permission logic + +Problem: + +Role checks are scattered and inconsistent. + +Examples: + +```text +admin +administrator +principal +vice_principal +vice-principal +teacher +teacher_assistant +parent +ta +guest +student +``` + +Required fix: + +Create: + +```text +RoleAccessService +AuthorizationService +Role enum/constants +Permission constants +``` + +Rules: + +```text +Normalize role names once. +Prefer permission checks over hard-coded role names. +No raw role string comparisons in controllers. +No exclusion-based admin logic. +``` + +Tests: + +```text +admin and administrator behavior is explicit. +principal behavior is explicit. +teacher assistant behavior is explicit. +parent behavior is explicit. +Api docs admin access recognizes intended admin roles. +``` + +## 2.2 Replace exclusion-based admin-like logic + +Problem: + +Some logic treats users as admin-like if their role is not in a small exclusion list. + +Required fix: + +```text +Never infer admin-like access by excluding known low-privilege roles. +Use explicit permissions. +``` + +Example replacement: + +```php +$user->can('attendance.admin.manage') +``` + +Tests: + +```text +Custom role finance is not attendance admin by default. +Custom role librarian is not grade admin by default. +Custom role volunteer is not student-data admin by default. +``` + +## 2.3 Add route authorization snapshot tests + +Create tests that fail if sensitive middleware is removed. + +Sensitive route groups: + +```text +scanner +attendance +students +scores +grading +finance +expenses +email +communications +messages +notifications +whatsapp +files +reports +badges +certificates +users +role-permissions +system +settings +docs +``` + +Rules: + +```text +Sensitive route must have auth middleware. +Mutation route must have permission/admin/owner enforcement. +Public route must be explicitly allowlisted and rate-limited. +``` + +--- + +# Phase 3: User, Role, and Permission Management + +## 3.1 Delete unsafe duplicate user controller + +Problem: + +Duplicate controllers exist: + +```text +App\Http\Controllers\Api\UserController +App\Http\Controllers\Api\Users\UserController +``` + +The unsafe duplicate should not remain in autoloaded code. + +Required fix: + +```text +Delete app/Http/Controllers/Api/UserController.php +``` + +Or move it outside autoloaded application code. + +Verification: + +```bash +php artisan route:list | grep "Api\\UserController" +``` + +Expected: + +```text +No route uses App\Http\Controllers\Api\UserController +``` + +## 3.2 Harden user management requests + +Sensitive fields must not be accepted from generic create/update requests: + +```text +role_id +is_verified +is_suspended +failed_attempts +token +account_id +status +password, except in password-specific flows +``` + +Required request classes: + +```text +AdminUserStoreRequest +AdminUserUpdateRequest +SelfProfileUpdateRequest +UserSecurityStateRequest +UserRoleUpdateRequest +``` + +Authorization example: + +```php +public function authorize(): bool +{ + return $this->user()?->can('users.manage') === true; +} +``` + +Tests: + +```text +Normal user cannot create user. +Normal user cannot update another user. +Normal user cannot change role_id. +Normal user cannot set is_verified. +Admin can create user. +Admin can update allowed fields. +``` + +## 3.3 Harden user model mass assignment + +Problem: + +The User model exposes sensitive fields through `$fillable`. + +Required fix: + +Remove these from generic fillable: + +```text +failed_attempts +status +is_suspended +is_verified +token +account_id +role_id +``` + +Use separate service methods: + +```text +updateUserRole() +verifyUser() +suspendUser() +activateUser() +resetFailedAttempts() +assignAccount() +``` + +Each method must check permission and audit the change. + +## 3.4 Harden role and permission management + +Required permissions: + +```text +roles.view +roles.manage +permissions.view +permissions.manage +users.roles.assign +``` + +Rules: + +```text +Prevent users from modifying their own privileged roles unless explicitly allowed. +Prevent deleting the last admin role. +Prevent deleting permissions that are still assigned unless intentionally forced. +Audit every role/permission mutation. +``` + +Tests: + +```text +Normal user cannot access role routes. +Admin without permission cannot assign roles. +Authorized admin can assign roles. +Admin cannot silently escalate own role. +Audit record is created for role changes. +``` + +--- + +# Phase 4: Student Data, Families, and Privacy + +## 4.1 Lock student data and IDOR surfaces + +Affected endpoints: + +```text +GET /students +GET /students/{studentId} +GET /students/{studentId}/attendance +GET /students/{studentId}/incidents +GET /students/{studentId}/scores +GET /students/{studentId}/parents +GET /students/{studentId}/emergency-contacts +GET /students/{studentId}/report-card +GET /students/{studentId}/score-card +``` + +Required access model: + +```text +Admin: can access all students. +Teacher: can access only assigned students/classes/sections. +Parent: can access only own authorized children. +Student: can access only self, if student accounts exist. +Normal unrelated user: no access. +``` + +Extra-sensitive fields: + +```text +medical information +allergies +emergency contacts +incidents +discipline records +guardian contact information +DOB +report cards +scores/grades +``` + +Required service: + +```text +StudentAccessService +``` + +Methods: + +```text +canViewStudent(user, student) +canViewStudentMedical(user, student) +canViewStudentGrades(user, student) +canViewStudentGuardians(user, student) +canUpdateStudent(user, student) +``` + +Tests: + +```text +Normal user cannot view arbitrary student. +Parent cannot view another parent's child. +Teacher cannot view student outside assigned section. +Teacher can view assigned student. +Admin can view all students. +Emergency contacts require stricter permission. +Medical/allergy fields require stricter permission. +``` + +## 4.2 Lock family and guardian lookup routes + +Affected areas: + +```text +families/by-student/* +families/{familyId}/guardians +communications family/guardian lookups +WhatsApp parent contact lists +message recipient directories +``` + +Required rules: + +```text +Admin can access all. +Teacher can access only families/guardians for assigned students. +Parent can access only own family data. +Normal user cannot enumerate family or guardian contacts. +``` + +Tests: + +```text +Normal user cannot list guardians. +Teacher cannot list guardians for unassigned student. +Parent cannot list another family. +Admin can list guardians. +``` + +## 4.3 Protect student photos and media + +Problem: + +Student photos are privacy-sensitive and may be stored with predictable paths. + +Required fix: + +```text +Store student photos privately by default. +Use UUID/random filenames. +Do not use predictable {studentId}.{extension} public paths. +Serve through authorized controller. +Validate MIME and file signature. +``` + +Tests: + +```text +Normal user cannot fetch arbitrary student photo. +Parent can fetch own child's photo if allowed. +Teacher can fetch assigned student photos if allowed. +Unassigned teacher cannot fetch photo. +Invalid image MIME is rejected. +``` + +--- + +# Phase 5: Attendance and Scanner Security + +## 5.1 Lock public scanner and badge scan endpoints + +Affected routes: + +```text +POST /api/v1/badge_scan/scan +POST /api/v1/scanner/process +``` + +Required scanner authentication: + +```text +Scanner device ID +Per-device API key +HMAC signature over request body +Timestamp +Nonce/replay protection +Optional VPN/IP allowlist +Device active/disabled state +Device location/school scope +``` + +Route structure: + +```php +Route::middleware(['scanner.auth', 'throttle:30,1'])->group(function () { + Route::post('badge_scan/scan', [BadgeScanController::class, 'scan']); + Route::post('scanner/process', [ScannerController::class, 'process']); +}); +``` + +Tests: + +```text +Missing scanner credentials -> 401 +Invalid HMAC -> 401 +Expired timestamp -> 401 +Reused nonce -> 409 +Disabled scanner device -> 403 +Valid scanner request records attendance +Normal user JWT without scanner key -> 403 +``` + +## 5.2 Lock authenticated attendance management + +Affected areas: + +```text +/api/v1/attendance/admin/update +/api/v1/attendance/admin/add-entry +/api/v1/attendance/staff/cell +/api/v1/attendance/early-dismissals/* +/api/v1/attendance/management/* +/api/v1/attendance-tracking/* +``` + +Required permissions: + +```text +attendance.teacher.submit +attendance.admin.manage +attendance.staff.manage +attendance.early_dismissal.manage +attendance.tracking.manage +attendance.notifications.send +``` + +Rules: + +```text +Teacher can submit attendance only for assigned sections. +Admin can edit all attendance. +Staff attendance edits require staff-attendance permission. +Early dismissal actions require authorized staff/admin. +Attendance-tracking emails require attendance.notifications.send. +Parent-info endpoints require parent/admin/authorized staff scoping. +``` + +Tests: + +```text +Teacher cannot submit attendance for unassigned class. +Normal user cannot create manual attendance entry. +Normal user cannot update staff attendance. +Normal user cannot upload early dismissal signature. +Normal user cannot send attendance warning emails. +Parent can only view own child attendance where allowed. +``` + +--- + +# Phase 6: Grades, Scores, Reports, and Academic Records + +## 6.1 Lock gradebook, scores, grading, and report cards + +Affected areas: + +```text +/scores/* +/grading/* +/reports/report-cards/* +homework scores +quiz scores +project scores +participation scores +midterm scores +final scores +report-card generation +grade locks +below-sixty decisions +placement decisions +``` + +Required permissions: + +```text +grades.view +grades.manage +grades.lock +grades.publish +report_cards.view +report_cards.generate +report_cards.publish +report_cards.override +``` + +Required service-level rule: + +```text +Before reading/updating any score, prove: +1. score belongs to student +2. student belongs to class section +3. class section is assigned to teacher or caller has admin permission +4. grade period is not locked unless caller has override permission +``` + +Rules: + +```text +Teacher can update only assigned sections/classes. +Teacher cannot update locked/finalized grades unless explicitly allowed. +Parent can only view own child's released report cards. +Student can only view own released grades, if applicable. +Admin can override with audit log. +Do not trust score_ids from client without ownership/scope verification. +``` + +Tests: + +```text +Teacher cannot edit grades for unassigned class. +Teacher cannot update arbitrary score_ids. +Teacher cannot edit locked grades. +Parent cannot access another student's report card. +Normal user cannot generate report cards. +Admin override creates audit log. +Report cards are hidden until released/published. +``` + +## 6.2 Lock reports, slips, stickers, certificates, and print outputs + +Affected areas: + +```text +/reports/slips/* +/reports/stickers/* +/reports/report-cards/* +/certificates/* +/print-requests/* +``` + +Required permissions: + +```text +reports.view +reports.print +slips.print +stickers.print +certificates.issue +certificates.reprint +certificates.view +``` + +Rules: + +```text +Normal user cannot generate reports/certificates. +Teacher can only print assigned class materials. +Parent can only view own child's released documents. +Admin can issue/reprint certificates. +Print downloads must be owner/admin scoped. +``` + +Tests: + +```text +Normal user cannot generate certificates. +Teacher can only print assigned class materials. +Parent cannot access another student's certificate/report. +Admin can print/reprint certificates. +Print request download ownership is enforced. +``` + +--- + +# Phase 7: Finance and Expenses + +## 7.1 Lock finance routes + +Affected areas: + +```text +finance/carryforwards/* +finance/event-charges/* +finance/installment-plans/* +finance/payments/* +finance/fees/refund +finance/fees/charges/* +finance/fees/family-balance +finance/invoices/* +finance/reports/* +manual payment routes +invoice/payment allocation routes +``` + +Required permissions: + +```text +finance.view +finance.manage +finance.approve +finance.refund +finance.payment.manage +finance.invoice.manage +finance.event_charge.manage +finance.installment.manage +finance.report.view +``` + +Rules: + +```text +Parent finance routes must be separate and scoped to own family/invoices only. +Finance staff can only perform actions granted by permission. +Approve/post/waive/void/refund actions require elevated finance permissions. +Every financial mutation must create an audit log. +``` + +Tests: + +```text +Normal authenticated user -> 403 for all finance writes. +Parent can only access own invoices/payments. +Finance staff can perform allowed finance actions. +Finance staff cannot approve if only granted view/manage. +Admin can perform all finance actions. +All finance mutations create audit logs. +``` + +## 7.2 Lock expenses + +Affected routes: + +```text +/expenses +/expenses/{id} +/expenses/{id}/status +``` + +Required permissions: + +```text +expenses.view +expenses.manage +expenses.approve +``` + +Rules: + +```text +Normal users cannot create/update/approve expenses unless explicitly intended. +Expense approval requires expenses.approve. +Expense updates require owner or expenses.manage, depending on business rule. +Status changes must be audited. +``` + +Tests: + +```text +Normal user cannot approve expense. +Unauthorized user cannot update another user's expense. +Expense manager can update allowed fields. +Expense approval creates audit log. +``` + +## 7.3 Harden finance model mass assignment + +Replace `protected $guarded = []` with explicit `$fillable` on sensitive finance models. + +Priority models: + +```text +FinanceBalanceCarryforward +FinanceFollowUpNote +FinanceNotificationLog +FinanceNotificationPreference +FinanceReceiptSequence +InvoiceInstallment +InvoiceInstallmentPlan +PaymentCarryforwardAllocation +PaymentInstallmentAllocation +``` + +Tests: + +```text +Forbidden finance fields cannot be mass assigned. +Finance security fields cannot be set by normal request data. +``` + +## 7.4 Fix financial chart privacy and static filenames + +Problem: + +Financial chart generation sends report data to external chart services and writes static filenames. + +Required fix: + +```text +Use local chart rendering for sensitive financial reports, or explicitly approve third-party use. +Use unique per-report chart filenames. +Do not store charts in shared predictable paths. +Delete temporary chart files after report generation. +Do not log full financial chart payloads. +``` + +Tests: + +```text +Concurrent report generation does not overwrite chart files. +Sensitive financial chart generation can run without third-party service. +Temporary chart files are cleaned up. +``` + +--- + +# Phase 8: Email, Communications, Messages, Notifications, and WhatsApp + +## 8.1 Lock generic email sending + +Affected routes: + +```text +GET /api/v1/email/senders +POST /api/v1/email/send +``` + +Required permissions: + +```text +email.send +email.sender.manage +``` + +Rules: + +```text +Require authorized sender profile. +Validate recipient count. +Validate attachment MIME types. +Limit total attachment size. +Reject raw HTML unless sender has explicit permission. +Restrict BCC unless admin/authorized role. +Audit every send. +``` + +Tests: + +```text +Normal user cannot send email. +Authorized staff can send email. +Unauthorized sender profile is rejected. +Oversized attachment is rejected. +Disallowed MIME attachment is rejected. +BCC is blocked for unauthorized users. +Email audit log is created. +``` + +## 8.2 Lock communications routes + +Affected areas: + +```text +/api/v1/communications/options +/api/v1/communications/students/{studentId}/families +/api/v1/communications/families/{familyId}/guardians +/api/v1/communications/preview +/api/v1/communications/send +``` + +Required permissions: + +```text +communications.view +communications.send +communications.guardians.view +communications.admin +``` + +Rules: + +```text +Admin can access all. +Teacher can access only assigned students/classes. +Teacher can send only to approved guardian recipients unless elevated. +Normal user cannot enumerate families or guardians. +Parent cannot enumerate other families. +All communication sends are audited. +``` + +Tests: + +```text +Normal user cannot enumerate guardians. +Teacher can only view guardians for assigned students. +Teacher cannot send arbitrary CC/BCC. +Admin can send broader communications. +All communication sends are audited. +``` + +## 8.3 Lock message recipient directories and attachments + +Problem: + +Message policies are better for individual messages, but recipient discovery and attachments are weak. + +Required fix: + +```text +Message recipient directories must be scoped to sender role and assignments. +Teachers can discover only assigned students/parents. +Parents can discover only allowed staff/teacher contacts if intended. +Normal users cannot enumerate staff/parents/students. +Message attachments must use private storage. +Attachments need MIME allowlist and file signature checks. +Do not accept arbitrary message status from client except draft/send flows. +``` + +Required permissions: + +```text +messages.send +messages.view +messages.attach +messages.directory.view +``` + +Tests: + +```text +Normal user cannot enumerate all recipients. +Teacher cannot discover parents outside assigned classes. +Parent cannot discover unrelated families. +Message attachment with disallowed MIME is rejected. +Message attachment cannot be downloaded by unauthorized user. +Client cannot set arbitrary status. +``` + +## 8.4 Lock notifications + +Affected areas: + +```text +/api/v1/notifications/* +``` + +Required permissions: + +```text +notifications.view +notifications.send +notifications.manage +``` + +Rules: + +```text +Normal users can view only their own notifications. +Only authorized staff/admin can create global notifications. +Only authorized staff/admin can update/delete/restore notifications. +Deleted notification listing requires notifications.manage. +Notification sends are audited. +``` + +Tests: + +```text +Normal user cannot create global notification. +Normal user cannot list deleted notifications. +Normal user cannot restore notifications. +Authorized user can manage notifications. +Audit log is created. +``` + +## 8.5 Lock WhatsApp routes + +Affected areas: + +```text +/api/v1/whatsapp/* +``` + +Required permissions: + +```text +whatsapp.links.manage +whatsapp.contacts.view +whatsapp.invites.send +whatsapp.membership.manage +``` + +Rules: + +```text +WhatsApp parent contact lists require admin or assigned teacher scope. +WhatsApp invite sending requires explicit permission. +WhatsApp group membership updates require admin/authorized staff. +Do not expose contact exports to normal users. +Audit invite sends and membership changes. +``` + +Tests: + +```text +Teacher can only view contacts for assigned classes. +Normal user cannot export/list parent WhatsApp contacts. +Normal user cannot send WhatsApp invites. +WhatsApp invite send creates audit log. +Membership updates require permission. +``` + +--- + +# Phase 9: Exam Drafts, Class Prep, Badges, Competitions, Curriculum, and School IDs + +## 9.1 Lock exam draft admin routes + +Affected routes: + +```text +/exam-drafts/admin +/exam-drafts/admin/upload +/exam-drafts/admin/review +``` + +Required permissions: + +```text +exam_drafts.teacher.submit +exam_drafts.admin.view +exam_drafts.admin.upload +exam_drafts.admin.review +exam_drafts.finalize +``` + +Rules: + +```text +Teacher upload must require assigned class/section. +Admin upload/review/finalize requires explicit exam draft permission. +Finalizing/rejecting drafts must be audited. +Legacy/final exam upload requires elevated permission. +``` + +Tests: + +```text +Normal user cannot view exam draft admin list. +Teacher cannot review/finalize drafts. +Teacher cannot upload for unassigned class. +Admin/exam reviewer can review/finalize. +Review action creates audit log. +``` + +## 9.2 Lock badges and print logging + +Affected routes: + +```text +/badges/generate-pdf +/badges/print-status +/badges/log-print +/badges/form-data +``` + +Required permissions: + +```text +badges.view +badges.print +badges.log_print +badges.manage +``` + +Rules: + +```text +Normal users cannot generate badges. +Normal users cannot log badge prints. +Badge status queries must be scoped. +Badge generation for arbitrary user_ids/student_ids requires permission. +Badge print logs must not be forgeable by normal users. +``` + +Tests: + +```text +Normal user cannot generate badges. +Normal user cannot log badge print. +Teacher can only generate badges for assigned class if allowed. +Admin can generate badges. +Print log creates audit record. +``` + +## 9.3 Lock class preparation + +Affected actions: + +```text +list preparation data +mark printed +save adjustments +print prep +sticker counts +students by class +``` + +Required permissions: + +```text +class_prep.view +class_prep.print +class_prep.adjust +``` + +Rules: + +```text +Teacher can access only assigned sections. +Normal user cannot fetch class rosters. +Adjustments require class_prep.adjust. +Printing requires class_prep.print. +``` + +Tests: + +```text +Normal user cannot fetch class prep data. +Teacher cannot fetch roster for unassigned section. +Teacher can fetch assigned section if allowed. +Adjustments require permission. +Mark printed requires permission. +``` + +## 9.4 Lock competitions + +Affected actions: + +```text +create competition +update competition settings +save scores +preview winners +publish +lock +``` + +Required permissions: + +```text +competitions.view +competitions.manage +competitions.score +competitions.publish +competitions.lock +``` + +Rules: + +```text +Competition creation/update requires competitions.manage. +Score entry requires assigned class or competitions.score. +Publish requires competitions.publish. +Lock requires competitions.lock. +Winner preview must not expose unauthorized student data. +``` + +Tests: + +```text +Normal user cannot create competition. +Teacher cannot score unassigned class. +Unauthorized user cannot publish winners. +Unauthorized user cannot lock competition. +Publishing/locking creates audit log. +``` + +## 9.5 Lock curriculum CRUD + +Affected area: + +```text +/api/v1/subjects/curriculum/* +``` + +Required permissions: + +```text +curriculum.view +curriculum.manage +``` + +Rules: + +```text +Teachers may view curriculum if intended. +Only curriculum admins/admins can create/update/delete. +All curriculum changes require audit logs. +``` + +Tests: + +```text +Normal user cannot create curriculum entry. +Teacher cannot delete curriculum unless explicitly allowed. +Admin can manage curriculum. +Curriculum mutation creates audit log. +``` + +## 9.6 Lock school ID generation and assignment + +Affected routes: + +```text +GET /api/v1/system/school-ids/student +GET /api/v1/system/school-ids/user +POST /api/v1/system/school-ids/assign +``` + +Required permissions: + +```text +school_ids.generate +school_ids.assign +``` + +Rules: + +```text +Only authorized admin/staff can generate IDs. +Only authorized admin/staff can assign IDs. +Assignment must prevent duplicates. +Generator must be race-condition safe. +``` + +Tests: + +```text +Normal user cannot generate school IDs. +Normal user cannot assign school ID to another user. +Admin can generate and assign. +Concurrent generation does not create duplicates. +``` + +--- + +# Phase 10: Files, Uploads, PDF, HTML, and External Processing + +## 10.1 Replace filename-based file authorization + +Affected routes: + +```text +/files/receipts/{name} +/files/reimbursements/{name} +/files/early-dismissal/{name} +/files/exams/teacher/{name} +/files/exams/final/{name} +``` + +Required fix: + +Use database-record routes: + +```text +GET /files/receipts/{receipt} +GET /files/reimbursements/{reimbursement} +GET /files/exams/{exam}/teacher-file +GET /files/exams/{exam}/final-file +GET /files/certificates/{certificate} +``` + +Rules: + +```text +Receipt: owner parent or finance/admin. +Reimbursement: owner, manager, finance, or admin. +Early dismissal file: authorized staff/admin. +Teacher exam file: assigned teacher or admin. +Final exam file: assigned teacher/admin. +Certificate: owner parent/student if released, or admin/staff. +``` + +Tests: + +```text +User cannot access another user's receipt. +Teacher cannot access unassigned exam file. +Parent cannot access reimbursement file. +Admin can access authorized files. +Path traversal remains blocked. +Downloads set safe headers. +``` + +## 10.2 Harden upload validation + +Apply to: + +```text +exam draft files +message attachments +reimbursement/receipt uploads +student photos +generic email attachments +class progress attachments +early dismissal signatures +certificate/report assets +``` + +Required validation: + +```text +extension +MIME type +file signature +file size +output size where conversion occurs +``` + +Storage rules: + +```text +Private storage by default. +UUID/random filenames. +Never trust original extension alone. +Serve through authorized controller only. +``` + +Allowed common types, where applicable: + +```text +application/pdf +image/jpeg +image/png +``` + +Reject unless explicitly needed: + +```text +SVG +HTML +JavaScript +Office documents +executables +archives +``` + +Tests: + +```text +PDF accepted where allowed. +JPEG accepted where allowed. +PNG accepted where allowed. +SVG rejected. +HTML rejected. +EXE rejected. +Unauthorized user cannot download upload. +``` + +## 10.3 Sandbox LibreOffice conversion + +Problem: + +Exam draft upload converts DOC/DOCX using `soffice`. + +Required sandbox: + +```text +isolated worker/container +no network access +non-root user +read-only base filesystem +limited writable temp directory +CPU limit +memory limit +timeout +macro disabled +file count limit +output size limit +``` + +Tests: + +```text +Invalid extension rejected. +Wrong MIME rejected. +Oversized file rejected. +DOCX conversion timeout is handled safely. +Malicious filename does not affect path. +Converted file stored outside public webroot. +``` + +## 10.4 Reduce and harden PDF libraries + +Problem: + +Multiple PDF libraries are installed: + +```text +dompdf/dompdf +setasign/fpdf +tecnickcom/tcpdf +``` + +Required fix: + +```bash +composer remove setasign/fpdf tecnickcom/tcpdf +``` + +Only remove if unused after confirming code references. + +Dompdf hardening: + +```php +$options->setIsRemoteEnabled(false); +$options->setChroot(storage_path('app/pdf-assets')); +``` + +Rules: + +```text +Do not render raw user HTML into PDFs. +Do not allow remote asset fetching in production. +Do not allow local file access outside chroot. +``` + +Tests: + +```text +PDF generation still works. +Remote image fetch is blocked. +Local file access outside chroot is blocked. +User HTML is escaped or sanitized. +``` + +## 10.5 Replace regex HTML sanitization + +For plain text: + +```php +nl2br(e($message)) +``` + +For rich HTML: + +```text +Use HTMLPurifier or equivalent. +Define allowed tags and attributes. +Block scripts, iframes, event handlers, javascript: URLs, unsafe data URLs. +``` + +Tests: + +```text +