From 2ef71cc92bd178911e0c6c297cc02c6a29aafcfd Mon Sep 17 00:00:00 2001
From: root
Date: Wed, 11 Mar 2026 17:53:15 -0400
Subject: [PATCH] add all controllers logic
---
app/Console/Commands/ImportUsers.php | 30 +
.../Api/Attendance/LateSlipLogsController.php | 78 ++
.../Controllers/Api/Auth/IpBanController.php | 115 +++
.../Api/Auth/SessionTimeoutController.php | 67 --
.../Controllers/Api/BaseApiController.php | 33 +-
.../ClassPreparationController.php | 74 +-
.../Api/Classes/ClassController.php | 136 +++
.../Api/Finance/PaypalPaymentController.php | 3 +-
.../Api/Frontend/FrontendController.php | 72 ++
.../Api/Frontend/InfoIconController.php | 31 +
.../Api/Frontend/LandingPageController.php | 86 ++
.../Api/Frontend/PageController.php | 62 ++
.../Api/Messaging/MessagesController.php | 222 +++++
.../Api/Settings/PolicyController.php | 43 +
.../Api/Settings/PreferencesController.php | 139 +++
.../Api/Settings/SettingsController.php | 51 +
.../Controllers/Api/Staff/StaffController.php | 121 +++
.../Api/Support/ContactController.php | 27 +
.../Api/Support/SupportController.php | 83 ++
.../Api/System/DashboardController.php | 31 +
.../Api/System/DatabaseHealthController.php | 30 +
.../Api/System/HealthController.php | 27 +
.../Api/System/NavBuilderController.php | 90 ++
app/Http/Controllers/Api/Ui/UiController.php | 32 +
.../Utilities/PhoneFormatterController.php | 36 +
app/Http/Controllers/View/ClassController.php | 150 ---
.../View/ClassPreparationController.php | 545 -----------
.../View/DashboardRedirectController.php | 45 -
app/Http/Kernel.php | 2 -
app/Http/Middleware/ApiDocsAuth.php | 65 ++
app/Http/Middleware/ApiJwtAuth.php | 29 +
app/Http/Middleware/CleanupScheduler.php | 26 +
app/Http/Middleware/PrimeTimezone.php | 26 +
app/Http/Middleware/RequirePermission.php | 32 +
.../Attendance/LateSlipLogIndexRequest.php | 28 +
.../Auth/SessionTimeoutCheckRequest.php | 18 -
.../Auth/SessionTimeoutConfigRequest.php | 18 -
.../Auth/SessionTimeoutPingRequest.php | 18 -
.../ClassPreparationAdjustmentRequest.php | 23 +
.../ClassPreparationIndexRequest.php | 21 +
.../ClassPreparationMarkPrintedRequest.php | 23 +
.../ClassPreparationPrintRequest.php | 32 +
.../Classes/ClassSectionIndexRequest.php | 27 +
.../Classes/ClassSectionStoreRequest.php | 25 +
.../Classes/ClassSectionUpdateRequest.php | 33 +
.../Frontend/ContactSubmitRequest.php | 17 +
.../Frontend/LandingTeacherRequest.php | 20 +
.../Messaging/MessageIndexRequest.php | 21 +
.../Requests/Messaging/MessageSendRequest.php | 23 +
.../Messaging/MessageUpdateRequest.php | 17 +
.../Payments/PaypalExecuteRequest.php | 1 +
.../Preferences/PreferencesIndexRequest.php | 25 +
.../Preferences/PreferencesUpsertRequest.php | 31 +
.../Requests/Security/IpBanIndexRequest.php | 25 +
app/Http/Requests/Security/IpBanRequest.php | 34 +
app/Http/Requests/Security/IpUnbanRequest.php | 36 +
.../Requests/Settings/PolicyShowRequest.php | 20 +
.../Settings/SettingsUpdateRequest.php | 17 +
app/Http/Requests/Staff/StaffIndexRequest.php | 21 +
app/Http/Requests/Staff/StaffStoreRequest.php | 22 +
.../Requests/Staff/StaffUpdateRequest.php | 21 +
.../Requests/Support/ContactSendRequest.php | 18 +
.../Support/SupportRequestIndexRequest.php | 20 +
.../Support/SupportRequestStoreRequest.php | 16 +
.../Requests/System/NavItemReorderRequest.php | 21 +
.../Requests/System/NavItemStoreRequest.php | 30 +
app/Http/Requests/Ui/UiStyleRequest.php | 19 +
.../Requests/Utilities/PhoneFormatRequest.php | 20 +
.../Attendance/LateSlipLogCollection.php | 27 +
.../Attendance/LateSlipLogResource.php | 28 +
.../Auth/SessionTimeoutConfigResource.php | 20 -
.../Auth/SessionTimeoutStatusResource.php | 18 -
.../ClassPreparationPrintResource.php | 22 +
.../ClassPreparationResultResource.php | 23 +
.../Classes/ClassAttendanceResource.php | 21 +
.../ClassAttendanceStudentResource.php | 21 +
.../Classes/ClassSectionCollection.php | 27 +
.../Classes/ClassSectionResource.php | 24 +
.../Frontend/ContactSubmissionResource.php | 18 +
.../Frontend/FrontendPageResource.php | 16 +
.../Frontend/FrontendUserResource.php | 17 +
.../Frontend/LandingPageResource.php | 18 +
.../Frontend/PageContentResource.php | 17 +
.../Frontend/ProfileIconResource.php | 17 +
.../Resources/Messaging/MessageCollection.php | 31 +
.../Resources/Messaging/MessageResource.php | 31 +
.../Resources/Messaging/RecipientResource.php | 17 +
.../Preferences/PreferencesCollection.php | 27 +
.../Preferences/PreferencesListResource.php | 28 +
.../Preferences/PreferencesResource.php | 21 +
.../Resources/Security/IpBanCollection.php | 27 +
app/Http/Resources/Security/IpBanResource.php | 26 +
.../Resources/Settings/PolicyResource.php | 20 +
.../Resources/Settings/SettingsResource.php | 18 +
app/Http/Resources/Staff/StaffCollection.php | 31 +
app/Http/Resources/Staff/StaffResource.php | 29 +
.../Support/ContactMessageResource.php | 17 +
.../Support/SupportRequestCollection.php | 31 +
.../Support/SupportRequestResource.php | 24 +
.../System/DashboardRouteResource.php | 19 +
.../Resources/System/HealthStatusResource.php | 20 +
.../System/NavBuilderDataResource.php | 18 +
app/Http/Resources/System/NavItemResource.php | 28 +
app/Http/Resources/Ui/UiStyleResource.php | 20 +
.../Utilities/PhoneFormatResource.php | 18 +
app/Models/SupportRequest.php | 34 +
app/Policies/ClassSectionPolicy.php | 55 ++
app/Policies/IpAttemptPolicy.php | 50 +
app/Policies/LateSlipLogPolicy.php | 40 +
app/Policies/MessagePolicy.php | 55 ++
app/Policies/NavItemPolicy.php | 15 +
app/Policies/PreferencesPolicy.php | 50 +
app/Policies/SettingPolicy.php | 35 +
app/Policies/StaffPolicy.php | 50 +
app/Policies/SupportRequestPolicy.php | 40 +
app/Providers/AppServiceProvider.php | 29 +
.../Attendance/LateSlipLogCommandService.php | 13 +
.../Attendance/LateSlipLogQueryService.php | 63 ++
.../Auth/RegistrationCaptchaService.php | 14 +-
app/Services/Auth/SessionActivityService.php | 39 -
.../Auth/SessionTimeoutConfigService.php | 21 -
app/Services/Auth/SessionTimeoutService.php | 72 --
app/Services/Auth/UserRoleService.php | 54 ++
...lassPreparationAdjustmentWriterService.php | 51 +
.../ClassPreparationPrintService.php | 60 ++
.../ClassPreparationService.php | 25 +-
.../ClassSections/ClassAttendanceService.php | 32 +
.../ClassSectionCommandService.php | 26 +
.../ClassSectionQueryService.php | 62 ++
.../ClassSections/ClassSectionSeedService.php | 52 ++
.../Dashboard/DashboardRouteService.php | 69 ++
app/Services/Docs/OpenApiRouteExporter.php | 19 +-
.../Frontend/ContactSubmissionService.php | 62 ++
app/Services/Frontend/FrontendPageService.php | 13 +
.../Frontend/LandingPageContextService.php | 18 +
.../LandingPageParentDashboardService.php | 148 +++
.../Frontend/LandingPageRoleService.php | 31 +
app/Services/Frontend/LandingPageService.php | 90 ++
.../LandingPageTeacherSummaryService.php | 595 ++++++++++++
app/Services/Frontend/ProfileIconService.php | 35 +
app/Services/Frontend/StaticPageService.php | 33 +
.../Messaging/MessageCommandService.php | 126 +++
.../Messaging/MessageQueryService.php | 115 +++
.../Messaging/MessageRecipientService.php | 90 ++
app/Services/Navigation/NavBuilderService.php | 216 +++++
app/Services/Navigation/NavbarService.php | 55 ++
.../Payments/PaypalPaymentService.php | 27 +-
app/Services/Phone/PhoneFormatterService.php | 22 +
app/Services/Policy/PolicyContentService.php | 50 +
.../Preferences/PreferencesCommandService.php | 56 ++
.../Preferences/PreferencesOptionsService.php | 48 +
.../Preferences/PreferencesQueryService.php | 83 ++
app/Services/Roles/RoleSwitchService.php | 2 -
.../School/SemesterSelectionService.php | 10 -
app/Services/Scores/ScoreCommentService.php | 21 +-
app/Services/Security/IpBanCommandService.php | 81 ++
app/Services/Security/IpBanQueryService.php | 46 +
app/Services/Settings/SettingsService.php | 48 +
app/Services/Staff/StaffCommandService.php | 100 ++
app/Services/Staff/StaffQueryService.php | 112 +++
.../Support/ContactMessageService.php | 41 +
.../Support/SupportRequestService.php | 80 ++
.../System/CleanupSchedulerService.php | 29 +
app/Services/System/DatabaseHealthService.php | 24 +
app/Services/System/HealthCheckService.php | 64 ++
app/Services/System/TimeService.php | 12 +
app/Services/Ui/UiStyleService.php | 94 ++
app/old/ApiClient.php | 107 ---
app/old/ClassController.php | 157 ----
app/old/ContactController.php | 66 --
app/old/DashboardRedirectController.php | 48 -
app/old/DocsController.php | 25 -
app/old/Filters/.gitkeep | 0
app/old/Filters/ApiAuthFilter.php | 59 --
app/old/Filters/ApiDocsAuthFilter.php | 55 --
app/old/Filters/AuthFilter.php | 200 ----
app/old/Filters/CleanupScheduler.php | 36 -
app/old/Filters/PermissionFilter.php | 31 -
app/old/Filters/TimezoneFilter.php | 26 -
app/old/FrontendController.php | 84 --
app/old/HealthController.php | 75 --
app/old/InfoIconController.php | 39 -
app/old/IpBanController.php | 148 ---
app/old/LandingPageController.php | 882 ------------------
app/old/LateSlipLogsController.php | 63 --
app/old/MessagesController.php | 249 -----
app/old/NavBuilderController.php | 303 ------
app/old/NavbarService.php | 66 --
app/old/PageController.php | 64 --
app/old/PhoneFormatterService.php | 31 -
app/old/PolicyController.php | 36 -
app/old/PreferencesController.php | 144 ---
app/old/RFIDController.php | 71 --
app/old/SendSMSController.php | 54 --
app/old/SettingsController.php | 38 -
app/old/StaffController.php | 156 ----
app/old/StatsController.php | 32 -
app/old/SupportController.php | 133 ---
app/old/TestDBController.php | 27 -
app/old/TimeService.php | 222 -----
app/old/UiController.php | 102 --
bootstrap/app.php | 5 +
.../2026_02_23_204001_create_users_table.php | 4 +
...026_02_23_204002_create_students_table.php | 4 +
...026_02_23_204030_create_expenses_table.php | 4 +
...3_204035_create_current_incident_table.php | 4 +
...026_02_23_204036_create_incident_table.php | 4 +
...2_23_204037_create_grading_locks_table.php | 4 +
...026_02_23_204038_create_homework_table.php | 4 +
...4039_create_inventory_categories_table.php | 4 +
...23_204040_create_inventory_items_table.php | 4 +
...04041_create_inventory_movements_table.php | 4 +
...2_23_204042_create_invoice_event_table.php | 4 +
...026_02_23_204043_create_invoices_table.php | 4 +
...044_create_invoice_students_list_table.php | 4 +
..._02_23_204045_create_ip_attempts_table.php | 4 +
..._23_204046_create_late_slip_logs_table.php | 4 +
..._23_204047_create_login_activity_table.php | 4 +
...23_204048_create_manual_payments_table.php | 4 +
...026_02_23_204049_create_messages_table.php | 4 +
...02_23_204050_create_midterm_exam_table.php | 4 +
...1_create_missing_score_overrides_table.php | 4 +
...26_02_23_204052_create_nav_items_table.php | 4 +
...2_23_204053_create_notifications_table.php | 4 +
...create_parent_attendance_reports_table.php | 4 +
..._create_parent_meeting_schedules_table.php | 4 +
...2026_02_23_204056_create_parents_table.php | 4 +
...4057_create_parent_notifications_table.php | 4 +
...2_23_204058_create_participation_table.php | 4 +
...23_204059_create_password_resets_table.php | 4 +
...0_create_password_reset_requests_table.php | 4 +
...100_create_reimbursement_batches_table.php | 4 +
...23_204101_create_paypal_payments_table.php | 4 +
..._23_204101_create_reimbursements_table.php | 4 +
...2_23_204102_create_payment_error_table.php | 4 +
...026_02_23_204103_create_payments_table.php | 4 +
...create_payment_notification_logs_table.php | 4 +
...4105_create_payment_transactions_table.php | 4 +
...04106_create_paypal_transactions_table.php | 4 +
..._02_23_204107_create_permissions_table.php | 4 +
..._204108_create_placement_batches_table.php | 4 +
...3_204109_create_placement_levels_table.php | 4 +
...3_204110_create_placement_scores_table.php | 4 +
...3_204111_create_user_preferences_table.php | 4 +
..._23_204112_create_print_requests_table.php | 4 +
...2026_02_23_204113_create_project_table.php | 4 +
...23_204114_create_promotion_queue_table.php | 4 +
.../2026_02_23_204117_create_quiz_table.php | 4 +
...2026_02_23_204118_create_refunds_table.php | 4 +
..._reimbursement_batch_admin_files_table.php | 4 +
.../2026_02_23_204123_create_roles_table.php | 4 +
..._23_204124_create_role_nav_items_table.php | 4 +
...3_204125_create_role_permissions_table.php | 4 +
..._23_204126_create_score_comments_table.php | 4 +
...026_02_23_204127_create_sections_table.php | 4 +
...23_204128_create_semester_scores_table.php | 4 +
...026_02_23_204129_create_settings_table.php | 4 +
...3_204130_create_staff_attendance_table.php | 4 +
.../2026_02_23_204131_create_staff_table.php | 4 +
..._204133_create_student_allergies_table.php | 4 +
...2_23_204134_create_student_class_table.php | 4 +
...reate_student_medical_conditions_table.php | 4 +
..._create_subject_curriculum_items_table.php | 4 +
...2_23_204138_create_teacher_class_table.php | 4 +
..._submission_notification_history_table.php | 4 +
...204142_create_user_notifications_table.php | 4 +
...6_02_23_204143_create_user_roles_table.php | 4 +
...4144_create_whatsapp_group_links_table.php | 4 +
...reate_whatsapp_group_memberships_table.php | 4 +
...4146_create_whatsapp_invites_log_table.php | 4 +
...50_create_class_progress_reports_table.php | 4 +
...ate_report_card_acknowledgements_table.php | 4 +
...reate_class_progress_attachments_table.php | 4 +
...create_reimbursement_batch_items_table.php | 4 +
...900001_create_additional_charges_table.php | 4 +
...eate_admin_notification_subjects_table.php | 4 +
...eate_attendance_comment_template_table.php | 4 +
...23_900004_create_attendance_data_table.php | 4 +
..._23_900005_create_attendance_day_table.php | 4 +
..._900006_create_attendance_record_table.php | 4 +
...00007_create_attendance_tracking_table.php | 4 +
...3_900008_create_authorized_users_table.php | 4 +
...3_900009_create_badge_print_logs_table.php | 4 +
...23_900010_create_calendar_events_table.php | 4 +
...026_02_23_900011_create_chapters_table.php | 4 +
...02_23_900012_create_classSection_table.php | 4 +
...13_create_class_prep_adjustments_table.php | 4 +
...014_create_class_preparation_log_table.php | 4 +
...2026_02_23_900017_create_classes_table.php | 4 +
...create_competition_class_winners_table.php | 4 +
...900019_create_competition_scores_table.php | 4 +
...00020_create_competition_winners_table.php | 4 +
...02_23_900021_create_competitions_table.php | 4 +
...2_23_900022_create_configuration_table.php | 4 +
...26_02_23_900023_create_contactus_table.php | 4 +
...02_23_900024_create_current_flag_table.php | 4 +
...23_900025_create_discount_usages_table.php | 4 +
..._900026_create_discount_vouchers_table.php | 4 +
...reate_early_dismissal_signatures_table.php | 4 +
...23_900028_create_email_templates_table.php | 4 +
...900029_create_emergency_contacts_table.php | 4 +
..._02_23_900030_create_enrollments_table.php | 4 +
...2_23_900031_create_event_charges_table.php | 4 +
.../2026_02_23_900032_create_events_table.php | 4 +
..._02_23_900033_create_exam_drafts_table.php | 4 +
..._create_examquestiondistribution_table.php | 4 +
...2_23_900035_create_examquestions_table.php | 4 +
.../2026_02_23_900036_create_exams_table.php | 4 +
...026_02_23_900038_create_families_table.php | 4 +
..._900039_create_family_comm_prefs_table.php | 4 +
...3_900040_create_family_guardians_table.php | 4 +
...23_900041_create_family_students_table.php | 4 +
...6_02_23_900042_create_final_exam_table.php | 4 +
..._02_23_900043_create_final_score_table.php | 4 +
...00044_create_longanswerquestions_table.php | 4 +
...6_create_notification_recipients_table.php | 4 +
..._02_23_900047_create_preferences_table.php | 4 +
...02_23_900048_create_qcmquestions_table.php | 4 +
...26_02_23_900049_create_questions_table.php | 4 +
...900050_create_questionstruefalse_table.php | 4 +
...2_23_900051_create_questiontypes_table.php | 4 +
...026_02_23_900052_create_scan_log_table.php | 4 +
...0053_create_shortanswerquestions_table.php | 4 +
...3_900054_create_support_requests_table.php | 4 +
...5_create_teacher_attendance_data_table.php | 4 +
...900056_create_communication_logs_table.php | 4 +
...12_create_personal_access_tokens_table.php | 4 +
...026_03_09_064908_create_sessions_table.php | 4 +
...26_03_09_070000_create_suppliers_table.php | 4 +
...026_03_09_070010_create_supplies_table.php | 4 +
...70020_create_supply_transactions_table.php | 4 +
...09_070030_create_purchase_orders_table.php | 4 +
...0040_create_purchase_order_items_table.php | 4 +
..._070050_create_supply_categories_table.php | 4 +
.../2026_03_11_045324_create_cache_table.php | 4 +
...026_03_11_205736_create_students_table.php | 31 +
...026_03_11_205736_create_teachers_table.php | 31 +
...11_205737_create_attendance_data_table.php | 31 +
public/html/privacy_policy.html | 1 +
public/html/terms_of_service.html | 1 +
resources/policies/picture_policy.html | 2 +
resources/policies/school_policy.html | 2 +
routes/api.php | 159 +++-
.../Attendance/LateSlipLogsControllerTest.php | 136 +++
.../Api/V1/Auth/IpBanControllerTest.php | 182 ++++
.../Api/V1/Auth/RegisterControllerTest.php | 7 +-
.../V1/Auth/SessionTimeoutControllerTest.php | 123 ---
.../ClassPreparationControllerTest.php | 75 +-
.../V1/Classes/ClassSectionControllerTest.php | 282 ++++++
.../V1/Frontend/FrontendControllerTest.php | 72 ++
.../V1/Frontend/InfoIconControllerTest.php | 40 +
.../V1/Frontend/LandingPageControllerTest.php | 81 ++
.../Api/V1/Frontend/PageControllerTest.php | 59 ++
.../V1/Messaging/MessagesControllerTest.php | 236 +++++
.../Api/V1/Settings/PolicyControllerTest.php | 27 +
.../V1/Settings/PreferencesControllerTest.php | 169 ++++
.../V1/Settings/SettingsControllerTest.php | 103 ++
.../Api/V1/Staff/StaffControllerTest.php | 169 ++++
.../Api/V1/Support/ContactControllerTest.php | 33 +
.../Api/V1/Support/SupportControllerTest.php | 108 +++
.../Api/V1/System/DashboardControllerTest.php | 59 ++
.../System/DatabaseHealthControllerTest.php | 19 +
.../Api/V1/System/HealthControllerTest.php | 31 +
.../V1/System/NavBuilderControllerTest.php | 150 +++
tests/Feature/Api/V1/Ui/UiControllerTest.php | 76 ++
.../PhoneFormatterControllerTest.php | 83 ++
.../RequirePermissionMiddlewareTest.php | 84 ++
.../Frontend/FrontendPageResourceTest.php | 17 +
.../Frontend/PageContentResourceTest.php | 22 +
.../Messaging/MessageResourceTest.php | 34 +
.../Messaging/RecipientResourceTest.php | 18 +
.../Settings/SettingsResourceTest.php | 24 +
.../Resources/Staff/StaffResourceTest.php | 29 +
.../Support/ContactMessageResourceTest.php | 22 +
.../Support/SupportRequestResourceTest.php | 28 +
.../Unit/Resources/Ui/UiStyleResourceTest.php | 26 +
.../LateSlipLogCommandServiceTest.php | 33 +
.../LateSlipLogQueryServiceTest.php | 37 +
.../Auth/RegistrationCaptchaServiceTest.php | 10 +-
.../Services/Auth/RegistrationServiceTest.php | 14 +-
.../Auth/SessionActivityServiceTest.php | 42 -
.../Auth/SessionTimeoutConfigServiceTest.php | 22 -
.../Auth/SessionTimeoutServiceTest.php | 51 -
.../Services/Auth/UserRoleServiceTest.php | 44 +
...PreparationAdjustmentWriterServiceTest.php | 33 +
.../ClassPreparationPrintServiceTest.php | 105 +++
.../ClassAttendanceServiceTest.php | 73 ++
.../ClassSectionCommandServiceTest.php | 36 +
.../ClassSectionQueryServiceTest.php | 47 +
.../ClassSectionSeedServiceTest.php | 31 +
.../Dashboard/DashboardRouteServiceTest.php | 75 ++
.../Frontend/ContactSubmissionServiceTest.php | 39 +
.../Frontend/FrontendPageServiceTest.php | 17 +
.../LandingPageContextServiceTest.php | 29 +
.../LandingPageParentDashboardServiceTest.php | 57 ++
.../Frontend/LandingPageRoleServiceTest.php | 53 ++
.../Frontend/LandingPageServiceTest.php | 57 ++
.../LandingPageTeacherSummaryServiceTest.php | 43 +
.../Frontend/ProfileIconServiceTest.php | 37 +
.../Frontend/StaticPageServiceTest.php | 32 +
.../Messaging/MessageCommandServiceTest.php | 72 ++
.../Messaging/MessageQueryServiceTest.php | 58 ++
.../Messaging/MessageRecipientServiceTest.php | 111 +++
.../Navigation/NavBuilderServiceTest.php | 56 ++
.../Services/Navigation/NavbarServiceTest.php | 49 +
.../Phone/PhoneFormatterServiceTest.php | 26 +
.../Policy/PolicyContentServiceTest.php | 23 +
.../PreferencesCommandServiceTest.php | 30 +
.../PreferencesOptionsServiceTest.php | 22 +
.../PreferencesQueryServiceTest.php | 54 ++
.../School/SemesterSelectionServiceTest.php | 7 +-
.../Security/IpBanCommandServiceTest.php | 48 +
.../Security/IpBanQueryServiceTest.php | 35 +
.../Services/Settings/SettingsServiceTest.php | 35 +
.../Staff/StaffCommandServiceTest.php | 60 ++
.../Services/Staff/StaffQueryServiceTest.php | 49 +
.../Support/ContactMessageServiceTest.php | 27 +
.../Support/SupportRequestServiceTest.php | 55 ++
.../System/CleanupSchedulerServiceTest.php | 35 +
.../System/HealthCheckServiceTest.php | 22 +
tests/Unit/Services/Ui/UiStyleServiceTest.php | 44 +
421 files changed, 12009 insertions(+), 5211 deletions(-)
create mode 100644 app/Console/Commands/ImportUsers.php
create mode 100644 app/Http/Controllers/Api/Attendance/LateSlipLogsController.php
create mode 100644 app/Http/Controllers/Api/Auth/IpBanController.php
delete mode 100644 app/Http/Controllers/Api/Auth/SessionTimeoutController.php
create mode 100644 app/Http/Controllers/Api/Classes/ClassController.php
create mode 100644 app/Http/Controllers/Api/Frontend/FrontendController.php
create mode 100644 app/Http/Controllers/Api/Frontend/InfoIconController.php
create mode 100644 app/Http/Controllers/Api/Frontend/LandingPageController.php
create mode 100644 app/Http/Controllers/Api/Frontend/PageController.php
create mode 100644 app/Http/Controllers/Api/Messaging/MessagesController.php
create mode 100644 app/Http/Controllers/Api/Settings/PolicyController.php
create mode 100644 app/Http/Controllers/Api/Settings/PreferencesController.php
create mode 100644 app/Http/Controllers/Api/Settings/SettingsController.php
create mode 100644 app/Http/Controllers/Api/Staff/StaffController.php
create mode 100644 app/Http/Controllers/Api/Support/ContactController.php
create mode 100644 app/Http/Controllers/Api/Support/SupportController.php
create mode 100644 app/Http/Controllers/Api/System/DashboardController.php
create mode 100644 app/Http/Controllers/Api/System/DatabaseHealthController.php
create mode 100644 app/Http/Controllers/Api/System/HealthController.php
create mode 100644 app/Http/Controllers/Api/System/NavBuilderController.php
create mode 100644 app/Http/Controllers/Api/Ui/UiController.php
create mode 100644 app/Http/Controllers/Api/Utilities/PhoneFormatterController.php
delete mode 100755 app/Http/Controllers/View/ClassController.php
delete mode 100755 app/Http/Controllers/View/ClassPreparationController.php
delete mode 100755 app/Http/Controllers/View/DashboardRedirectController.php
create mode 100644 app/Http/Middleware/ApiDocsAuth.php
create mode 100644 app/Http/Middleware/ApiJwtAuth.php
create mode 100644 app/Http/Middleware/CleanupScheduler.php
create mode 100644 app/Http/Middleware/PrimeTimezone.php
create mode 100644 app/Http/Middleware/RequirePermission.php
create mode 100644 app/Http/Requests/Attendance/LateSlipLogIndexRequest.php
delete mode 100644 app/Http/Requests/Auth/SessionTimeoutCheckRequest.php
delete mode 100644 app/Http/Requests/Auth/SessionTimeoutConfigRequest.php
delete mode 100644 app/Http/Requests/Auth/SessionTimeoutPingRequest.php
create mode 100644 app/Http/Requests/ClassPreparation/ClassPreparationAdjustmentRequest.php
create mode 100644 app/Http/Requests/ClassPreparation/ClassPreparationIndexRequest.php
create mode 100644 app/Http/Requests/ClassPreparation/ClassPreparationMarkPrintedRequest.php
create mode 100644 app/Http/Requests/ClassPreparation/ClassPreparationPrintRequest.php
create mode 100644 app/Http/Requests/Classes/ClassSectionIndexRequest.php
create mode 100644 app/Http/Requests/Classes/ClassSectionStoreRequest.php
create mode 100644 app/Http/Requests/Classes/ClassSectionUpdateRequest.php
create mode 100644 app/Http/Requests/Frontend/ContactSubmitRequest.php
create mode 100644 app/Http/Requests/Frontend/LandingTeacherRequest.php
create mode 100644 app/Http/Requests/Messaging/MessageIndexRequest.php
create mode 100644 app/Http/Requests/Messaging/MessageSendRequest.php
create mode 100644 app/Http/Requests/Messaging/MessageUpdateRequest.php
create mode 100644 app/Http/Requests/Preferences/PreferencesIndexRequest.php
create mode 100644 app/Http/Requests/Preferences/PreferencesUpsertRequest.php
create mode 100644 app/Http/Requests/Security/IpBanIndexRequest.php
create mode 100644 app/Http/Requests/Security/IpBanRequest.php
create mode 100644 app/Http/Requests/Security/IpUnbanRequest.php
create mode 100644 app/Http/Requests/Settings/PolicyShowRequest.php
create mode 100644 app/Http/Requests/Settings/SettingsUpdateRequest.php
create mode 100644 app/Http/Requests/Staff/StaffIndexRequest.php
create mode 100644 app/Http/Requests/Staff/StaffStoreRequest.php
create mode 100644 app/Http/Requests/Staff/StaffUpdateRequest.php
create mode 100644 app/Http/Requests/Support/ContactSendRequest.php
create mode 100644 app/Http/Requests/Support/SupportRequestIndexRequest.php
create mode 100644 app/Http/Requests/Support/SupportRequestStoreRequest.php
create mode 100644 app/Http/Requests/System/NavItemReorderRequest.php
create mode 100644 app/Http/Requests/System/NavItemStoreRequest.php
create mode 100644 app/Http/Requests/Ui/UiStyleRequest.php
create mode 100644 app/Http/Requests/Utilities/PhoneFormatRequest.php
create mode 100644 app/Http/Resources/Attendance/LateSlipLogCollection.php
create mode 100644 app/Http/Resources/Attendance/LateSlipLogResource.php
delete mode 100644 app/Http/Resources/Auth/SessionTimeoutConfigResource.php
delete mode 100644 app/Http/Resources/Auth/SessionTimeoutStatusResource.php
create mode 100644 app/Http/Resources/ClassPreparation/ClassPreparationPrintResource.php
create mode 100644 app/Http/Resources/ClassPreparation/ClassPreparationResultResource.php
create mode 100644 app/Http/Resources/Classes/ClassAttendanceResource.php
create mode 100644 app/Http/Resources/Classes/ClassAttendanceStudentResource.php
create mode 100644 app/Http/Resources/Classes/ClassSectionCollection.php
create mode 100644 app/Http/Resources/Classes/ClassSectionResource.php
create mode 100644 app/Http/Resources/Frontend/ContactSubmissionResource.php
create mode 100644 app/Http/Resources/Frontend/FrontendPageResource.php
create mode 100644 app/Http/Resources/Frontend/FrontendUserResource.php
create mode 100644 app/Http/Resources/Frontend/LandingPageResource.php
create mode 100644 app/Http/Resources/Frontend/PageContentResource.php
create mode 100644 app/Http/Resources/Frontend/ProfileIconResource.php
create mode 100644 app/Http/Resources/Messaging/MessageCollection.php
create mode 100644 app/Http/Resources/Messaging/MessageResource.php
create mode 100644 app/Http/Resources/Messaging/RecipientResource.php
create mode 100644 app/Http/Resources/Preferences/PreferencesCollection.php
create mode 100644 app/Http/Resources/Preferences/PreferencesListResource.php
create mode 100644 app/Http/Resources/Preferences/PreferencesResource.php
create mode 100644 app/Http/Resources/Security/IpBanCollection.php
create mode 100644 app/Http/Resources/Security/IpBanResource.php
create mode 100644 app/Http/Resources/Settings/PolicyResource.php
create mode 100644 app/Http/Resources/Settings/SettingsResource.php
create mode 100644 app/Http/Resources/Staff/StaffCollection.php
create mode 100644 app/Http/Resources/Staff/StaffResource.php
create mode 100644 app/Http/Resources/Support/ContactMessageResource.php
create mode 100644 app/Http/Resources/Support/SupportRequestCollection.php
create mode 100644 app/Http/Resources/Support/SupportRequestResource.php
create mode 100644 app/Http/Resources/System/DashboardRouteResource.php
create mode 100644 app/Http/Resources/System/HealthStatusResource.php
create mode 100644 app/Http/Resources/System/NavBuilderDataResource.php
create mode 100644 app/Http/Resources/System/NavItemResource.php
create mode 100644 app/Http/Resources/Ui/UiStyleResource.php
create mode 100644 app/Http/Resources/Utilities/PhoneFormatResource.php
create mode 100644 app/Models/SupportRequest.php
create mode 100644 app/Policies/ClassSectionPolicy.php
create mode 100644 app/Policies/IpAttemptPolicy.php
create mode 100644 app/Policies/LateSlipLogPolicy.php
create mode 100644 app/Policies/MessagePolicy.php
create mode 100644 app/Policies/NavItemPolicy.php
create mode 100644 app/Policies/PreferencesPolicy.php
create mode 100644 app/Policies/SettingPolicy.php
create mode 100644 app/Policies/StaffPolicy.php
create mode 100644 app/Policies/SupportRequestPolicy.php
create mode 100644 app/Services/Attendance/LateSlipLogCommandService.php
create mode 100644 app/Services/Attendance/LateSlipLogQueryService.php
delete mode 100644 app/Services/Auth/SessionActivityService.php
delete mode 100644 app/Services/Auth/SessionTimeoutConfigService.php
delete mode 100644 app/Services/Auth/SessionTimeoutService.php
create mode 100644 app/Services/Auth/UserRoleService.php
create mode 100644 app/Services/ClassPreparation/ClassPreparationAdjustmentWriterService.php
create mode 100644 app/Services/ClassPreparation/ClassPreparationPrintService.php
create mode 100644 app/Services/ClassSections/ClassAttendanceService.php
create mode 100644 app/Services/ClassSections/ClassSectionCommandService.php
create mode 100644 app/Services/ClassSections/ClassSectionQueryService.php
create mode 100644 app/Services/ClassSections/ClassSectionSeedService.php
create mode 100644 app/Services/Dashboard/DashboardRouteService.php
create mode 100644 app/Services/Frontend/ContactSubmissionService.php
create mode 100644 app/Services/Frontend/FrontendPageService.php
create mode 100644 app/Services/Frontend/LandingPageContextService.php
create mode 100644 app/Services/Frontend/LandingPageParentDashboardService.php
create mode 100644 app/Services/Frontend/LandingPageRoleService.php
create mode 100644 app/Services/Frontend/LandingPageService.php
create mode 100644 app/Services/Frontend/LandingPageTeacherSummaryService.php
create mode 100644 app/Services/Frontend/ProfileIconService.php
create mode 100644 app/Services/Frontend/StaticPageService.php
create mode 100644 app/Services/Messaging/MessageCommandService.php
create mode 100644 app/Services/Messaging/MessageQueryService.php
create mode 100644 app/Services/Messaging/MessageRecipientService.php
create mode 100644 app/Services/Navigation/NavBuilderService.php
create mode 100644 app/Services/Navigation/NavbarService.php
create mode 100644 app/Services/Phone/PhoneFormatterService.php
create mode 100644 app/Services/Policy/PolicyContentService.php
create mode 100644 app/Services/Preferences/PreferencesCommandService.php
create mode 100644 app/Services/Preferences/PreferencesOptionsService.php
create mode 100644 app/Services/Preferences/PreferencesQueryService.php
create mode 100644 app/Services/Security/IpBanCommandService.php
create mode 100644 app/Services/Security/IpBanQueryService.php
create mode 100644 app/Services/Settings/SettingsService.php
create mode 100644 app/Services/Staff/StaffCommandService.php
create mode 100644 app/Services/Staff/StaffQueryService.php
create mode 100644 app/Services/Support/ContactMessageService.php
create mode 100644 app/Services/Support/SupportRequestService.php
create mode 100644 app/Services/System/CleanupSchedulerService.php
create mode 100644 app/Services/System/DatabaseHealthService.php
create mode 100644 app/Services/System/HealthCheckService.php
create mode 100644 app/Services/Ui/UiStyleService.php
delete mode 100644 app/old/ApiClient.php
delete mode 100644 app/old/ClassController.php
delete mode 100644 app/old/ContactController.php
delete mode 100644 app/old/DashboardRedirectController.php
delete mode 100644 app/old/DocsController.php
delete mode 100644 app/old/Filters/.gitkeep
delete mode 100644 app/old/Filters/ApiAuthFilter.php
delete mode 100644 app/old/Filters/ApiDocsAuthFilter.php
delete mode 100644 app/old/Filters/AuthFilter.php
delete mode 100644 app/old/Filters/CleanupScheduler.php
delete mode 100644 app/old/Filters/PermissionFilter.php
delete mode 100644 app/old/Filters/TimezoneFilter.php
delete mode 100644 app/old/FrontendController.php
delete mode 100644 app/old/HealthController.php
delete mode 100644 app/old/InfoIconController.php
delete mode 100644 app/old/IpBanController.php
delete mode 100644 app/old/LandingPageController.php
delete mode 100644 app/old/LateSlipLogsController.php
delete mode 100644 app/old/MessagesController.php
delete mode 100644 app/old/NavBuilderController.php
delete mode 100644 app/old/NavbarService.php
delete mode 100644 app/old/PageController.php
delete mode 100644 app/old/PhoneFormatterService.php
delete mode 100644 app/old/PolicyController.php
delete mode 100644 app/old/PreferencesController.php
delete mode 100644 app/old/RFIDController.php
delete mode 100644 app/old/SendSMSController.php
delete mode 100644 app/old/SettingsController.php
delete mode 100644 app/old/StaffController.php
delete mode 100644 app/old/StatsController.php
delete mode 100644 app/old/SupportController.php
delete mode 100644 app/old/TestDBController.php
delete mode 100644 app/old/TimeService.php
delete mode 100644 app/old/UiController.php
create mode 100644 database/migrations/2026_03_11_205736_create_students_table.php
create mode 100644 database/migrations/2026_03_11_205736_create_teachers_table.php
create mode 100644 database/migrations/2026_03_11_205737_create_attendance_data_table.php
create mode 100644 public/html/privacy_policy.html
create mode 100644 public/html/terms_of_service.html
create mode 100644 resources/policies/picture_policy.html
create mode 100644 resources/policies/school_policy.html
create mode 100644 tests/Feature/Api/V1/Attendance/LateSlipLogsControllerTest.php
create mode 100644 tests/Feature/Api/V1/Auth/IpBanControllerTest.php
delete mode 100644 tests/Feature/Api/V1/Auth/SessionTimeoutControllerTest.php
create mode 100644 tests/Feature/Api/V1/Classes/ClassSectionControllerTest.php
create mode 100644 tests/Feature/Api/V1/Frontend/FrontendControllerTest.php
create mode 100644 tests/Feature/Api/V1/Frontend/InfoIconControllerTest.php
create mode 100644 tests/Feature/Api/V1/Frontend/LandingPageControllerTest.php
create mode 100644 tests/Feature/Api/V1/Frontend/PageControllerTest.php
create mode 100644 tests/Feature/Api/V1/Messaging/MessagesControllerTest.php
create mode 100644 tests/Feature/Api/V1/Settings/PolicyControllerTest.php
create mode 100644 tests/Feature/Api/V1/Settings/PreferencesControllerTest.php
create mode 100644 tests/Feature/Api/V1/Settings/SettingsControllerTest.php
create mode 100644 tests/Feature/Api/V1/Staff/StaffControllerTest.php
create mode 100644 tests/Feature/Api/V1/Support/ContactControllerTest.php
create mode 100644 tests/Feature/Api/V1/Support/SupportControllerTest.php
create mode 100644 tests/Feature/Api/V1/System/DashboardControllerTest.php
create mode 100644 tests/Feature/Api/V1/System/DatabaseHealthControllerTest.php
create mode 100644 tests/Feature/Api/V1/System/HealthControllerTest.php
create mode 100644 tests/Feature/Api/V1/System/NavBuilderControllerTest.php
create mode 100644 tests/Feature/Api/V1/Ui/UiControllerTest.php
create mode 100644 tests/Feature/Api/V1/Utilities/PhoneFormatterControllerTest.php
create mode 100644 tests/Feature/Middleware/RequirePermissionMiddlewareTest.php
create mode 100644 tests/Unit/Resources/Frontend/FrontendPageResourceTest.php
create mode 100644 tests/Unit/Resources/Frontend/PageContentResourceTest.php
create mode 100644 tests/Unit/Resources/Messaging/MessageResourceTest.php
create mode 100644 tests/Unit/Resources/Messaging/RecipientResourceTest.php
create mode 100644 tests/Unit/Resources/Settings/SettingsResourceTest.php
create mode 100644 tests/Unit/Resources/Staff/StaffResourceTest.php
create mode 100644 tests/Unit/Resources/Support/ContactMessageResourceTest.php
create mode 100644 tests/Unit/Resources/Support/SupportRequestResourceTest.php
create mode 100644 tests/Unit/Resources/Ui/UiStyleResourceTest.php
create mode 100644 tests/Unit/Services/Attendance/LateSlipLogCommandServiceTest.php
create mode 100644 tests/Unit/Services/Attendance/LateSlipLogQueryServiceTest.php
delete mode 100644 tests/Unit/Services/Auth/SessionActivityServiceTest.php
delete mode 100644 tests/Unit/Services/Auth/SessionTimeoutConfigServiceTest.php
delete mode 100644 tests/Unit/Services/Auth/SessionTimeoutServiceTest.php
create mode 100644 tests/Unit/Services/Auth/UserRoleServiceTest.php
create mode 100644 tests/Unit/Services/ClassPreparation/ClassPreparationAdjustmentWriterServiceTest.php
create mode 100644 tests/Unit/Services/ClassPreparation/ClassPreparationPrintServiceTest.php
create mode 100644 tests/Unit/Services/ClassSections/ClassAttendanceServiceTest.php
create mode 100644 tests/Unit/Services/ClassSections/ClassSectionCommandServiceTest.php
create mode 100644 tests/Unit/Services/ClassSections/ClassSectionQueryServiceTest.php
create mode 100644 tests/Unit/Services/ClassSections/ClassSectionSeedServiceTest.php
create mode 100644 tests/Unit/Services/Dashboard/DashboardRouteServiceTest.php
create mode 100644 tests/Unit/Services/Frontend/ContactSubmissionServiceTest.php
create mode 100644 tests/Unit/Services/Frontend/FrontendPageServiceTest.php
create mode 100644 tests/Unit/Services/Frontend/LandingPageContextServiceTest.php
create mode 100644 tests/Unit/Services/Frontend/LandingPageParentDashboardServiceTest.php
create mode 100644 tests/Unit/Services/Frontend/LandingPageRoleServiceTest.php
create mode 100644 tests/Unit/Services/Frontend/LandingPageServiceTest.php
create mode 100644 tests/Unit/Services/Frontend/LandingPageTeacherSummaryServiceTest.php
create mode 100644 tests/Unit/Services/Frontend/ProfileIconServiceTest.php
create mode 100644 tests/Unit/Services/Frontend/StaticPageServiceTest.php
create mode 100644 tests/Unit/Services/Messaging/MessageCommandServiceTest.php
create mode 100644 tests/Unit/Services/Messaging/MessageQueryServiceTest.php
create mode 100644 tests/Unit/Services/Messaging/MessageRecipientServiceTest.php
create mode 100644 tests/Unit/Services/Navigation/NavBuilderServiceTest.php
create mode 100644 tests/Unit/Services/Navigation/NavbarServiceTest.php
create mode 100644 tests/Unit/Services/Phone/PhoneFormatterServiceTest.php
create mode 100644 tests/Unit/Services/Policy/PolicyContentServiceTest.php
create mode 100644 tests/Unit/Services/Preferences/PreferencesCommandServiceTest.php
create mode 100644 tests/Unit/Services/Preferences/PreferencesOptionsServiceTest.php
create mode 100644 tests/Unit/Services/Preferences/PreferencesQueryServiceTest.php
create mode 100644 tests/Unit/Services/Security/IpBanCommandServiceTest.php
create mode 100644 tests/Unit/Services/Security/IpBanQueryServiceTest.php
create mode 100644 tests/Unit/Services/Settings/SettingsServiceTest.php
create mode 100644 tests/Unit/Services/Staff/StaffCommandServiceTest.php
create mode 100644 tests/Unit/Services/Staff/StaffQueryServiceTest.php
create mode 100644 tests/Unit/Services/Support/ContactMessageServiceTest.php
create mode 100644 tests/Unit/Services/Support/SupportRequestServiceTest.php
create mode 100644 tests/Unit/Services/System/CleanupSchedulerServiceTest.php
create mode 100644 tests/Unit/Services/System/HealthCheckServiceTest.php
create mode 100644 tests/Unit/Services/Ui/UiStyleServiceTest.php
diff --git a/app/Console/Commands/ImportUsers.php b/app/Console/Commands/ImportUsers.php
new file mode 100644
index 00000000..0ad21819
--- /dev/null
+++ b/app/Console/Commands/ImportUsers.php
@@ -0,0 +1,30 @@
+authorize('viewAny', LateSlipLog::class);
+
+ $filters = $request->validated();
+ $page = (int) ($filters['page'] ?? 1);
+ $perPage = (int) ($filters['per_page'] ?? 50);
+
+ $logs = $this->queryService->paginate($filters, $page, $perPage);
+ $collection = new LateSlipLogCollection($logs);
+
+ return $this->success([
+ 'logs' => $collection->toArray($request),
+ 'meta' => $collection->with($request)['meta'],
+ ]);
+ }
+
+ public function show(int $id): JsonResponse
+ {
+ $log = $this->queryService->find($id);
+ if (!$log) {
+ return $this->error('Late slip log not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ $this->authorize('view', $log);
+
+ return $this->success([
+ 'log' => new LateSlipLogResource($log),
+ ]);
+ }
+
+ public function destroy(int $id): JsonResponse
+ {
+ $log = $this->queryService->find($id);
+ if (!$log) {
+ return $this->error('Late slip log not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ $this->authorize('delete', $log);
+
+ try {
+ $deleted = $this->commandService->delete($log);
+ } catch (\Throwable $e) {
+ Log::error('Late slip log delete failed: ' . $e->getMessage());
+ return $this->error('Unable to delete late slip log.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ if (!$deleted) {
+ return $this->error('Unable to delete late slip log.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ return $this->success(null, 'Late slip log deleted.');
+ }
+}
diff --git a/app/Http/Controllers/Api/Auth/IpBanController.php b/app/Http/Controllers/Api/Auth/IpBanController.php
new file mode 100644
index 00000000..59dcf7ae
--- /dev/null
+++ b/app/Http/Controllers/Api/Auth/IpBanController.php
@@ -0,0 +1,115 @@
+authorize('viewAny', IpAttempt::class);
+
+ $filters = $request->validated();
+ $page = (int) ($filters['page'] ?? 1);
+ $perPage = (int) ($filters['per_page'] ?? 20);
+
+ $bans = $this->queryService->paginate($filters, $page, $perPage);
+ $collection = new IpBanCollection($bans);
+
+ return $this->success([
+ 'bans' => $collection->toArray($request),
+ 'meta' => $collection->with($request)['meta'],
+ ]);
+ }
+
+ public function show(int $id): JsonResponse
+ {
+ $ban = $this->queryService->find($id);
+ if (!$ban) {
+ return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ $this->authorize('view', $ban);
+
+ return $this->success([
+ 'ban' => new IpBanResource($ban),
+ ]);
+ }
+
+ public function ban(IpBanRequest $request): JsonResponse
+ {
+ $this->authorize('create', IpAttempt::class);
+
+ $validated = $request->validated();
+ $id = $validated['id'] ?? null;
+ $ip = $validated['ip'] ?? null;
+ $hours = (int) ($validated['hours'] ?? 24);
+
+ try {
+ $ban = $this->commandService->banNow($id ? (int) $id : null, $ip ? (string) $ip : null, $hours);
+ } catch (\Throwable $e) {
+ Log::error('IP ban failed: ' . $e->getMessage());
+ return $this->error('Unable to ban IP.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ if (!$ban) {
+ return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ return $this->success([
+ 'ban' => new IpBanResource($ban),
+ ], 'IP banned.');
+ }
+
+ public function unban(IpUnbanRequest $request): JsonResponse
+ {
+ $this->authorize('update', IpAttempt::class);
+
+ $validated = $request->validated();
+ $all = filter_var($validated['all'] ?? false, FILTER_VALIDATE_BOOLEAN);
+
+ try {
+ if ($all) {
+ $count = $this->commandService->unbanAll();
+ return $this->success([
+ 'count' => $count,
+ ], $count . ' IP(s) unbanned.');
+ }
+
+ $ban = $this->commandService->unbanOne(
+ isset($validated['id']) ? (int) $validated['id'] : null,
+ $validated['ip'] ?? null
+ );
+ } catch (\Throwable $e) {
+ Log::error('IP unban failed: ' . $e->getMessage());
+ return $this->error('Unable to unban IP.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ if (!$ban) {
+ return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ return $this->success([
+ 'ban' => new IpBanResource($ban),
+ ], 'IP unbanned.');
+ }
+}
diff --git a/app/Http/Controllers/Api/Auth/SessionTimeoutController.php b/app/Http/Controllers/Api/Auth/SessionTimeoutController.php
deleted file mode 100644
index 94a2744c..00000000
--- a/app/Http/Controllers/Api/Auth/SessionTimeoutController.php
+++ /dev/null
@@ -1,67 +0,0 @@
-configService->config();
-
- return $this->success([
- 'config' => new SessionTimeoutConfigResource($config),
- ]);
- }
-
- public function check(SessionTimeoutCheckRequest $request): JsonResponse
- {
- $result = $this->timeoutService->checkTimeout();
-
- if (($result['status'] ?? '') === 'expired') {
- return $this->error(
- $result['message'] ?? 'Session expired.',
- Response::HTTP_UNAUTHORIZED,
- (new SessionTimeoutStatusResource($result))->toArray($request)
- );
- }
-
- return $this->success([
- 'session' => new SessionTimeoutStatusResource($result),
- ]);
- }
-
- public function ping(SessionTimeoutPingRequest $request): JsonResponse
- {
- $result = $this->timeoutService->pingActivity();
-
- if (($result['status'] ?? '') === 'expired') {
- return $this->error(
- $result['message'] ?? 'Session expired.',
- Response::HTTP_UNAUTHORIZED,
- (new SessionTimeoutStatusResource($result))->toArray($request)
- );
- }
-
- return $this->success([
- 'session' => new SessionTimeoutStatusResource($result),
- ]);
- }
-}
diff --git a/app/Http/Controllers/Api/BaseApiController.php b/app/Http/Controllers/Api/BaseApiController.php
index c9d7adce..aa93b2ab 100755
--- a/app/Http/Controllers/Api/BaseApiController.php
+++ b/app/Http/Controllers/Api/BaseApiController.php
@@ -229,7 +229,7 @@ class BaseApiController extends Controller
protected function getCurrentUserId(): ?int
{
- $userId = (int) (session()->get('user_id') ?? 0);
+ $userId = (int) (auth()->id() ?? 0);
return $userId > 0 ? $userId : null;
}
@@ -251,23 +251,20 @@ class BaseApiController extends Controller
$name = $row['email'] ?? 'User #' . $userId;
}
- $roles = (array) (session()->get('roles') ?? []);
- if (empty($roles)) {
- try {
- $roles = DB::table('user_roles ur')
- ->select('LOWER(r.name) AS name')
- ->join('roles r', 'r.id', '=', 'ur.role_id')
- ->where('ur.user_id', $userId)
- ->whereNull('ur.deleted_at')
- ->pluck('name')
- ->map(fn($name) => strtolower((string) $name))
- ->unique()
- ->values()
- ->toArray();
- } catch (\Throwable $e) {
- Log::error('Unable to load user roles: ' . $e->getMessage());
- $roles = [];
- }
+ try {
+ $roles = DB::table('user_roles ur')
+ ->select('LOWER(r.name) AS name')
+ ->join('roles r', 'r.id', '=', 'ur.role_id')
+ ->where('ur.user_id', $userId)
+ ->whereNull('ur.deleted_at')
+ ->pluck('name')
+ ->map(fn($name) => strtolower((string) $name))
+ ->unique()
+ ->values()
+ ->toArray();
+ } catch (\Throwable $e) {
+ Log::error('Unable to load user roles: ' . $e->getMessage());
+ $roles = [];
}
return (object) [
diff --git a/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php b/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php
index 584ff75d..da68969b 100644
--- a/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php
+++ b/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php
@@ -3,9 +3,16 @@
namespace App\Http\Controllers\Api\ClassPreparation;
use App\Http\Controllers\Api\BaseApiController;
+use App\Http\Requests\ClassPreparation\ClassPreparationAdjustmentRequest;
+use App\Http\Requests\ClassPreparation\ClassPreparationIndexRequest;
+use App\Http\Requests\ClassPreparation\ClassPreparationMarkPrintedRequest;
+use App\Http\Requests\ClassPreparation\ClassPreparationPrintRequest;
+use App\Http\Resources\ClassPreparation\ClassPreparationPrintResource;
+use App\Http\Resources\ClassPreparation\ClassPreparationResultResource;
use App\Services\ClassPreparation\ClassPreparationService;
use Illuminate\Http\JsonResponse;
-use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
+use Symfony\Component\HttpFoundation\Response;
class ClassPreparationController extends BaseApiController
{
@@ -17,7 +24,7 @@ class ClassPreparationController extends BaseApiController
$this->service = $service;
}
- public function index(Request $request): JsonResponse
+ public function index(ClassPreparationIndexRequest $request): JsonResponse
{
$schoolYear = (string) ($request->query('school_year') ?? '');
$semester = (string) ($request->query('semester') ?? '');
@@ -27,34 +34,63 @@ class ClassPreparationController extends BaseApiController
$semester !== '' ? $semester : null
);
- return response()->json([
- 'ok' => true,
+ return $this->success([
'schoolYear' => $payload['schoolYear'],
'semester' => $payload['semester'],
- 'results' => $payload['results'],
+ 'results' => ClassPreparationResultResource::collection($payload['results']),
'totals' => $payload['totals'],
'shortages' => $payload['shortages'],
]);
}
- public function markPrinted(Request $request): JsonResponse
+ public function markPrinted(ClassPreparationMarkPrintedRequest $request): JsonResponse
{
- $schoolYear = (string) ($request->input('school_year') ?? '');
- $semester = (string) ($request->input('semester') ?? '');
- $ids = $request->input('class_section_ids', []);
-
- if ($schoolYear === '' || !is_array($ids)) {
- return response()->json([
- 'ok' => false,
- 'message' => 'school_year and class_section_ids are required.',
- ], 422);
- }
+ $validated = $request->validated();
+ $schoolYear = (string) ($validated['school_year'] ?? '');
+ $semester = (string) ($validated['semester'] ?? '');
+ $ids = $validated['class_section_ids'] ?? [];
$count = $this->service->markPrinted($schoolYear, $semester, $ids);
- return response()->json([
- 'ok' => true,
+ return $this->success([
'updated' => $count,
- ]);
+ ], 'Class preparation logs updated.');
+ }
+
+ public function saveAdjustments(ClassPreparationAdjustmentRequest $request): JsonResponse
+ {
+ $validated = $request->validated();
+ $classSectionId = (string) $validated['class_section_id'];
+ $schoolYear = (string) ($validated['school_year'] ?? '');
+
+ try {
+ $count = $this->service->saveAdjustments($classSectionId, $schoolYear, $validated['adjustments']);
+ } catch (\Throwable $e) {
+ Log::error('Class prep adjustment save failed: ' . $e->getMessage());
+ return $this->error('Unable to save adjustments.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ return $this->success([
+ 'updated' => $count,
+ ], 'Adjustments saved.');
+ }
+
+ public function print(ClassPreparationPrintRequest $request): JsonResponse
+ {
+ $validated = $request->validated();
+
+ $payload = $this->service->printPrep(
+ (string) $validated['class_section_id'],
+ (string) $validated['school_year'],
+ (string) ($validated['semester'] ?? '')
+ );
+
+ if (empty($payload['ok'])) {
+ return $this->error('Unable to log class preparation print.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ return $this->success([
+ 'print' => new ClassPreparationPrintResource($payload),
+ ], 'Class preparation logged.');
}
}
diff --git a/app/Http/Controllers/Api/Classes/ClassController.php b/app/Http/Controllers/Api/Classes/ClassController.php
new file mode 100644
index 00000000..e5f50d02
--- /dev/null
+++ b/app/Http/Controllers/Api/Classes/ClassController.php
@@ -0,0 +1,136 @@
+authorize('viewAny', ClassSection::class);
+
+ $filters = $request->validated();
+ $page = (int) ($filters['page'] ?? 1);
+ $perPage = (int) ($filters['per_page'] ?? 20);
+
+ $sections = $this->queryService->list($filters, $page, $perPage);
+ $collection = new ClassSectionCollection($sections);
+
+ return $this->success([
+ 'sections' => $collection->toArray($request),
+ 'meta' => $collection->with($request)['meta'],
+ ]);
+ }
+
+ public function show(ClassSection $classSection): JsonResponse
+ {
+ $this->authorize('view', $classSection);
+
+ $section = $this->queryService->find((int) $classSection->id);
+
+ if (!$section) {
+ return $this->error('Class section not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ return $this->success([
+ 'section' => new ClassSectionResource($section),
+ ]);
+ }
+
+ public function store(ClassSectionStoreRequest $request): JsonResponse
+ {
+ $this->authorize('create', ClassSection::class);
+
+ $section = $this->commandService->create($request->validated());
+
+ return $this->success([
+ 'section' => new ClassSectionResource($section),
+ ], 'Class section created.', Response::HTTP_CREATED);
+ }
+
+ public function update(ClassSectionUpdateRequest $request, ClassSection $classSection): JsonResponse
+ {
+ $this->authorize('update', $classSection);
+
+ $section = $this->commandService->update($classSection, $request->validated());
+
+ return $this->success([
+ 'section' => new ClassSectionResource($section),
+ ], 'Class section updated.');
+ }
+
+ public function destroy(ClassSection $classSection): JsonResponse
+ {
+ $this->authorize('delete', $classSection);
+
+ if (!$this->commandService->delete($classSection)) {
+ return $this->error('Class section not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ return $this->success(null, 'Class section deleted.');
+ }
+
+ public function attendance(Request $request, int $classSectionId): JsonResponse
+ {
+ $teacherId = (int) ($request->user()?->id ?? 0);
+ if ($teacherId <= 0) {
+ return $this->error('Missing teacher authentication.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ $payload = $this->attendanceService->getAttendancePayload(
+ $classSectionId,
+ $teacherId,
+ $request->query('school_year'),
+ $request->query('semester')
+ );
+
+ if (empty($payload['students'])) {
+ return $this->error('No data found for this class.', Response::HTTP_NOT_FOUND);
+ }
+
+ return $this->success([
+ 'attendance' => new ClassAttendanceResource($payload),
+ ]);
+ }
+
+ public function seedDefaults(): JsonResponse
+ {
+ $this->authorize('seedDefaults', ClassSection::class);
+
+ try {
+ $created = $this->seedService->seedDefaults();
+ } catch (\Throwable $e) {
+ Log::error('Seeding default classes failed: ' . $e->getMessage());
+ return $this->error('Unable to seed default classes.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ return $this->success([
+ 'created' => $created,
+ ], 'Default classes seeded.');
+ }
+}
diff --git a/app/Http/Controllers/Api/Finance/PaypalPaymentController.php b/app/Http/Controllers/Api/Finance/PaypalPaymentController.php
index cfaf9530..f9a93879 100644
--- a/app/Http/Controllers/Api/Finance/PaypalPaymentController.php
+++ b/app/Http/Controllers/Api/Finance/PaypalPaymentController.php
@@ -34,7 +34,8 @@ class PaypalPaymentController extends BaseApiController
$payload = $request->validated();
$this->service->executePayment(
(string) $payload['payer_id'],
- $payload['paypal_payment_id'] ?? null
+ $payload['paypal_payment_id'] ?? null,
+ isset($payload['payment_id']) ? (int) $payload['payment_id'] : null
);
return response()->json(['ok' => true]);
diff --git a/app/Http/Controllers/Api/Frontend/FrontendController.php b/app/Http/Controllers/Api/Frontend/FrontendController.php
new file mode 100644
index 00000000..e1ce8cb7
--- /dev/null
+++ b/app/Http/Controllers/Api/Frontend/FrontendController.php
@@ -0,0 +1,72 @@
+success([
+ 'page' => new FrontendPageResource($this->pageService->page('index')),
+ ]);
+ }
+
+ public function facility(): JsonResponse
+ {
+ return $this->success([
+ 'page' => new FrontendPageResource($this->pageService->page('facility')),
+ ]);
+ }
+
+ public function team(): JsonResponse
+ {
+ return $this->success([
+ 'page' => new FrontendPageResource($this->pageService->page('team')),
+ ]);
+ }
+
+ public function callToAction(): JsonResponse
+ {
+ return $this->success([
+ 'page' => new FrontendPageResource($this->pageService->page('call_to_action')),
+ ]);
+ }
+
+ public function testimonial(): JsonResponse
+ {
+ return $this->success([
+ 'page' => new FrontendPageResource($this->pageService->page('testimonial')),
+ ]);
+ }
+
+ public function notFound(): JsonResponse
+ {
+ return $this->success([
+ 'page' => new FrontendPageResource($this->pageService->page('not_found')),
+ ]);
+ }
+
+ public function fetchUser(): JsonResponse
+ {
+ $user = auth()->user();
+ if (!$user) {
+ return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ return $this->success([
+ 'user' => new FrontendUserResource($user),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/Frontend/InfoIconController.php b/app/Http/Controllers/Api/Frontend/InfoIconController.php
new file mode 100644
index 00000000..45acf53a
--- /dev/null
+++ b/app/Http/Controllers/Api/Frontend/InfoIconController.php
@@ -0,0 +1,31 @@
+id() ?? 0);
+ if ($userId <= 0) {
+ return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ $payload = $this->service->buildForUser($userId);
+
+ return $this->success([
+ 'profile' => new ProfileIconResource($payload),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/Frontend/LandingPageController.php b/app/Http/Controllers/Api/Frontend/LandingPageController.php
new file mode 100644
index 00000000..121d3822
--- /dev/null
+++ b/app/Http/Controllers/Api/Frontend/LandingPageController.php
@@ -0,0 +1,86 @@
+service->dashboardForUser(
+ $request->user(),
+ $request->validated()['class_section_id'] ?? null
+ );
+
+ return $this->success([
+ 'landing' => new LandingPageResource($payload),
+ ]);
+ }
+
+ public function teacher(LandingTeacherRequest $request): JsonResponse
+ {
+ $payload = $this->service->teacher(
+ $request->user(),
+ $request->validated()['class_section_id'] ?? null
+ );
+
+ return $this->success([
+ 'landing' => new LandingPageResource($payload),
+ ]);
+ }
+
+ public function parent(): JsonResponse
+ {
+ $payload = $this->service->parent(auth()->user());
+
+ return $this->success([
+ 'landing' => new LandingPageResource($payload),
+ ]);
+ }
+
+ public function administrator(): JsonResponse
+ {
+ $payload = $this->service->administrator();
+
+ return $this->success([
+ 'landing' => new LandingPageResource($payload),
+ ]);
+ }
+
+ public function admin(): JsonResponse
+ {
+ $payload = $this->service->admin();
+
+ return $this->success([
+ 'landing' => new LandingPageResource($payload),
+ ]);
+ }
+
+ public function student(): JsonResponse
+ {
+ $payload = $this->service->student();
+
+ return $this->success([
+ 'landing' => new LandingPageResource($payload),
+ ]);
+ }
+
+ public function guest(): JsonResponse
+ {
+ $payload = $this->service->guest();
+
+ return $this->success([
+ 'landing' => new LandingPageResource($payload),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/Frontend/PageController.php b/app/Http/Controllers/Api/Frontend/PageController.php
new file mode 100644
index 00000000..86cf6a2c
--- /dev/null
+++ b/app/Http/Controllers/Api/Frontend/PageController.php
@@ -0,0 +1,62 @@
+renderStatic('privacy_policy.html', 'privacy_policy');
+ }
+
+ public function termsOfService(): JsonResponse
+ {
+ return $this->renderStatic('terms_of_service.html', 'terms_of_service');
+ }
+
+ public function helpCenter(): JsonResponse
+ {
+ return $this->renderStatic('help_center.html', 'help_center');
+ }
+
+ public function submitContact(ContactSubmitRequest $request): JsonResponse
+ {
+ $result = $this->contactService->submit($request->validated());
+
+ return $this->success([
+ 'submission' => new ContactSubmissionResource([
+ 'email' => $request->validated('email'),
+ 'email_sent' => $result['email_sent'],
+ 'contact_id' => $result['record']?->id,
+ ]),
+ ], 'Message received.', Response::HTTP_CREATED);
+ }
+
+ private function renderStatic(string $filename, string $type): JsonResponse
+ {
+ $result = $this->pageService->load($filename, $type);
+ if (!$result['ok']) {
+ return $this->error($result['error'], Response::HTTP_NOT_FOUND);
+ }
+
+ return $this->success([
+ 'page' => new PageContentResource($result),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/Messaging/MessagesController.php b/app/Http/Controllers/Api/Messaging/MessagesController.php
new file mode 100644
index 00000000..f9d51ba0
--- /dev/null
+++ b/app/Http/Controllers/Api/Messaging/MessagesController.php
@@ -0,0 +1,222 @@
+id() ?? 0);
+ if ($userId <= 0) {
+ return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ $filters = $request->validated();
+ $page = (int) ($filters['page'] ?? 1);
+ $perPage = (int) ($filters['per_page'] ?? 20);
+
+ $this->authorize('viewAny', Message::class);
+
+ $role = $this->queryService->getUserRoleName($userId);
+ $messages = $this->queryService->inbox($userId, $filters, $page, $perPage);
+ $collection = new MessageCollection($messages);
+ $meta = $collection->with($request)['meta'] ?? null;
+
+ return $this->success([
+ 'role' => $role,
+ 'messages' => $collection->toArray($request),
+ 'meta' => $meta,
+ ]);
+ }
+
+ public function inbox(MessageIndexRequest $request): JsonResponse
+ {
+ return $this->listMessages($request, 'inbox');
+ }
+
+ public function sent(MessageIndexRequest $request): JsonResponse
+ {
+ return $this->listMessages($request, 'sent');
+ }
+
+ public function drafts(MessageIndexRequest $request): JsonResponse
+ {
+ return $this->listMessages($request, 'drafts');
+ }
+
+ public function trash(MessageIndexRequest $request): JsonResponse
+ {
+ return $this->listMessages($request, 'trash');
+ }
+
+ public function show(int $id): JsonResponse
+ {
+ $message = $this->queryService->findForUser($id);
+ if (!$message) {
+ return $this->error('Message not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ $this->authorize('view', $message);
+
+ $userId = (int) (auth()->id() ?? 0);
+ if ($message->recipient_id === $userId) {
+ $this->commandService->markRead($message);
+ $message->refresh();
+ }
+
+ return $this->success([
+ 'message' => new MessageResource($message),
+ ]);
+ }
+
+ public function store(MessageSendRequest $request): JsonResponse
+ {
+ $userId = (int) (auth()->id() ?? 0);
+ if ($userId <= 0) {
+ return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ $this->authorize('create', Message::class);
+
+ try {
+ $message = $this->commandService->create($userId, $request->validated(), $request->file('attachment'));
+ } catch (\Throwable $e) {
+ Log::error('Message send failed: ' . $e->getMessage());
+ return $this->error($e->getMessage(), Response::HTTP_BAD_REQUEST);
+ }
+
+ return $this->success([
+ 'message' => new MessageResource($message->fresh(['sender', 'recipient'])),
+ ], 'Message sent.', Response::HTTP_CREATED);
+ }
+
+ public function receive(): JsonResponse
+ {
+ $userId = (int) (auth()->id() ?? 0);
+ if ($userId <= 0) {
+ return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ $messages = $this->queryService->receivedAndMarkRead($userId);
+
+ return $this->success([
+ 'messages' => (new MessageCollection(collect($messages)))->toArray(request()),
+ ]);
+ }
+
+ public function recipients(string $type): JsonResponse
+ {
+ $type = strtolower(trim($type));
+ $data = match ($type) {
+ 'teacher' => $this->recipientService->teachers(),
+ 'parent' => $this->recipientService->parents(),
+ default => null,
+ };
+
+ if ($data === null) {
+ return $this->error('Invalid recipient type.', Response::HTTP_BAD_REQUEST);
+ }
+
+ $payload = array_map(fn ($row) => (new RecipientResource($row))->toArray(request()), $data);
+
+ return $this->success([
+ 'recipients' => $payload,
+ ]);
+ }
+
+ public function update(MessageUpdateRequest $request, int $id): JsonResponse
+ {
+ $message = Message::query()->find($id);
+ if (!$message) {
+ return $this->error('Message not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ $this->authorize('update', $message);
+
+ try {
+ $updated = $this->commandService->update($message, $request->validated());
+ } catch (\Throwable $e) {
+ Log::error('Message update failed: ' . $e->getMessage());
+ return $this->error('Unable to update message.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ return $this->success([
+ 'message' => new MessageResource($updated->load(['sender', 'recipient'])),
+ ], 'Message updated.');
+ }
+
+ public function destroy(int $id): JsonResponse
+ {
+ $message = Message::query()->find($id);
+ if (!$message) {
+ return $this->error('Message not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ $this->authorize('delete', $message);
+
+ try {
+ $trashed = $this->commandService->trash($message);
+ } catch (\Throwable $e) {
+ Log::error('Message delete failed: ' . $e->getMessage());
+ return $this->error('Unable to delete message.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ if (!$trashed) {
+ return $this->error('Unable to delete message.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ return $this->success(null, 'Message deleted.');
+ }
+
+ private function listMessages(MessageIndexRequest $request, string $bucket): JsonResponse
+ {
+ $userId = (int) (auth()->id() ?? 0);
+ if ($userId <= 0) {
+ return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ $filters = $request->validated();
+ $page = (int) ($filters['page'] ?? 1);
+ $perPage = (int) ($filters['per_page'] ?? 20);
+
+ $this->authorize('viewAny', Message::class);
+
+ $paginator = match ($bucket) {
+ 'sent' => $this->queryService->sent($userId, $filters, $page, $perPage),
+ 'drafts' => $this->queryService->drafts($userId, $filters, $page, $perPage),
+ 'trash' => $this->queryService->trash($userId, $filters, $page, $perPage),
+ default => $this->queryService->inbox($userId, $filters, $page, $perPage),
+ };
+
+ $collection = new MessageCollection($paginator);
+ $meta = $collection->with($request)['meta'] ?? null;
+
+ return $this->success([
+ 'messages' => $collection->toArray($request),
+ 'meta' => $meta,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/Settings/PolicyController.php b/app/Http/Controllers/Api/Settings/PolicyController.php
new file mode 100644
index 00000000..6cb589a8
--- /dev/null
+++ b/app/Http/Controllers/Api/Settings/PolicyController.php
@@ -0,0 +1,43 @@
+respondPolicy('school');
+ }
+
+ public function picture(PolicyShowRequest $request): JsonResponse
+ {
+ return $this->respondPolicy('picture');
+ }
+
+ private function respondPolicy(string $type): JsonResponse
+ {
+ try {
+ $policy = $this->service->getPolicy($type);
+ } catch (\InvalidArgumentException $e) {
+ return $this->error('Policy not found.', Response::HTTP_NOT_FOUND);
+ } catch (\Throwable $e) {
+ return $this->error('Unable to load policy.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ return $this->success([
+ 'policy' => new PolicyResource($policy),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/Settings/PreferencesController.php b/app/Http/Controllers/Api/Settings/PreferencesController.php
new file mode 100644
index 00000000..bf6470aa
--- /dev/null
+++ b/app/Http/Controllers/Api/Settings/PreferencesController.php
@@ -0,0 +1,139 @@
+authorize('viewAny', Preferences::class);
+
+ $filters = $request->validated();
+ $page = (int) ($filters['page'] ?? 1);
+ $perPage = (int) ($filters['per_page'] ?? 20);
+
+ $rows = $this->queryService->paginate($filters, $page, $perPage);
+ $collection = new PreferencesCollection($rows);
+
+ return $this->success([
+ 'preferences' => $collection->toArray($request),
+ 'meta' => $collection->with($request)['meta'],
+ ]);
+ }
+
+ public function show(): JsonResponse
+ {
+ $userId = (int) (auth()->id() ?? 0);
+ if ($userId <= 0) {
+ return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ $payload = $this->queryService->getForUser($userId);
+
+ return $this->success([
+ 'preferences' => new PreferencesResource($payload['preferences']),
+ 'options' => $payload['options'],
+ ]);
+ }
+
+ public function showForUser(int $userId): JsonResponse
+ {
+ $row = Preferences::query()->where('user_id', $userId)->first();
+ if (!$row) {
+ return $this->error('Preferences not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ $this->authorize('view', $row);
+
+ $payload = $this->queryService->getForUser($userId);
+
+ return $this->success([
+ 'preferences' => new PreferencesResource($payload['preferences']),
+ 'options' => $payload['options'],
+ ]);
+ }
+
+ public function store(PreferencesUpsertRequest $request): JsonResponse
+ {
+ $userId = (int) (auth()->id() ?? 0);
+ if ($userId <= 0) {
+ return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ try {
+ $saved = $this->commandService->upsert($userId, $request->validated());
+ } catch (\Throwable $e) {
+ Log::error('Preferences save failed: ' . $e->getMessage());
+ return $this->error('Unable to save preferences.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ $payload = $this->queryService->getForUser($userId);
+
+ return $this->success([
+ 'preferences' => new PreferencesResource($payload['preferences']),
+ ], 'Preferences saved.', $saved->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
+ }
+
+ public function updateForUser(PreferencesUpsertRequest $request, int $userId): JsonResponse
+ {
+ $row = Preferences::query()->where('user_id', $userId)->first();
+ if ($row) {
+ $this->authorize('update', $row);
+ }
+
+ try {
+ $saved = $this->commandService->upsert($userId, $request->validated());
+ } catch (\Throwable $e) {
+ Log::error('Preferences update failed: ' . $e->getMessage());
+ return $this->error('Unable to save preferences.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ $payload = $this->queryService->getForUser($userId);
+
+ return $this->success([
+ 'preferences' => new PreferencesResource($payload['preferences']),
+ ], 'Preferences saved.', $saved->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
+ }
+
+ public function destroy(int $userId): JsonResponse
+ {
+ $row = Preferences::query()->where('user_id', $userId)->first();
+ if (!$row) {
+ return $this->error('Preferences not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ $this->authorize('delete', $row);
+
+ try {
+ $deleted = $this->commandService->delete($row);
+ } catch (\Throwable $e) {
+ Log::error('Preferences delete failed: ' . $e->getMessage());
+ return $this->error('Unable to delete preferences.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ if (!$deleted) {
+ return $this->error('Unable to delete preferences.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ return $this->success(null, 'Preferences deleted.');
+ }
+}
diff --git a/app/Http/Controllers/Api/Settings/SettingsController.php b/app/Http/Controllers/Api/Settings/SettingsController.php
new file mode 100644
index 00000000..fed17162
--- /dev/null
+++ b/app/Http/Controllers/Api/Settings/SettingsController.php
@@ -0,0 +1,51 @@
+authorize('viewAny', Setting::class);
+
+ return $this->success([
+ 'settings' => new SettingsResource($this->settingsService->get()),
+ ]);
+ }
+
+ public function update(SettingsUpdateRequest $request): JsonResponse
+ {
+ $settings = Setting::singleton();
+ $this->authorize('update', $settings);
+
+ $userId = (int) (auth()->id() ?? 0);
+ if ($userId <= 0) {
+ return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ try {
+ $updated = $this->settingsService->update($request->validated(), $userId);
+ } catch (\Throwable $e) {
+ Log::error('Settings update failed: ' . $e->getMessage());
+ return $this->error('Unable to update settings.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ return $this->success([
+ 'settings' => new SettingsResource($updated),
+ ], 'Settings updated.');
+ }
+}
diff --git a/app/Http/Controllers/Api/Staff/StaffController.php b/app/Http/Controllers/Api/Staff/StaffController.php
new file mode 100644
index 00000000..f0aa39aa
--- /dev/null
+++ b/app/Http/Controllers/Api/Staff/StaffController.php
@@ -0,0 +1,121 @@
+authorize('viewAny', Staff::class);
+
+ $filters = $request->validated();
+ $page = (int) ($filters['page'] ?? 1);
+ $perPage = (int) ($filters['per_page'] ?? 20);
+
+ $result = $this->queryService->paginate($filters, $page, $perPage);
+ $collection = new StaffCollection($result['paginator']);
+
+ return $this->success([
+ 'staff' => $collection->toArray($request),
+ 'meta' => $collection->with($request)['meta'] ?? null,
+ 'issues_count' => $result['issues_count'],
+ 'semester' => $result['semester'],
+ 'school_year' => $result['school_year'],
+ ]);
+ }
+
+ public function show(int $id): JsonResponse
+ {
+ $staff = $this->queryService->find($id);
+ if (!$staff) {
+ return $this->error('Staff member not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ $this->authorize('view', $staff);
+ $staff->school_id = \App\Models\User::getSchoolIdByUserId((int) ($staff->user_id ?? 0));
+
+ return $this->success([
+ 'staff' => new StaffResource($staff),
+ ]);
+ }
+
+ public function store(StaffStoreRequest $request): JsonResponse
+ {
+ $this->authorize('create', Staff::class);
+
+ try {
+ $staff = $this->commandService->create($request->validated());
+ } catch (\Throwable $e) {
+ Log::error('Staff create failed: ' . $e->getMessage());
+ return $this->error($e->getMessage(), Response::HTTP_BAD_REQUEST);
+ }
+
+ return $this->success([
+ 'staff' => new StaffResource($staff),
+ ], 'Staff member added.', Response::HTTP_CREATED);
+ }
+
+ public function update(StaffUpdateRequest $request, int $id): JsonResponse
+ {
+ $staff = $this->queryService->find($id);
+ if (!$staff) {
+ return $this->error('Staff member not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ $this->authorize('update', $staff);
+
+ try {
+ $updated = $this->commandService->update($staff, $request->validated());
+ } catch (\Throwable $e) {
+ Log::error('Staff update failed: ' . $e->getMessage());
+ return $this->error('Unable to update staff.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ return $this->success([
+ 'staff' => new StaffResource($updated),
+ ], 'Staff member updated.');
+ }
+
+ public function destroy(int $id): JsonResponse
+ {
+ $staff = $this->queryService->find($id);
+ if (!$staff) {
+ return $this->error('Staff member not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ $this->authorize('delete', $staff);
+
+ try {
+ $deleted = $this->commandService->delete($staff);
+ } catch (\Throwable $e) {
+ Log::error('Staff delete failed: ' . $e->getMessage());
+ return $this->error('Unable to delete staff.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ if (!$deleted) {
+ return $this->error('Unable to delete staff.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ return $this->success(null, 'Staff member deleted.');
+ }
+}
diff --git a/app/Http/Controllers/Api/Support/ContactController.php b/app/Http/Controllers/Api/Support/ContactController.php
new file mode 100644
index 00000000..8f40429d
--- /dev/null
+++ b/app/Http/Controllers/Api/Support/ContactController.php
@@ -0,0 +1,27 @@
+messageService->send($request->validated());
+
+ return $this->success([
+ 'contact' => new ContactMessageResource($result),
+ ], 'Message sent.', Response::HTTP_CREATED);
+ }
+}
diff --git a/app/Http/Controllers/Api/Support/SupportController.php b/app/Http/Controllers/Api/Support/SupportController.php
new file mode 100644
index 00000000..db7eb7d1
--- /dev/null
+++ b/app/Http/Controllers/Api/Support/SupportController.php
@@ -0,0 +1,83 @@
+id() ?? 0);
+ if ($userId <= 0) {
+ return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ $this->authorize('viewAny', SupportRequest::class);
+
+ $filters = $request->validated();
+ $page = (int) ($filters['page'] ?? 1);
+ $perPage = (int) ($filters['per_page'] ?? 20);
+
+ $isAdmin = auth()->user()?->roles()
+ ->pluck('roles.name')
+ ->map(fn ($name) => strtolower((string) $name))
+ ->intersect(['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'])
+ ->isNotEmpty();
+
+ $paginator = $this->supportService->list($userId, $filters, $page, $perPage, $isAdmin);
+ $collection = new SupportRequestCollection($paginator);
+
+ return $this->success([
+ 'requests' => $collection->toArray($request),
+ 'meta' => $collection->with($request)['meta'] ?? null,
+ ]);
+ }
+
+ public function store(SupportRequestStoreRequest $request): JsonResponse
+ {
+ $user = auth()->user();
+ if (!$user) {
+ return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ $this->authorize('create', SupportRequest::class);
+
+ $payload = $request->validated();
+ $payload['user_email'] = $user->email ?? null;
+ $payload['user_name'] = trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? ''));
+
+ try {
+ $result = $this->supportService->create($user->id, $payload);
+ } catch (\Throwable $e) {
+ Log::error('Support request failed: ' . $e->getMessage());
+ return $this->error('Unable to submit support request.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ return $this->success([
+ 'request' => new SupportRequestResource($result['record']),
+ 'email_sent' => $result['email_sent'],
+ ], 'Support request submitted.', Response::HTTP_CREATED);
+ }
+
+ public function teacher(): JsonResponse
+ {
+ return $this->success([
+ 'page' => 'teacher_support',
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/System/DashboardController.php b/app/Http/Controllers/Api/System/DashboardController.php
new file mode 100644
index 00000000..e81e8fc2
--- /dev/null
+++ b/app/Http/Controllers/Api/System/DashboardController.php
@@ -0,0 +1,31 @@
+user();
+ if (!$user) {
+ return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ $payload = $this->service->resolveForUser($user);
+
+ return $this->success([
+ 'dashboard' => new DashboardRouteResource($payload),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/System/DatabaseHealthController.php b/app/Http/Controllers/Api/System/DatabaseHealthController.php
new file mode 100644
index 00000000..7e30e288
--- /dev/null
+++ b/app/Http/Controllers/Api/System/DatabaseHealthController.php
@@ -0,0 +1,30 @@
+healthService->check();
+ if (!$result['ok']) {
+ return $this->error('Database connection failed.', Response::HTTP_SERVICE_UNAVAILABLE, [
+ 'error' => $result['error'],
+ ]);
+ }
+
+ return $this->success([
+ 'ok' => true,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/System/HealthController.php b/app/Http/Controllers/Api/System/HealthController.php
new file mode 100644
index 00000000..7c9ab1ef
--- /dev/null
+++ b/app/Http/Controllers/Api/System/HealthController.php
@@ -0,0 +1,27 @@
+service->check();
+ $status = $payload['ok'] ? Response::HTTP_OK : Response::HTTP_SERVICE_UNAVAILABLE;
+
+ return $this->success([
+ 'health' => new HealthStatusResource($payload),
+ ], 'Health check.', $status);
+ }
+}
diff --git a/app/Http/Controllers/Api/System/NavBuilderController.php b/app/Http/Controllers/Api/System/NavBuilderController.php
new file mode 100644
index 00000000..44c7c474
--- /dev/null
+++ b/app/Http/Controllers/Api/System/NavBuilderController.php
@@ -0,0 +1,90 @@
+user();
+ $roles = $user?->roles()->pluck('roles.name')->map(fn ($name) => strtolower((string) $name))->toArray() ?? [];
+
+ $menu = $this->navbarService->getMenuForRoles($roles);
+
+ return $this->success([
+ 'items' => NavItemResource::collection($menu),
+ ]);
+ }
+
+ public function data(): JsonResponse
+ {
+ $this->authorize('manage', NavItem::class);
+
+ $payload = $this->builderService->getAdminPayload();
+
+ return $this->success([
+ 'data' => new NavBuilderDataResource($payload),
+ ]);
+ }
+
+ public function store(NavItemStoreRequest $request): JsonResponse
+ {
+ $this->authorize('manage', NavItem::class);
+
+ $result = $this->builderService->save($request->validated());
+
+ if (empty($result['ok'])) {
+ return $this->error($result['message'] ?? 'Unable to save menu item.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->success([
+ 'id' => $result['id'],
+ ], 'Menu saved.', Response::HTTP_CREATED);
+ }
+
+ public function destroy(int $id): JsonResponse
+ {
+ $this->authorize('manage', NavItem::class);
+
+ $deleted = $this->builderService->delete($id);
+
+ if (!$deleted) {
+ return $this->error('Menu item not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ return $this->success(null, 'Menu item deleted.');
+ }
+
+ public function reorder(NavItemReorderRequest $request): JsonResponse
+ {
+ $this->authorize('manage', NavItem::class);
+
+ try {
+ $this->builderService->reorder($request->validated()['orders']);
+ } catch (\Throwable $e) {
+ Log::error('Nav reorder failed: ' . $e->getMessage());
+ return $this->error('Unable to reorder items.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ return $this->success(['ok' => true], 'Menu reordered.');
+ }
+}
diff --git a/app/Http/Controllers/Api/Ui/UiController.php b/app/Http/Controllers/Api/Ui/UiController.php
new file mode 100644
index 00000000..f962200c
--- /dev/null
+++ b/app/Http/Controllers/Api/Ui/UiController.php
@@ -0,0 +1,32 @@
+id() ?? 0);
+ if ($userId <= 0) {
+ return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
+ }
+
+ $prefs = $this->styleService->update($userId, $request->validated());
+
+ return $this->success([
+ 'preferences' => new UiStyleResource($prefs),
+ ], 'Style updated.');
+ }
+}
diff --git a/app/Http/Controllers/Api/Utilities/PhoneFormatterController.php b/app/Http/Controllers/Api/Utilities/PhoneFormatterController.php
new file mode 100644
index 00000000..3c1014d1
--- /dev/null
+++ b/app/Http/Controllers/Api/Utilities/PhoneFormatterController.php
@@ -0,0 +1,36 @@
+validated()['number'];
+ $formatted = $this->service->format($raw);
+
+ if ($formatted === null) {
+ return $this->error('Invalid phone number.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->success([
+ 'phone' => new PhoneFormatResource([
+ 'raw' => $raw,
+ 'formatted' => $formatted,
+ 'is_valid' => true,
+ ]),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/View/ClassController.php b/app/Http/Controllers/View/ClassController.php
deleted file mode 100755
index 18df10ab..00000000
--- a/app/Http/Controllers/View/ClassController.php
+++ /dev/null
@@ -1,150 +0,0 @@
-classSection = $classSection;
- $this->student = $student;
- $this->teacher = $teacher;
- $this->config = $config;
-
- $this->semester = $this->config->getConfig('semester');
- $this->schoolYear = $this->config->getConfig('school_year');
- }
-
- public function index(): View
- {
- $classSections = $this->classSection
- ->newQuery()
- ->orderBy('class_section_name', 'ASC')
- ->get();
-
- return view('administrator.class_section', [
- 'classsection' => $classSections,
- ]);
- }
-
- public function create(): View
- {
- return view('administrator.create_class');
- }
-
- public function store(Request $request): RedirectResponse
- {
- $data = $request->only(['name', 'description']);
- // TODO: persist the payload once the backing model/table columns are finalized.
-
- return redirect()->to('/administrator/classes')->with([
- 'payload' => $data,
- ]);
- }
-
- public function edit(int $id): View
- {
- return view('administrator.edit_class', [
- 'class' => $this->classSection->find($id),
- ]);
- }
-
- public function updateClass(Request $request, int $id): RedirectResponse
- {
- $data = $request->only(['name', 'description']);
- // TODO: persist the payload once the backing model/table columns are finalized.
-
- return redirect()->to('/administrator/classes')->with([
- 'payload' => $data,
- 'id' => $id,
- ]);
- }
-
- public function destroyClass(int $id): RedirectResponse
- {
- // TODO: remove the record once the backing model/table columns are finalized.
-
- return redirect()->to('/administrator/classes')->with([
- 'deleted_id' => $id,
- ]);
- }
-
- public function addClasses(): RedirectResponse
- {
- $classes = [
- ['class_name' => 'Class 1', 'teacher_id' => 1, 'schedule' => 'Monday 9:00 AM - 10:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 2', 'teacher_id' => 2, 'schedule' => 'Tuesday 9:00 AM - 10:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 3', 'teacher_id' => 3, 'schedule' => 'Wednesday 9:00 AM - 10:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 4', 'teacher_id' => 4, 'schedule' => 'Thursday 9:00 AM - 10:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 5', 'teacher_id' => 5, 'schedule' => 'Friday 9:00 AM - 10:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 6', 'teacher_id' => 6, 'schedule' => 'Monday 10:00 AM - 11:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 7', 'teacher_id' => 7, 'schedule' => 'Tuesday 10:00 AM - 11:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 8', 'teacher_id' => 8, 'schedule' => 'Wednesday 10:00 AM - 11:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 9', 'teacher_id' => 9, 'schedule' => 'Thursday 10:00 AM - 11:00 AM', 'capacity' => 30],
- ['class_name' => 'Youth', 'teacher_id' => 10, 'schedule' => 'Friday 10:00 AM - 11:00 AM', 'capacity' => 30],
- ];
-
- DB::transaction(function () use ($classes) {
- foreach ($classes as $class) {
- DB::table('classes')->insert($class);
- }
- });
-
- return redirect()->to('/parent/classes')->with('success', 'Classes added successfully.');
- }
-
- public function classAttendance(int $classSectionId): RedirectResponse|View
- {
- $teacherId = (int) (session()->get('user_id') ?? 0);
- if ($teacherId <= 0) {
- return redirect()->to('/teacher/classes')->with('error', 'Missing teacher session.');
- }
-
- $teacherData = $this->teacher->find($teacherId);
-
- $studentsData = $this->student->newQuery()
- ->join('student_class', 'students.id', '=', 'student_class.student_id')
- ->leftJoin('class_sections', 'class_sections.id', '=', 'student_class.class_section_id')
- ->where('student_class.class_section_id', $classSectionId)
- ->select('students.*', 'student_class.class_section_id', 'class_sections.class_section_name')
- ->get()
- ->toArray();
-
- $className = $studentsData[0]['class_section_name'] ?? 'Class Name Not Available';
-
- if (empty($teacherData) || empty($studentsData)) {
- return redirect()->to('/teacher/classes')->with('error', 'No data found for this class.');
- }
- $teacherFirst = $teacherData['teacher_first'] ?? $teacherData['firstname'] ?? '';
- $teacherLast = $teacherData['teacher_last'] ?? $teacherData['lastname'] ?? '';
-
- return view('teacher.classes', [
- 'teacher_name' => trim($teacherFirst . ' ' . $teacherLast),
- 'students' => $studentsData,
- 'class_name' => $className,
- 'semester' => $this->semester,
- 'class_section_id' => $classSectionId,
- ]);
- }
-}
diff --git a/app/Http/Controllers/View/ClassPreparationController.php b/app/Http/Controllers/View/ClassPreparationController.php
deleted file mode 100755
index 0b9bbd0d..00000000
--- a/app/Http/Controllers/View/ClassPreparationController.php
+++ /dev/null
@@ -1,545 +0,0 @@
-studentClass = $studentClass;
- $this->prepLog = $prepLog;
- $this->classSection = $classSection;
- $this->config = $config;
- $this->adjustment = $adjustment;
-
- $this->schoolYear = $this->config->getConfig('school_year');
- $this->semester = $this->config->getConfig('semester');
- }
-
- public function index(Request $request): View
- {
- $schoolYear = (string) ($request->query('school_year') ?? $this->schoolYear ?? '');
- $semParam = $request->query('semester');
- $semester = is_string($semParam) && $semParam !== '' ? $semParam : (string) ($this->semester ?? '');
- $allowed = $this->allowedPrepCategories;
-
- $classSections = $this->studentClass->newQuery()
- ->selectRaw('class_section_id, COUNT(DISTINCT student_id) AS student_count')
- ->where('school_year', $schoolYear)
- ->where('semester', $semester)
- ->groupBy('class_section_id')
- ->get()
- ->map(fn ($row) => (array) $row)
- ->toArray();
-
- $inventoryMap = $this->buildInventoryMap($schoolYear, $semester);
-
- $requiredTotals = array_fill_keys($allowed, 0);
- $prepResults = [];
-
- foreach ($classSections as $section) {
- $classSectionId = (string) ($section['class_section_id'] ?? '');
- $studentCount = (int) ($section['student_count'] ?? 0);
- $classLevel = $this->getClassLevelBySection($classSectionId);
- $className = $this->classSection->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
-
- $baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
-
- $rawAdjustments = $this->adjustment->newQuery()
- ->where('class_section_id', $classSectionId)
- ->where('school_year', $schoolYear)
- ->where('adjustable', 1)
- ->get()
- ->map(fn ($row) => (array) $row)
- ->toArray();
-
- $adjMap = ['Large Table' => 0, 'Small Table' => 0];
-
- foreach ($rawAdjustments as $adjustment) {
- $item = $adjustment['item_name'] ?? '';
- $delta = (int) ($adjustment['adjustment'] ?? 0);
-
- if (array_key_exists($item, $adjMap)) {
- $adjMap[$item] = $delta;
- }
-
- if (isset($baseItems[$item])) {
- $baseItems[$item] = max(0, (int) $baseItems[$item] + $delta);
- } elseif (in_array($item, $allowed, true)) {
- $baseItems[$item] = max(0, $delta);
- }
- }
-
- foreach ($allowed as $category) {
- $requiredTotals[$category] += (int) ($baseItems[$category] ?? 0);
- }
-
- $oldSnap = $this->prepLog->newQuery()
- ->where('class_section_id', $classSectionId)
- ->where('school_year', $schoolYear)
- ->orderBy('created_at', 'DESC')
- ->first();
-
- $oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'] ?? '[]', true) : [];
- $hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
-
- $prepResults[] = [
- 'class_section' => $className,
- 'class_section_id' => $classSectionId,
- 'student_count' => $studentCount,
- 'class_level' => $classLevel,
- 'prep_items' => $baseItems,
- 'needs_print' => $hasChanged,
- 'last_printed_at' => $oldSnap['created_at'] ?? null,
- 'adjustments' => $adjMap,
- ];
- }
-
- $shortages = [];
- foreach ($requiredTotals as $item => $required) {
- $available = (int) ($inventoryMap[$item] ?? 0);
- if ($available < (int) $required) {
- $shortages[$item] = (int) $required - $available;
- }
- }
-
- return view('class_prep/list', [
- 'prepResults' => $prepResults,
- 'schoolYear' => $schoolYear,
- 'semester' => $semester,
- 'shortages' => $shortages,
- 'totalNeeded' => $requiredTotals,
- 'available' => $inventoryMap,
- ]);
- }
-
- public function saveAdjustment(Request $request): RedirectResponse
- {
- $data = $request->all();
- $sectionId = (string) ($data['class_section_id'] ?? '');
- $schoolYear = (string) ($data['school_year'] ?? $this->schoolYear ?? '');
- $adjustments = is_array($data['adjustments'] ?? null) ? $data['adjustments'] : [];
-
- foreach ($adjustments as $itemName => $adjustment) {
- $row = $this->adjustment->newQuery()
- ->where('class_section_id', $sectionId)
- ->where('school_year', $schoolYear)
- ->where('item_name', $itemName)
- ->first();
-
- if ($row) {
- $this->adjustment->update($row['id'], [
- 'adjustment' => (int) $adjustment,
- 'adjustable' => 1,
- ]);
- continue;
- }
-
- $this->adjustment->insert([
- 'class_section_id' => $sectionId,
- 'item_name' => $itemName,
- 'adjustment' => (int) $adjustment,
- 'school_year' => $schoolYear,
- 'adjustable' => 1,
- ]);
- }
-
- return redirect()->to('/class-prep?school_year=' . urlencode($schoolYear))
- ->with('success', 'Adjustments saved successfully.');
- }
-
- public function print(string $classSectionId, string $schoolYear): View
- {
- $className = $this->classSection->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
-
- $studentRow = $this->studentClass->newQuery()
- ->selectRaw('COUNT(DISTINCT student_id) AS cnt')
- ->where('class_section_id', $classSectionId)
- ->where('school_year', $schoolYear)
- ->where('semester', $this->semester)
- ->first();
-
- $studentCount = (int) ($studentRow['cnt'] ?? 0);
- $classLevel = $this->getClassLevelBySection($classSectionId);
- $items = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
-
- $rawAdjustments = $this->adjustment->newQuery()
- ->where('class_section_id', $classSectionId)
- ->where('school_year', $schoolYear)
- ->where('adjustable', 1)
- ->get()
- ->map(fn ($row) => (array) $row)
- ->toArray();
-
- foreach ($rawAdjustments as $adjustment) {
- $item = $adjustment['item_name'] ?? '';
- $delta = (int) ($adjustment['adjustment'] ?? 0);
- if (isset($items[$item])) {
- $items[$item] = max(0, (int) $items[$item] + $delta);
- }
- }
-
- $this->prepLog->insert([
- 'class_section_id' => $classSectionId,
- 'class_section' => $className,
- 'school_year' => $schoolYear,
- 'prep_data' => json_encode($items),
- 'created_at' => utc_now(),
- ]);
-
- return view('class_prep/print', [
- 'classSectionId' => $classSectionId,
- 'classSection' => $className,
- 'schoolYear' => $schoolYear,
- 'prepItems' => $items,
- ]);
- }
-
- public function apiList(Request $request): JsonResponse
- {
- $schoolYear = (string) ($request->query('school_year') ?? $this->schoolYear ?? '');
- $allowed = $this->allowedPrepCategories;
-
- $classSections = $this->studentClass->newQuery()
- ->selectRaw('class_section_id, COUNT(DISTINCT student_id) AS student_count')
- ->where('school_year', $schoolYear)
- ->where('semester', $this->semester)
- ->groupBy('class_section_id')
- ->get()
- ->map(fn ($row) => (array) $row)
- ->toArray();
-
- $inventoryMap = $this->buildInventoryMap($schoolYear, $this->semester);
-
- $requiredTotals = array_fill_keys($allowed, 0);
- $results = [];
-
- foreach ($classSections as $row) {
- $classSectionId = (string) ($row['class_section_id'] ?? '');
- $studentCount = (int) ($row['student_count'] ?? 0);
- $classLevel = $this->getClassLevelBySection($classSectionId);
- $className = $this->classSection->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
-
- $baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
-
- $rawAdjustments = $this->adjustment->newQuery()
- ->where('class_section_id', $classSectionId)
- ->where('school_year', $schoolYear)
- ->where('adjustable', 1)
- ->get()
- ->map(fn ($row) => (array) $row)
- ->toArray();
-
- $adjMap = ['Large Table' => 0, 'Small Table' => 0];
-
- foreach ($rawAdjustments as $adjustment) {
- $item = $adjustment['item_name'] ?? '';
- $delta = (int) ($adjustment['adjustment'] ?? 0);
- if (array_key_exists($item, $adjMap)) {
- $adjMap[$item] = $delta;
- }
- if (isset($baseItems[$item])) {
- $baseItems[$item] = max(0, (int) $baseItems[$item] + $delta);
- }
- }
-
- foreach ($allowed as $cat) {
- $requiredTotals[$cat] += (int) ($baseItems[$cat] ?? 0);
- }
-
- $oldSnap = $this->prepLog->newQuery()
- ->where('class_section_id', $classSectionId)
- ->where('school_year', $schoolYear)
- ->orderBy('created_at', 'DESC')
- ->first();
-
- $oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'] ?? '[]', true) : [];
- $hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
-
- $results[] = [
- 'class_section' => $className,
- 'class_section_id' => $classSectionId,
- 'student_count' => $studentCount,
- 'class_level' => $classLevel,
- 'prep_items' => $baseItems,
- 'needs_print' => $hasChanged,
- 'last_printed_at' => $oldSnap['created_at'] ?? null,
- 'adjustments' => $adjMap,
- ];
- }
-
- $shortages = [];
- foreach ($requiredTotals as $item => $required) {
- $available = (int) ($inventoryMap[$item] ?? 0);
- if ($available < (int) $required) {
- $shortages[$item] = (int) $required - $available;
- }
- }
-
- return response()->json([
- 'ok' => true,
- 'schoolYear' => $schoolYear,
- 'results' => $results,
- 'totals' => $requiredTotals,
- 'shortages' => $shortages,
- 'csrf_token' => csrf_token(),
- 'csrf_hash' => csrf_token(),
- ]);
- }
-
- public function apiMarkPrinted(Request $request): JsonResponse
- {
- $schoolYear = (string) ($request->post('school_year') ?? $this->schoolYear ?? '');
- $ids = $request->post('class_section_ids', []);
- if (!is_array($ids)) {
- $ids = $ids ? [$ids] : [];
- }
- $ids = array_values(array_unique(array_filter(array_map('strval', $ids))));
-
- $count = 0;
- foreach ($ids as $classSectionId) {
- $studentRow = $this->studentClass->newQuery()
- ->selectRaw('COUNT(DISTINCT student_id) AS cnt')
- ->where('class_section_id', $classSectionId)
- ->where('school_year', $schoolYear)
- ->where('semester', $this->semester)
- ->first();
-
- $studentCount = (int) ($studentRow['cnt'] ?? 0);
- $classLevel = $this->getClassLevelBySection($classSectionId);
- $className = $this->classSection->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
- $items = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
-
- $rawAdjustments = $this->adjustment->newQuery()
- ->where('class_section_id', $classSectionId)
- ->where('school_year', $schoolYear)
- ->where('adjustable', 1)
- ->get()
- ->map(fn ($row) => (array) $row)
- ->toArray();
-
- foreach ($rawAdjustments as $adjustment) {
- $item = $adjustment['item_name'] ?? '';
- $delta = (int) ($adjustment['adjustment'] ?? 0);
- if (isset($items[$item])) {
- $items[$item] = max(0, (int) $items[$item] + $delta);
- }
- }
-
- try {
- $this->prepLog->insert([
- 'class_section_id' => $classSectionId,
- 'class_section' => $className,
- 'school_year' => $schoolYear,
- 'prep_data' => json_encode($items),
- 'created_at' => utc_now(),
- ]);
- $count++;
- } catch (\Throwable $e) {
- // ignore and continue to next section
- }
- }
-
- return response()->json([
- 'ok' => true,
- 'updated' => $count,
- 'csrf_token' => csrf_token(),
- 'csrf_hash' => csrf_token(),
- ]);
- }
-
- private function getTeacherCountForSection(string $classSectionId, ?string $schoolYear = null): int
- {
- $schoolYear = $schoolYear ?? $this->schoolYear;
-
- $row = DB::table('teacher_class')
- ->selectRaw('COUNT(*) AS cnt')
- ->where('class_section_id', $classSectionId)
- ->whereIn('position', ['main', 'ta'])
- ->where('school_year', $schoolYear)
- ->first();
-
- return (int) ($row->cnt ?? 0);
- }
-
- private function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array
- {
- $allowed = $this->allowedPrepCategories;
- $items = array_fill_keys($allowed, 0);
-
- $categories = DB::table('inventory_categories')
- ->select('name', 'grade_min', 'grade_max')
- ->where('type', 'classroom')
- ->whereIn('name', $allowed)
- ->orderBy('name')
- ->get()
- ->map(fn ($row) => (array) $row)
- ->toArray();
-
- $isLowerGrades = in_array($classLevel, [1, 2], true);
- $teacherCount = $this->getTeacherCountForSection($classSectionId);
-
- foreach ($categories as $category) {
- $name = $category['name'] ?? '';
- $gradeMin = $category['grade_min'];
- $gradeMax = $category['grade_max'];
-
- if ($gradeMin !== null && $classLevel < (int) $gradeMin) {
- $items[$name] = 0;
- continue;
- }
- if ($gradeMax !== null && $classLevel > (int) $gradeMax) {
- $items[$name] = 0;
- continue;
- }
-
- $qty = 0;
- switch (strtolower($name)) {
- case 'small table':
- $qty = $isLowerGrades ? (int) ceil($students / 3) : 0;
- break;
- case 'large table':
- $qty = $isLowerGrades ? 0 : (int) ceil($students / 4);
- break;
- case 'small chair':
- $qty = $isLowerGrades ? $students : 0;
- break;
- case 'regular chair':
- $qty = $isLowerGrades ? 0 : $students;
- break;
- case 'teacher chair':
- $qty = $teacherCount;
- break;
- case 'trash bin':
- case 'white board':
- case 'grade box':
- $qty = 1;
- break;
- default:
- $qty = 0;
- }
-
- if (array_key_exists($name, $items)) {
- $items[$name] = $qty;
- }
- }
-
- return $items;
- }
-
- private function hasPrepChanged(array $new, array $old): bool
- {
- ksort($new);
- ksort($old);
-
- return json_encode($new) !== json_encode($old);
- }
-
- private function buildInventoryMap(string $schoolYear, string $semester): array
- {
- $allowed = $this->allowedPrepCategories;
- $inventoryMap = array_fill_keys($allowed, 0);
-
- $queries = [
- DB::table('inventory_items as ii')
- ->selectRaw('ic.name AS item_name, COALESCE(SUM(ii.good_qty),0) AS available')
- ->join('inventory_categories as ic', 'ic.id', '=', 'ii.category_id')
- ->where('ii.type', 'classroom')
- ->where('ii.school_year', $schoolYear)
- ->where('ii.semester', $semester)
- ->whereIn('ic.name', $allowed)
- ->groupBy('ic.name'),
- DB::table('inventory_items')
- ->selectRaw('name AS item_name, COALESCE(SUM(good_qty),0) AS available')
- ->where('type', 'classroom')
- ->where('school_year', $schoolYear)
- ->where('semester', $semester)
- ->whereIn('name', $allowed)
- ->groupBy('name'),
- DB::table('inventory_items as ii')
- ->selectRaw('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.`condition`="good" THEN ii.quantity ELSE 0 END),0) AS available')
- ->join('inventory_categories as ic', 'ic.id', '=', 'ii.category_id')
- ->where('ii.type', 'classroom')
- ->where('ii.school_year', $schoolYear)
- ->where('ii.semester', $semester)
- ->whereIn('ic.name', $allowed)
- ->groupBy('ic.name'),
- DB::table('inventory_items')
- ->selectRaw('name AS item_name, COALESCE(SUM(CASE WHEN `condition`="good" THEN quantity ELSE 0 END),0) AS available')
- ->where('type', 'classroom')
- ->where('school_year', $schoolYear)
- ->where('semester', $semester)
- ->whereIn('name', $allowed)
- ->groupBy('name'),
- ];
-
- foreach ($queries as $query) {
- foreach ($query->get()->toArray() as $row) {
- $row = (array) $row;
- $name = (string) ($row['item_name'] ?? $row['name'] ?? '');
- $val = (int) ($row['available'] ?? 0);
-
- if ($name !== '' && isset($inventoryMap[$name])) {
- $inventoryMap[$name] = max($inventoryMap[$name], $val);
- }
- }
- }
-
- return $inventoryMap;
- }
-
- private function getClassLevelBySection(string $classSectionId): int
- {
- if (preg_match('/^(kg|k)(\b|[^a-z0-9])/i', $classSectionId)) {
- return 1;
- }
-
- $numeric = (int) preg_replace('/\D/', '', $classSectionId);
- if ($numeric > 0 && $numeric < 30) {
- $firstDigit = (int) substr((string) $numeric, 0, 1);
- return match ($firstDigit) {
- 1 => 1,
- 2 => 2,
- default => 3,
- };
- }
-
- return 3;
- }
-}
diff --git a/app/Http/Controllers/View/DashboardRedirectController.php b/app/Http/Controllers/View/DashboardRedirectController.php
deleted file mode 100755
index a05e138c..00000000
--- a/app/Http/Controllers/View/DashboardRedirectController.php
+++ /dev/null
@@ -1,45 +0,0 @@
-get('roles') ?? [];
-
- if (empty($roles) && $session->has('role')) {
- $roles = [$session->get('role')];
- }
-
- return $this->redirectToDashboard($roles);
- }
-
- private function redirectToDashboard(array $roles): RedirectResponse
- {
- if (empty($roles)) {
- log_message('error', 'Empty roles array passed to redirectToDashboard.');
- return redirect()->to('/landing_page/guest_dashboard');
- }
-
- $rows = $this->role->findByNamesOrSlugs($roles);
-
- if (!empty($rows)) {
- $route = $rows[0]['dashboard_route'] ?? '/landing_page/guest_dashboard';
- log_message('debug', 'Redirecting user to: ' . $route);
- return redirect()->to($route);
- }
-
- log_message('warning', 'No matching role found. Redirecting to guest dashboard.');
- return redirect()->to('/landing_page/guest_dashboard');
- }
-}
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index 405c9887..00f76766 100755
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -35,8 +35,6 @@ class Kernel extends HttpKernel
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
- \Illuminate\Session\Middleware\StartSession::class,
- \Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
diff --git a/app/Http/Middleware/ApiDocsAuth.php b/app/Http/Middleware/ApiDocsAuth.php
new file mode 100644
index 00000000..21e7fae4
--- /dev/null
+++ b/app/Http/Middleware/ApiDocsAuth.php
@@ -0,0 +1,65 @@
+header('Authorization');
+ if ($authHeader !== '' && stripos($authHeader, 'Bearer ') === 0) {
+ $token = trim(substr($authHeader, 7));
+ if ($token === '') {
+ return $this->deny('Missing token.', 401);
+ }
+
+ try {
+ $user = JWTAuth::setToken($token)->authenticate();
+ if ($user) {
+ Auth::setUser($user);
+ }
+ } catch (JWTException $e) {
+ $user = null;
+ }
+ }
+ }
+
+ if (!$user) {
+ return $this->deny('Unauthorized access to documentation.', 401);
+ }
+
+ if (!$this->userIsAdmin((int) $user->id)) {
+ return $this->deny('Admin privileges required.', 403);
+ }
+
+ return $next($request);
+ }
+
+ private function userIsAdmin(int $userId): bool
+ {
+ return DB::table('user_roles as ur')
+ ->join('roles as r', 'r.id', '=', 'ur.role_id')
+ ->where('ur.user_id', $userId)
+ ->whereNull('ur.deleted_at')
+ ->whereRaw('LOWER(r.name) = ?', ['admin'])
+ ->exists();
+ }
+
+ private function deny(string $message, int $status): Response
+ {
+ return response()->json([
+ 'status' => false,
+ 'message' => $message,
+ ], $status);
+ }
+}
diff --git a/app/Http/Middleware/ApiJwtAuth.php b/app/Http/Middleware/ApiJwtAuth.php
new file mode 100644
index 00000000..f6f2d6c1
--- /dev/null
+++ b/app/Http/Middleware/ApiJwtAuth.php
@@ -0,0 +1,29 @@
+authenticate();
+ if (!$user) {
+ return response()->json(['status' => false, 'message' => 'Unauthorized.'], 401);
+ }
+
+ Auth::setUser($user);
+ } catch (JWTException $e) {
+ return response()->json(['status' => false, 'message' => 'Unauthorized.'], 401);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/CleanupScheduler.php b/app/Http/Middleware/CleanupScheduler.php
new file mode 100644
index 00000000..f2fe3ac1
--- /dev/null
+++ b/app/Http/Middleware/CleanupScheduler.php
@@ -0,0 +1,26 @@
+service->shouldRun($cacheKey, 2)) {
+ $this->service->runUnverifiedCleanup();
+ $this->service->markRun($cacheKey);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/PrimeTimezone.php b/app/Http/Middleware/PrimeTimezone.php
new file mode 100644
index 00000000..34811419
--- /dev/null
+++ b/app/Http/Middleware/PrimeTimezone.php
@@ -0,0 +1,26 @@
+timeService->primeFromRequest($request);
+ } catch (\Throwable $e) {
+ // ignore; fallback detection still works
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/RequirePermission.php b/app/Http/Middleware/RequirePermission.php
new file mode 100644
index 00000000..bd5ae72b
--- /dev/null
+++ b/app/Http/Middleware/RequirePermission.php
@@ -0,0 +1,32 @@
+user()?->id ?? 0);
+ if ($userId <= 0) {
+ return response()->json(['status' => false, 'message' => 'Unauthorized.'], 401);
+ }
+
+ if (!$this->permissions->hasPermission($userId, $permission)) {
+ if ($request->expectsJson()) {
+ return response()->json(['status' => false, 'message' => 'Access denied.'], 403);
+ }
+ return response()->json(['status' => false, 'message' => 'Access denied.'], 403);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Requests/Attendance/LateSlipLogIndexRequest.php b/app/Http/Requests/Attendance/LateSlipLogIndexRequest.php
new file mode 100644
index 00000000..9292582e
--- /dev/null
+++ b/app/Http/Requests/Attendance/LateSlipLogIndexRequest.php
@@ -0,0 +1,28 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'school_year' => ['nullable', 'string', 'max:9'],
+ 'semester' => ['nullable', 'string', 'max:255'],
+ 'q' => ['nullable', 'string', 'max:120'],
+ 'date_from' => ['nullable', 'date'],
+ 'date_to' => ['nullable', 'date'],
+ 'sort_by' => ['nullable', 'string', 'in:id,slip_date,student_name,printed_at,updated_at'],
+ 'sort_dir' => ['nullable', 'string', 'in:asc,desc'],
+ 'page' => ['nullable', 'integer', 'min:1'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Auth/SessionTimeoutCheckRequest.php b/app/Http/Requests/Auth/SessionTimeoutCheckRequest.php
deleted file mode 100644
index 1ef812e3..00000000
--- a/app/Http/Requests/Auth/SessionTimeoutCheckRequest.php
+++ /dev/null
@@ -1,18 +0,0 @@
-check();
- }
-
- public function rules(): array
- {
- return [];
- }
-}
diff --git a/app/Http/Requests/Auth/SessionTimeoutConfigRequest.php b/app/Http/Requests/Auth/SessionTimeoutConfigRequest.php
deleted file mode 100644
index a178fb64..00000000
--- a/app/Http/Requests/Auth/SessionTimeoutConfigRequest.php
+++ /dev/null
@@ -1,18 +0,0 @@
-check();
- }
-
- public function rules(): array
- {
- return [];
- }
-}
diff --git a/app/Http/Requests/Auth/SessionTimeoutPingRequest.php b/app/Http/Requests/Auth/SessionTimeoutPingRequest.php
deleted file mode 100644
index e6ee51bf..00000000
--- a/app/Http/Requests/Auth/SessionTimeoutPingRequest.php
+++ /dev/null
@@ -1,18 +0,0 @@
-check();
- }
-
- public function rules(): array
- {
- return [];
- }
-}
diff --git a/app/Http/Requests/ClassPreparation/ClassPreparationAdjustmentRequest.php b/app/Http/Requests/ClassPreparation/ClassPreparationAdjustmentRequest.php
new file mode 100644
index 00000000..1b4b2ad2
--- /dev/null
+++ b/app/Http/Requests/ClassPreparation/ClassPreparationAdjustmentRequest.php
@@ -0,0 +1,23 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'class_section_id' => ['required', 'string', 'max:32'],
+ 'school_year' => ['nullable', 'string', 'max:9'],
+ 'adjustments' => ['required', 'array'],
+ 'adjustments.*' => ['required', 'integer'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/ClassPreparation/ClassPreparationIndexRequest.php b/app/Http/Requests/ClassPreparation/ClassPreparationIndexRequest.php
new file mode 100644
index 00000000..25892303
--- /dev/null
+++ b/app/Http/Requests/ClassPreparation/ClassPreparationIndexRequest.php
@@ -0,0 +1,21 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'school_year' => ['nullable', 'string', 'max:9'],
+ 'semester' => ['nullable', 'string', 'max:255'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/ClassPreparation/ClassPreparationMarkPrintedRequest.php b/app/Http/Requests/ClassPreparation/ClassPreparationMarkPrintedRequest.php
new file mode 100644
index 00000000..1c06d114
--- /dev/null
+++ b/app/Http/Requests/ClassPreparation/ClassPreparationMarkPrintedRequest.php
@@ -0,0 +1,23 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'school_year' => ['required', 'string', 'max:9'],
+ 'semester' => ['nullable', 'string', 'max:255'],
+ 'class_section_ids' => ['required', 'array'],
+ 'class_section_ids.*' => ['required', 'string', 'max:32'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/ClassPreparation/ClassPreparationPrintRequest.php b/app/Http/Requests/ClassPreparation/ClassPreparationPrintRequest.php
new file mode 100644
index 00000000..4459fa63
--- /dev/null
+++ b/app/Http/Requests/ClassPreparation/ClassPreparationPrintRequest.php
@@ -0,0 +1,32 @@
+user() !== null;
+ }
+
+ protected function prepareForValidation(): void
+ {
+ parent::prepareForValidation();
+
+ $this->merge([
+ 'class_section_id' => $this->route('classSectionId'),
+ 'school_year' => $this->route('schoolYear'),
+ ]);
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'class_section_id' => ['required', 'string', 'max:32'],
+ 'school_year' => ['required', 'string', 'max:9'],
+ 'semester' => ['nullable', 'string', 'max:255'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Classes/ClassSectionIndexRequest.php b/app/Http/Requests/Classes/ClassSectionIndexRequest.php
new file mode 100644
index 00000000..a9ec26c5
--- /dev/null
+++ b/app/Http/Requests/Classes/ClassSectionIndexRequest.php
@@ -0,0 +1,27 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'search' => ['nullable', 'string', 'max:120'],
+ 'class_id' => ['nullable', 'integer', 'min:1'],
+ 'school_year' => ['nullable', 'string', 'max:9'],
+ 'semester' => ['nullable', 'string', 'max:255'],
+ 'sort_by' => ['nullable', 'string', 'in:class_section_name,class_section_id,class_id,school_year,semester,class_name'],
+ 'sort_dir' => ['nullable', 'string', 'in:asc,desc'],
+ 'page' => ['nullable', 'integer', 'min:1'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Classes/ClassSectionStoreRequest.php b/app/Http/Requests/Classes/ClassSectionStoreRequest.php
new file mode 100644
index 00000000..9d9d84bb
--- /dev/null
+++ b/app/Http/Requests/Classes/ClassSectionStoreRequest.php
@@ -0,0 +1,25 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'class_id' => ['required', 'integer', 'min:1'],
+ 'class_section_id' => ['required', 'integer', 'min:1', Rule::unique('classSection', 'class_section_id')],
+ 'class_section_name' => ['required', 'string', 'max:100'],
+ 'semester' => ['required', 'string', 'max:255'],
+ 'school_year' => ['required', 'string', 'max:9'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Classes/ClassSectionUpdateRequest.php b/app/Http/Requests/Classes/ClassSectionUpdateRequest.php
new file mode 100644
index 00000000..50b0778c
--- /dev/null
+++ b/app/Http/Requests/Classes/ClassSectionUpdateRequest.php
@@ -0,0 +1,33 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ $section = $this->route('classSection');
+ $sectionId = is_object($section) ? $section->id : $section;
+
+ return [
+ 'class_id' => ['sometimes', 'integer', 'min:1'],
+ 'class_section_id' => [
+ 'sometimes',
+ 'integer',
+ 'min:1',
+ Rule::unique('classSection', 'class_section_id')->ignore($sectionId),
+ ],
+ 'class_section_name' => ['sometimes', 'string', 'max:100'],
+ 'semester' => ['sometimes', 'string', 'max:255'],
+ 'school_year' => ['sometimes', 'string', 'max:9'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Frontend/ContactSubmitRequest.php b/app/Http/Requests/Frontend/ContactSubmitRequest.php
new file mode 100644
index 00000000..13216d70
--- /dev/null
+++ b/app/Http/Requests/Frontend/ContactSubmitRequest.php
@@ -0,0 +1,17 @@
+ ['required', 'email', 'max:255'],
+ 'message' => ['required', 'string', 'min:10'],
+ 'subject' => ['nullable', 'string', 'max:255'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Frontend/LandingTeacherRequest.php b/app/Http/Requests/Frontend/LandingTeacherRequest.php
new file mode 100644
index 00000000..d226ac2e
--- /dev/null
+++ b/app/Http/Requests/Frontend/LandingTeacherRequest.php
@@ -0,0 +1,20 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'class_section_id' => ['nullable', 'integer', 'min:1'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Messaging/MessageIndexRequest.php b/app/Http/Requests/Messaging/MessageIndexRequest.php
new file mode 100644
index 00000000..7e3fe2c6
--- /dev/null
+++ b/app/Http/Requests/Messaging/MessageIndexRequest.php
@@ -0,0 +1,21 @@
+ ['nullable', 'integer', 'min:1'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
+ 'priority' => ['nullable', 'in:low,normal,high'],
+ 'read_status' => ['nullable', 'in:0,1'],
+ 'search' => ['nullable', 'string', 'max:255'],
+ 'sort_by' => ['nullable', 'in:sent_datetime,subject,priority'],
+ 'sort_dir' => ['nullable', 'in:asc,desc'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Messaging/MessageSendRequest.php b/app/Http/Requests/Messaging/MessageSendRequest.php
new file mode 100644
index 00000000..5709bc93
--- /dev/null
+++ b/app/Http/Requests/Messaging/MessageSendRequest.php
@@ -0,0 +1,23 @@
+ ['nullable', 'integer', 'min:1', 'required_without:recipient_role'],
+ 'recipient_role' => ['nullable', 'string', 'required_without:recipient_id', 'in:teacher,parent,admin,student,guest,administrator'],
+ 'subject' => ['required', 'string', 'max:255'],
+ 'message' => ['required', 'string'],
+ 'priority' => ['nullable', 'in:low,normal,high'],
+ 'status' => ['nullable', 'in:sent,received,draft,trashed'],
+ 'semester' => ['nullable', 'string', 'max:255'],
+ 'school_year' => ['nullable', 'string', 'max:20'],
+ 'attachment' => ['nullable', 'file', 'max:10240'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Messaging/MessageUpdateRequest.php b/app/Http/Requests/Messaging/MessageUpdateRequest.php
new file mode 100644
index 00000000..9a36539f
--- /dev/null
+++ b/app/Http/Requests/Messaging/MessageUpdateRequest.php
@@ -0,0 +1,17 @@
+ ['nullable', 'in:sent,received,draft,trashed'],
+ 'priority' => ['nullable', 'in:low,normal,high'],
+ 'read_status' => ['nullable', 'in:0,1'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Payments/PaypalExecuteRequest.php b/app/Http/Requests/Payments/PaypalExecuteRequest.php
index 7879bba9..82a360ca 100644
--- a/app/Http/Requests/Payments/PaypalExecuteRequest.php
+++ b/app/Http/Requests/Payments/PaypalExecuteRequest.php
@@ -16,6 +16,7 @@ class PaypalExecuteRequest extends ApiFormRequest
return [
'payer_id' => ['required', 'string', 'max:100'],
'paypal_payment_id' => ['nullable', 'string', 'max:100'],
+ 'payment_id' => ['nullable', 'integer', 'min:1'],
];
}
}
diff --git a/app/Http/Requests/Preferences/PreferencesIndexRequest.php b/app/Http/Requests/Preferences/PreferencesIndexRequest.php
new file mode 100644
index 00000000..400624e7
--- /dev/null
+++ b/app/Http/Requests/Preferences/PreferencesIndexRequest.php
@@ -0,0 +1,25 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'user_id' => ['nullable', 'integer', 'min:1'],
+ 'q' => ['nullable', 'string', 'max:120'],
+ 'sort_by' => ['nullable', 'string', 'in:updated_at,created_at,user_id'],
+ 'sort_dir' => ['nullable', 'string', 'in:asc,desc'],
+ 'page' => ['nullable', 'integer', 'min:1'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Preferences/PreferencesUpsertRequest.php b/app/Http/Requests/Preferences/PreferencesUpsertRequest.php
new file mode 100644
index 00000000..418f61c3
--- /dev/null
+++ b/app/Http/Requests/Preferences/PreferencesUpsertRequest.php
@@ -0,0 +1,31 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ $options = app(PreferencesOptionsService::class);
+
+ return [
+ 'receive_email_notifications' => ['nullable', 'boolean'],
+ 'receive_sms_notifications' => ['nullable', 'boolean'],
+ 'notification_email' => ['nullable', 'boolean'],
+ 'notification_sms' => ['nullable', 'boolean'],
+ 'theme' => ['nullable', 'string', Rule::in(['light', 'dark'])],
+ 'language' => ['nullable', 'string', Rule::in(['en', 'fr', 'es'])],
+ 'style_color' => ['nullable', 'string', Rule::in($options->styleOptions())],
+ 'menu_color' => ['nullable', 'string', Rule::in($options->menuOptions())],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Security/IpBanIndexRequest.php b/app/Http/Requests/Security/IpBanIndexRequest.php
new file mode 100644
index 00000000..0720fc30
--- /dev/null
+++ b/app/Http/Requests/Security/IpBanIndexRequest.php
@@ -0,0 +1,25 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'status' => ['nullable', 'string', 'in:all,active'],
+ 'search' => ['nullable', 'string', 'max:100'],
+ 'sort_by' => ['nullable', 'string', 'in:updated_at,blocked_until,attempts,ip_address,last_attempt_at'],
+ 'sort_dir' => ['nullable', 'string', 'in:asc,desc'],
+ 'page' => ['nullable', 'integer', 'min:1'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Security/IpBanRequest.php b/app/Http/Requests/Security/IpBanRequest.php
new file mode 100644
index 00000000..85ca2767
--- /dev/null
+++ b/app/Http/Requests/Security/IpBanRequest.php
@@ -0,0 +1,34 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'id' => ['nullable', 'integer', 'min:1'],
+ 'ip' => ['nullable', 'ip'],
+ 'hours' => ['nullable', 'integer', 'min:1', 'max:720'],
+ ];
+ }
+
+ public function withValidator(Validator $validator): void
+ {
+ $validator->after(function (Validator $validator) {
+ $id = $this->input('id');
+ $ip = $this->input('ip');
+ if (!$id && !$ip) {
+ $validator->errors()->add('id', 'Either id or ip is required.');
+ }
+ });
+ }
+}
diff --git a/app/Http/Requests/Security/IpUnbanRequest.php b/app/Http/Requests/Security/IpUnbanRequest.php
new file mode 100644
index 00000000..682a3353
--- /dev/null
+++ b/app/Http/Requests/Security/IpUnbanRequest.php
@@ -0,0 +1,36 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'id' => ['nullable', 'integer', 'min:1'],
+ 'ip' => ['nullable', 'ip'],
+ 'all' => ['nullable', 'boolean'],
+ ];
+ }
+
+ public function withValidator(Validator $validator): void
+ {
+ $validator->after(function (Validator $validator) {
+ $id = $this->input('id');
+ $ip = $this->input('ip');
+ $all = filter_var($this->input('all'), FILTER_VALIDATE_BOOLEAN);
+
+ if (!$all && !$id && !$ip) {
+ $validator->errors()->add('id', 'Either id, ip, or all=true is required.');
+ }
+ });
+ }
+}
diff --git a/app/Http/Requests/Settings/PolicyShowRequest.php b/app/Http/Requests/Settings/PolicyShowRequest.php
new file mode 100644
index 00000000..a0f76fe5
--- /dev/null
+++ b/app/Http/Requests/Settings/PolicyShowRequest.php
@@ -0,0 +1,20 @@
+ ['nullable', 'in:html'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Settings/SettingsUpdateRequest.php b/app/Http/Requests/Settings/SettingsUpdateRequest.php
new file mode 100644
index 00000000..9b056ed8
--- /dev/null
+++ b/app/Http/Requests/Settings/SettingsUpdateRequest.php
@@ -0,0 +1,17 @@
+ ['nullable', 'string', 'max:255'],
+ 'administrator_email' => ['nullable', 'email', 'max:255'],
+ 'timezone' => ['nullable', 'string', 'max:80'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Staff/StaffIndexRequest.php b/app/Http/Requests/Staff/StaffIndexRequest.php
new file mode 100644
index 00000000..33949291
--- /dev/null
+++ b/app/Http/Requests/Staff/StaffIndexRequest.php
@@ -0,0 +1,21 @@
+ ['nullable', 'integer', 'min:1'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
+ 'role' => ['nullable', 'string', 'max:100'],
+ 'school_year' => ['nullable', 'string', 'max:20'],
+ 'semester' => ['nullable', 'string', 'max:20'],
+ 'sort_by' => ['nullable', 'in:created_at,lastname,firstname'],
+ 'sort_dir' => ['nullable', 'in:asc,desc'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Staff/StaffStoreRequest.php b/app/Http/Requests/Staff/StaffStoreRequest.php
new file mode 100644
index 00000000..b948fc4f
--- /dev/null
+++ b/app/Http/Requests/Staff/StaffStoreRequest.php
@@ -0,0 +1,22 @@
+ ['nullable', 'integer', 'min:1'],
+ 'firstname' => ['required', 'string', 'max:100'],
+ 'lastname' => ['required', 'string', 'max:100'],
+ 'email' => ['required', 'email', 'max:150'],
+ 'phone' => ['nullable', 'string', 'max:20'],
+ 'role_name' => ['required', 'string', 'max:100'],
+ 'school_year' => ['nullable', 'string', 'max:20'],
+ 'status' => ['nullable', 'string', 'max:40'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Staff/StaffUpdateRequest.php b/app/Http/Requests/Staff/StaffUpdateRequest.php
new file mode 100644
index 00000000..67319f9f
--- /dev/null
+++ b/app/Http/Requests/Staff/StaffUpdateRequest.php
@@ -0,0 +1,21 @@
+ ['nullable', 'string', 'max:100'],
+ 'lastname' => ['nullable', 'string', 'max:100'],
+ 'email' => ['nullable', 'email', 'max:150'],
+ 'phone' => ['nullable', 'string', 'max:20'],
+ 'role_name' => ['nullable', 'string', 'max:100'],
+ 'school_year' => ['nullable', 'string', 'max:20'],
+ 'status' => ['nullable', 'string', 'max:40'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Support/ContactSendRequest.php b/app/Http/Requests/Support/ContactSendRequest.php
new file mode 100644
index 00000000..eef2ec3c
--- /dev/null
+++ b/app/Http/Requests/Support/ContactSendRequest.php
@@ -0,0 +1,18 @@
+ ['required', 'string', 'min:3', 'max:255'],
+ 'email' => ['required', 'email', 'max:255'],
+ 'subject' => ['required', 'string', 'min:3', 'max:255'],
+ 'message' => ['required', 'string', 'min:10'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Support/SupportRequestIndexRequest.php b/app/Http/Requests/Support/SupportRequestIndexRequest.php
new file mode 100644
index 00000000..11951723
--- /dev/null
+++ b/app/Http/Requests/Support/SupportRequestIndexRequest.php
@@ -0,0 +1,20 @@
+ ['nullable', 'integer', 'min:1'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
+ 'status' => ['nullable', 'string', 'max:25'],
+ 'user_id' => ['nullable', 'integer', 'min:1'],
+ 'sort_by' => ['nullable', 'in:created_at,status,subject'],
+ 'sort_dir' => ['nullable', 'in:asc,desc'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Support/SupportRequestStoreRequest.php b/app/Http/Requests/Support/SupportRequestStoreRequest.php
new file mode 100644
index 00000000..a8bf56c5
--- /dev/null
+++ b/app/Http/Requests/Support/SupportRequestStoreRequest.php
@@ -0,0 +1,16 @@
+ ['required', 'string', 'min:3', 'max:255'],
+ 'message' => ['required', 'string', 'min:10'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/System/NavItemReorderRequest.php b/app/Http/Requests/System/NavItemReorderRequest.php
new file mode 100644
index 00000000..f656fede
--- /dev/null
+++ b/app/Http/Requests/System/NavItemReorderRequest.php
@@ -0,0 +1,21 @@
+ ['required', 'array'],
+ 'orders.*' => ['integer'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/System/NavItemStoreRequest.php b/app/Http/Requests/System/NavItemStoreRequest.php
new file mode 100644
index 00000000..ce52b3fc
--- /dev/null
+++ b/app/Http/Requests/System/NavItemStoreRequest.php
@@ -0,0 +1,30 @@
+ ['nullable', 'integer', 'min:1', 'exists:nav_items,id'],
+ 'menu_parent_id' => ['nullable', 'integer', 'min:1', 'exists:nav_items,id'],
+ 'parent_id' => ['nullable', 'integer', 'min:1', 'exists:nav_items,id'],
+ 'label' => ['required', 'string', 'max:255'],
+ 'url' => ['nullable', 'string', 'max:255'],
+ 'icon_class' => ['nullable', 'string', 'max:255'],
+ 'target' => ['nullable', 'string', 'max:50'],
+ 'sort_order' => ['nullable', 'integer'],
+ 'is_enabled' => ['nullable', 'boolean'],
+ 'roles' => ['nullable', 'array'],
+ 'roles.*' => ['integer', 'min:1', 'exists:roles,id'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Ui/UiStyleRequest.php b/app/Http/Requests/Ui/UiStyleRequest.php
new file mode 100644
index 00000000..9d0bebe0
--- /dev/null
+++ b/app/Http/Requests/Ui/UiStyleRequest.php
@@ -0,0 +1,19 @@
+ ['nullable', 'string', 'max:50'],
+ 'menu' => ['nullable', 'string', 'max:50'],
+ 'menu_bg' => ['nullable', 'string', 'max:20'],
+ 'menu_text' => ['nullable', 'string', 'max:20'],
+ 'menu_mode' => ['nullable', 'in:light,dark,auto'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Utilities/PhoneFormatRequest.php b/app/Http/Requests/Utilities/PhoneFormatRequest.php
new file mode 100644
index 00000000..12df9a64
--- /dev/null
+++ b/app/Http/Requests/Utilities/PhoneFormatRequest.php
@@ -0,0 +1,20 @@
+user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'number' => ['required', 'string', 'max:50'],
+ ];
+ }
+}
diff --git a/app/Http/Resources/Attendance/LateSlipLogCollection.php b/app/Http/Resources/Attendance/LateSlipLogCollection.php
new file mode 100644
index 00000000..2ae9108a
--- /dev/null
+++ b/app/Http/Resources/Attendance/LateSlipLogCollection.php
@@ -0,0 +1,27 @@
+ [
+ 'total' => $this->resource->total() ?? null,
+ 'per_page' => $this->resource->perPage() ?? null,
+ 'current_page' => $this->resource->currentPage() ?? null,
+ 'last_page' => $this->resource->lastPage() ?? null,
+ ],
+ ];
+ }
+}
diff --git a/app/Http/Resources/Attendance/LateSlipLogResource.php b/app/Http/Resources/Attendance/LateSlipLogResource.php
new file mode 100644
index 00000000..ad2986cc
--- /dev/null
+++ b/app/Http/Resources/Attendance/LateSlipLogResource.php
@@ -0,0 +1,28 @@
+ $this->resource->id ?? null,
+ 'school_year' => $this->resource->school_year ?? null,
+ 'semester' => $this->resource->semester ?? null,
+ 'student_name' => $this->resource->student_name ?? null,
+ 'slip_date' => $this->resource->slip_date ?? null,
+ 'time_in' => $this->resource->time_in ?? null,
+ 'grade' => $this->resource->grade ?? null,
+ 'reason' => $this->resource->reason ?? null,
+ 'admin_name' => $this->resource->admin_name ?? null,
+ 'printed_by' => $this->resource->printed_by ?? null,
+ 'printed_at' => $this->resource->printed_at ?? null,
+ 'created_at' => $this->resource->created_at ?? null,
+ 'updated_at' => $this->resource->updated_at ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Auth/SessionTimeoutConfigResource.php b/app/Http/Resources/Auth/SessionTimeoutConfigResource.php
deleted file mode 100644
index dcb670e5..00000000
--- a/app/Http/Resources/Auth/SessionTimeoutConfigResource.php
+++ /dev/null
@@ -1,20 +0,0 @@
- (int) ($this['timeout'] ?? 0),
- 'warning_time' => (int) ($this['warning_time'] ?? 0),
- 'check_interval' => (int) ($this['check_interval'] ?? 0),
- 'logout_url' => (string) ($this['logout_url'] ?? ''),
- 'keep_alive_url' => (string) ($this['keep_alive_url'] ?? ''),
- 'check_url' => (string) ($this['check_url'] ?? ''),
- ];
- }
-}
diff --git a/app/Http/Resources/Auth/SessionTimeoutStatusResource.php b/app/Http/Resources/Auth/SessionTimeoutStatusResource.php
deleted file mode 100644
index 887a18c9..00000000
--- a/app/Http/Resources/Auth/SessionTimeoutStatusResource.php
+++ /dev/null
@@ -1,18 +0,0 @@
- (string) ($this['status'] ?? ''),
- 'time_remaining' => (int) ($this['time_remaining'] ?? 0),
- 'redirect' => $this['redirect'] ?? null,
- 'message' => $this['message'] ?? null,
- ];
- }
-}
diff --git a/app/Http/Resources/ClassPreparation/ClassPreparationPrintResource.php b/app/Http/Resources/ClassPreparation/ClassPreparationPrintResource.php
new file mode 100644
index 00000000..86d2b3be
--- /dev/null
+++ b/app/Http/Resources/ClassPreparation/ClassPreparationPrintResource.php
@@ -0,0 +1,22 @@
+ $this->resource['class_section_id'] ?? null,
+ 'class_section' => $this->resource['class_section'] ?? null,
+ 'school_year' => $this->resource['school_year'] ?? null,
+ 'semester' => $this->resource['semester'] ?? null,
+ 'prep_items' => $this->resource['prep_items'] ?? [],
+ 'adjustments' => $this->resource['adjustments'] ?? [],
+ 'printed_at' => $this->resource['printed_at'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/ClassPreparation/ClassPreparationResultResource.php b/app/Http/Resources/ClassPreparation/ClassPreparationResultResource.php
new file mode 100644
index 00000000..fdb35278
--- /dev/null
+++ b/app/Http/Resources/ClassPreparation/ClassPreparationResultResource.php
@@ -0,0 +1,23 @@
+ $this->resource['class_section'] ?? null,
+ 'class_section_id' => $this->resource['class_section_id'] ?? null,
+ 'student_count' => $this->resource['student_count'] ?? 0,
+ 'class_level' => $this->resource['class_level'] ?? null,
+ 'prep_items' => $this->resource['prep_items'] ?? [],
+ 'needs_print' => $this->resource['needs_print'] ?? false,
+ 'last_printed_at' => $this->resource['last_printed_at'] ?? null,
+ 'adjustments' => $this->resource['adjustments'] ?? [],
+ ];
+ }
+}
diff --git a/app/Http/Resources/Classes/ClassAttendanceResource.php b/app/Http/Resources/Classes/ClassAttendanceResource.php
new file mode 100644
index 00000000..93bc9853
--- /dev/null
+++ b/app/Http/Resources/Classes/ClassAttendanceResource.php
@@ -0,0 +1,21 @@
+ $this->resource['teacher_name'] ?? null,
+ 'class_name' => $this->resource['class_name'] ?? null,
+ 'class_section_id' => $this->resource['class_section_id'] ?? null,
+ 'semester' => $this->resource['semester'] ?? null,
+ 'school_year' => $this->resource['school_year'] ?? null,
+ 'students' => ClassAttendanceStudentResource::collection($this->resource['students'] ?? []),
+ ];
+ }
+}
diff --git a/app/Http/Resources/Classes/ClassAttendanceStudentResource.php b/app/Http/Resources/Classes/ClassAttendanceStudentResource.php
new file mode 100644
index 00000000..974e23a8
--- /dev/null
+++ b/app/Http/Resources/Classes/ClassAttendanceStudentResource.php
@@ -0,0 +1,21 @@
+ $this->resource['student_id'] ?? null,
+ 'school_id' => $this->resource['school_id'] ?? null,
+ 'firstname' => $this->resource['firstname'] ?? null,
+ 'lastname' => $this->resource['lastname'] ?? null,
+ 'class_section_id' => $this->resource['class_section_id'] ?? null,
+ 'updated_by' => $this->resource['updated_by'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Classes/ClassSectionCollection.php b/app/Http/Resources/Classes/ClassSectionCollection.php
new file mode 100644
index 00000000..273ae72f
--- /dev/null
+++ b/app/Http/Resources/Classes/ClassSectionCollection.php
@@ -0,0 +1,27 @@
+ [
+ 'total' => $this->resource->total() ?? null,
+ 'per_page' => $this->resource->perPage() ?? null,
+ 'current_page' => $this->resource->currentPage() ?? null,
+ 'last_page' => $this->resource->lastPage() ?? null,
+ ],
+ ];
+ }
+}
diff --git a/app/Http/Resources/Classes/ClassSectionResource.php b/app/Http/Resources/Classes/ClassSectionResource.php
new file mode 100644
index 00000000..f638eabd
--- /dev/null
+++ b/app/Http/Resources/Classes/ClassSectionResource.php
@@ -0,0 +1,24 @@
+ $this->resource->id ?? null,
+ 'class_id' => $this->resource->class_id ?? null,
+ 'class_section_id' => $this->resource->class_section_id ?? null,
+ 'class_section_name' => $this->resource->class_section_name ?? null,
+ 'class_name' => $this->resource->class_name ?? null,
+ 'semester' => $this->resource->semester ?? null,
+ 'school_year' => $this->resource->school_year ?? null,
+ 'created_at' => $this->resource->created_at ?? null,
+ 'updated_at' => $this->resource->updated_at ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Frontend/ContactSubmissionResource.php b/app/Http/Resources/Frontend/ContactSubmissionResource.php
new file mode 100644
index 00000000..7f95bd06
--- /dev/null
+++ b/app/Http/Resources/Frontend/ContactSubmissionResource.php
@@ -0,0 +1,18 @@
+ $this->resource['email'] ?? null,
+ 'email_sent' => (bool) ($this->resource['email_sent'] ?? false),
+ 'contact_id' => $this->resource['contact_id'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Frontend/FrontendPageResource.php b/app/Http/Resources/Frontend/FrontendPageResource.php
new file mode 100644
index 00000000..b1c92626
--- /dev/null
+++ b/app/Http/Resources/Frontend/FrontendPageResource.php
@@ -0,0 +1,16 @@
+ $this->resource['page'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Frontend/FrontendUserResource.php b/app/Http/Resources/Frontend/FrontendUserResource.php
new file mode 100644
index 00000000..b02f262e
--- /dev/null
+++ b/app/Http/Resources/Frontend/FrontendUserResource.php
@@ -0,0 +1,17 @@
+ $this->firstname ?? null,
+ 'lastname' => $this->lastname ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Frontend/LandingPageResource.php b/app/Http/Resources/Frontend/LandingPageResource.php
new file mode 100644
index 00000000..e8aaccb4
--- /dev/null
+++ b/app/Http/Resources/Frontend/LandingPageResource.php
@@ -0,0 +1,18 @@
+ $this->resource['role'] ?? null,
+ 'dashboard' => $this->resource['dashboard'] ?? null,
+ 'summary' => $this->resource['summary'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Frontend/PageContentResource.php b/app/Http/Resources/Frontend/PageContentResource.php
new file mode 100644
index 00000000..ccb1eb99
--- /dev/null
+++ b/app/Http/Resources/Frontend/PageContentResource.php
@@ -0,0 +1,17 @@
+ $this->resource['type'] ?? null,
+ 'content' => $this->resource['content'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Frontend/ProfileIconResource.php b/app/Http/Resources/Frontend/ProfileIconResource.php
new file mode 100644
index 00000000..b9310089
--- /dev/null
+++ b/app/Http/Resources/Frontend/ProfileIconResource.php
@@ -0,0 +1,17 @@
+ $this->resource['userName'] ?? null,
+ 'user_initials' => $this->resource['userInitials'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Messaging/MessageCollection.php b/app/Http/Resources/Messaging/MessageCollection.php
new file mode 100644
index 00000000..3703c1a4
--- /dev/null
+++ b/app/Http/Resources/Messaging/MessageCollection.php
@@ -0,0 +1,31 @@
+resource, 'total')) {
+ return [];
+ }
+
+ return [
+ 'meta' => [
+ 'total' => $this->resource->total() ?? null,
+ 'per_page' => $this->resource->perPage() ?? null,
+ 'current_page' => $this->resource->currentPage() ?? null,
+ 'last_page' => $this->resource->lastPage() ?? null,
+ ],
+ ];
+ }
+
+ public function toArray($request): array
+ {
+ return $this->collection->map(function ($item) use ($request) {
+ return (new MessageResource($item))->toArray($request);
+ })->all();
+ }
+}
diff --git a/app/Http/Resources/Messaging/MessageResource.php b/app/Http/Resources/Messaging/MessageResource.php
new file mode 100644
index 00000000..3acb8ac3
--- /dev/null
+++ b/app/Http/Resources/Messaging/MessageResource.php
@@ -0,0 +1,31 @@
+ $this->id ?? null,
+ 'sender_id' => $this->sender_id ?? null,
+ 'recipient_id' => $this->recipient_id ?? null,
+ 'subject' => $this->subject ?? null,
+ 'message' => $this->message ?? null,
+ 'sent_datetime' => optional($this->sent_datetime)->toDateTimeString(),
+ 'read_status' => (bool) ($this->read_status ?? false),
+ 'read_datetime' => optional($this->read_datetime)->toDateTimeString(),
+ 'message_number' => $this->message_number ?? null,
+ 'priority' => $this->priority ?? null,
+ 'attachment' => $this->attachment ?? null,
+ 'status' => $this->status ?? null,
+ 'semester' => $this->semester ?? null,
+ 'school_year' => $this->school_year ?? null,
+ 'sender_name' => $this->sender ? trim($this->sender->firstname . ' ' . $this->sender->lastname) : null,
+ 'recipient_name' => $this->recipient ? trim($this->recipient->firstname . ' ' . $this->recipient->lastname) : null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Messaging/RecipientResource.php b/app/Http/Resources/Messaging/RecipientResource.php
new file mode 100644
index 00000000..27c93202
--- /dev/null
+++ b/app/Http/Resources/Messaging/RecipientResource.php
@@ -0,0 +1,17 @@
+ $this->resource['id'] ?? null,
+ 'name' => $this->resource['name'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Preferences/PreferencesCollection.php b/app/Http/Resources/Preferences/PreferencesCollection.php
new file mode 100644
index 00000000..244c03f3
--- /dev/null
+++ b/app/Http/Resources/Preferences/PreferencesCollection.php
@@ -0,0 +1,27 @@
+ [
+ 'total' => $this->resource->total() ?? null,
+ 'per_page' => $this->resource->perPage() ?? null,
+ 'current_page' => $this->resource->currentPage() ?? null,
+ 'last_page' => $this->resource->lastPage() ?? null,
+ ],
+ ];
+ }
+}
diff --git a/app/Http/Resources/Preferences/PreferencesListResource.php b/app/Http/Resources/Preferences/PreferencesListResource.php
new file mode 100644
index 00000000..e745c931
--- /dev/null
+++ b/app/Http/Resources/Preferences/PreferencesListResource.php
@@ -0,0 +1,28 @@
+ $this->resource->id ?? null,
+ 'user_id' => $this->resource->user_id ?? null,
+ 'firstname' => $this->resource->firstname ?? null,
+ 'lastname' => $this->resource->lastname ?? null,
+ 'email' => $this->resource->email ?? null,
+ 'receive_email_notifications' => (bool) ($this->resource->notification_email ?? false),
+ 'receive_sms_notifications' => (bool) ($this->resource->notification_sms ?? false),
+ 'theme' => $this->resource->theme ?? null,
+ 'language' => $this->resource->language ?? null,
+ 'style_color' => $this->resource->style_color ?? null,
+ 'menu_color' => $this->resource->menu_color ?? null,
+ 'created_at' => $this->resource->created_at ?? null,
+ 'updated_at' => $this->resource->updated_at ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Preferences/PreferencesResource.php b/app/Http/Resources/Preferences/PreferencesResource.php
new file mode 100644
index 00000000..dc8ea91e
--- /dev/null
+++ b/app/Http/Resources/Preferences/PreferencesResource.php
@@ -0,0 +1,21 @@
+ $this->resource['receive_email_notifications'] ?? false,
+ 'receive_sms_notifications' => $this->resource['receive_sms_notifications'] ?? false,
+ 'theme' => $this->resource['theme'] ?? null,
+ 'language' => $this->resource['language'] ?? null,
+ 'style_color' => $this->resource['style_color'] ?? null,
+ 'menu_color' => $this->resource['menu_color'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Security/IpBanCollection.php b/app/Http/Resources/Security/IpBanCollection.php
new file mode 100644
index 00000000..355ca64c
--- /dev/null
+++ b/app/Http/Resources/Security/IpBanCollection.php
@@ -0,0 +1,27 @@
+ [
+ 'total' => $this->resource->total() ?? null,
+ 'per_page' => $this->resource->perPage() ?? null,
+ 'current_page' => $this->resource->currentPage() ?? null,
+ 'last_page' => $this->resource->lastPage() ?? null,
+ ],
+ ];
+ }
+}
diff --git a/app/Http/Resources/Security/IpBanResource.php b/app/Http/Resources/Security/IpBanResource.php
new file mode 100644
index 00000000..3e215201
--- /dev/null
+++ b/app/Http/Resources/Security/IpBanResource.php
@@ -0,0 +1,26 @@
+resource->blocked_until ?? null;
+ $isActive = $blockedUntil !== null && $blockedUntil > now('UTC');
+
+ return [
+ 'id' => $this->resource->id ?? null,
+ 'ip_address' => $this->resource->ip_address ?? null,
+ 'attempts' => $this->resource->attempts ?? 0,
+ 'last_attempt_at' => $this->resource->last_attempt_at ?? null,
+ 'blocked_until' => $blockedUntil,
+ 'is_active' => $isActive,
+ 'created_at' => $this->resource->created_at ?? null,
+ 'updated_at' => $this->resource->updated_at ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Settings/PolicyResource.php b/app/Http/Resources/Settings/PolicyResource.php
new file mode 100644
index 00000000..065db175
--- /dev/null
+++ b/app/Http/Resources/Settings/PolicyResource.php
@@ -0,0 +1,20 @@
+ $this->resource['type'] ?? null,
+ 'title' => $this->resource['title'] ?? null,
+ 'format' => $this->resource['format'] ?? 'html',
+ 'content' => $this->resource['content'] ?? '',
+ 'updated_at' => $this->resource['updated_at'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Settings/SettingsResource.php b/app/Http/Resources/Settings/SettingsResource.php
new file mode 100644
index 00000000..fbec7cb3
--- /dev/null
+++ b/app/Http/Resources/Settings/SettingsResource.php
@@ -0,0 +1,18 @@
+ $this->resource['site_name'] ?? null,
+ 'administrator_email' => $this->resource['administrator_email'] ?? null,
+ 'timezone' => $this->resource['timezone'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Staff/StaffCollection.php b/app/Http/Resources/Staff/StaffCollection.php
new file mode 100644
index 00000000..7eb263e1
--- /dev/null
+++ b/app/Http/Resources/Staff/StaffCollection.php
@@ -0,0 +1,31 @@
+collection->map(function ($item) use ($request) {
+ return (new StaffResource($item))->toArray($request);
+ })->all();
+ }
+
+ public function with($request): array
+ {
+ if (!method_exists($this->resource, 'total')) {
+ return [];
+ }
+
+ return [
+ 'meta' => [
+ 'total' => $this->resource->total() ?? null,
+ 'per_page' => $this->resource->perPage() ?? null,
+ 'current_page' => $this->resource->currentPage() ?? null,
+ 'last_page' => $this->resource->lastPage() ?? null,
+ ],
+ ];
+ }
+}
diff --git a/app/Http/Resources/Staff/StaffResource.php b/app/Http/Resources/Staff/StaffResource.php
new file mode 100644
index 00000000..9a880e21
--- /dev/null
+++ b/app/Http/Resources/Staff/StaffResource.php
@@ -0,0 +1,29 @@
+ $this->id ?? null,
+ 'user_id' => $this->user_id ?? null,
+ 'firstname' => $this->firstname ?? null,
+ 'lastname' => $this->lastname ?? null,
+ 'email' => $this->email ?? null,
+ 'phone' => $this->phone ?? null,
+ 'role_name' => $this->role_name ?? null,
+ 'active_role' => $this->active_role ?? null,
+ 'school_year' => $this->school_year ?? null,
+ 'class_section' => $this->class_section ?? null,
+ 'verification_issue' => (bool) ($this->verification_issue ?? false),
+ 'school_id' => $this->school_id ?? null,
+ 'created_at' => optional($this->created_at)->toDateTimeString(),
+ 'updated_at' => optional($this->updated_at)->toDateTimeString(),
+ ];
+ }
+}
diff --git a/app/Http/Resources/Support/ContactMessageResource.php b/app/Http/Resources/Support/ContactMessageResource.php
new file mode 100644
index 00000000..e631421e
--- /dev/null
+++ b/app/Http/Resources/Support/ContactMessageResource.php
@@ -0,0 +1,17 @@
+ (bool) ($this->resource['email_sent'] ?? false),
+ 'recipient' => $this->resource['recipient'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Support/SupportRequestCollection.php b/app/Http/Resources/Support/SupportRequestCollection.php
new file mode 100644
index 00000000..fda04237
--- /dev/null
+++ b/app/Http/Resources/Support/SupportRequestCollection.php
@@ -0,0 +1,31 @@
+collection->map(function ($item) use ($request) {
+ return (new SupportRequestResource($item))->toArray($request);
+ })->all();
+ }
+
+ public function with($request): array
+ {
+ if (!method_exists($this->resource, 'total')) {
+ return [];
+ }
+
+ return [
+ 'meta' => [
+ 'total' => $this->resource->total() ?? null,
+ 'per_page' => $this->resource->perPage() ?? null,
+ 'current_page' => $this->resource->currentPage() ?? null,
+ 'last_page' => $this->resource->lastPage() ?? null,
+ ],
+ ];
+ }
+}
diff --git a/app/Http/Resources/Support/SupportRequestResource.php b/app/Http/Resources/Support/SupportRequestResource.php
new file mode 100644
index 00000000..e8dafa00
--- /dev/null
+++ b/app/Http/Resources/Support/SupportRequestResource.php
@@ -0,0 +1,24 @@
+ $this->id ?? null,
+ 'user_id' => $this->user_id ?? null,
+ 'subject' => $this->subject ?? null,
+ 'message' => $this->message ?? null,
+ 'status' => $this->status ?? null,
+ 'semester' => $this->semester ?? null,
+ 'school_year' => $this->school_year ?? null,
+ 'created_at' => optional($this->created_at)->toDateTimeString(),
+ 'updated_at' => optional($this->updated_at)->toDateTimeString(),
+ ];
+ }
+}
diff --git a/app/Http/Resources/System/DashboardRouteResource.php b/app/Http/Resources/System/DashboardRouteResource.php
new file mode 100644
index 00000000..5b6dabf3
--- /dev/null
+++ b/app/Http/Resources/System/DashboardRouteResource.php
@@ -0,0 +1,19 @@
+ $this->resource['route'] ?? null,
+ 'role' => $this->resource['role'] ?? null,
+ 'role_slug' => $this->resource['role_slug'] ?? null,
+ 'roles' => $this->resource['roles'] ?? [],
+ ];
+ }
+}
diff --git a/app/Http/Resources/System/HealthStatusResource.php b/app/Http/Resources/System/HealthStatusResource.php
new file mode 100644
index 00000000..e78d0025
--- /dev/null
+++ b/app/Http/Resources/System/HealthStatusResource.php
@@ -0,0 +1,20 @@
+ $this->resource['ok'] ?? false,
+ 'paths' => $this->resource['paths'] ?? [],
+ 'database' => $this->resource['database'] ?? [],
+ 'write_path' => $this->resource['write_path'] ?? null,
+ 'timestamp' => $this->resource['timestamp'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/System/NavBuilderDataResource.php b/app/Http/Resources/System/NavBuilderDataResource.php
new file mode 100644
index 00000000..bc53417a
--- /dev/null
+++ b/app/Http/Resources/System/NavBuilderDataResource.php
@@ -0,0 +1,18 @@
+ $this->resource['items'] ?? [],
+ 'roles' => $this->resource['roles'] ?? [],
+ 'parentOptions' => $this->resource['parentOptions'] ?? [],
+ ];
+ }
+}
diff --git a/app/Http/Resources/System/NavItemResource.php b/app/Http/Resources/System/NavItemResource.php
new file mode 100644
index 00000000..67b0d82b
--- /dev/null
+++ b/app/Http/Resources/System/NavItemResource.php
@@ -0,0 +1,28 @@
+resource) ? $this->resource : $this->resource->toArray();
+
+ return [
+ 'id' => (int) ($item['id'] ?? 0),
+ 'menu_parent_id' => $item['menu_parent_id'] ?? null,
+ 'label' => (string) ($item['label'] ?? ''),
+ 'url' => $item['url'] ?? null,
+ 'icon_class' => $item['icon_class'] ?? null,
+ 'target' => $item['target'] ?? null,
+ 'sort_order' => (int) ($item['sort_order'] ?? 0),
+ 'is_enabled' => (int) ($item['is_enabled'] ?? 0),
+ 'children' => !empty($item['children'])
+ ? NavItemResource::collection($item['children'])
+ : [],
+ ];
+ }
+}
diff --git a/app/Http/Resources/Ui/UiStyleResource.php b/app/Http/Resources/Ui/UiStyleResource.php
new file mode 100644
index 00000000..ee052699
--- /dev/null
+++ b/app/Http/Resources/Ui/UiStyleResource.php
@@ -0,0 +1,20 @@
+ $this->style_color ?? null,
+ 'menu_color' => $this->menu_color ?? null,
+ 'menu_custom_bg' => $this->menu_custom_bg ?? null,
+ 'menu_custom_text' => $this->menu_custom_text ?? null,
+ 'menu_custom_mode' => $this->menu_custom_mode ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Utilities/PhoneFormatResource.php b/app/Http/Resources/Utilities/PhoneFormatResource.php
new file mode 100644
index 00000000..9a440784
--- /dev/null
+++ b/app/Http/Resources/Utilities/PhoneFormatResource.php
@@ -0,0 +1,18 @@
+ $this->resource['raw'] ?? null,
+ 'formatted' => $this->resource['formatted'] ?? null,
+ 'is_valid' => $this->resource['is_valid'] ?? false,
+ ];
+ }
+}
diff --git a/app/Models/SupportRequest.php b/app/Models/SupportRequest.php
new file mode 100644
index 00000000..792488a3
--- /dev/null
+++ b/app/Models/SupportRequest.php
@@ -0,0 +1,34 @@
+ 'integer',
+ 'created_at' => 'datetime',
+ 'updated_at' => 'datetime',
+ ];
+
+ public function user()
+ {
+ return $this->belongsTo(User::class, 'user_id');
+ }
+}
diff --git a/app/Policies/ClassSectionPolicy.php b/app/Policies/ClassSectionPolicy.php
new file mode 100644
index 00000000..fff0f965
--- /dev/null
+++ b/app/Policies/ClassSectionPolicy.php
@@ -0,0 +1,55 @@
+isAdmin($user);
+ }
+
+ public function update(User $user, ClassSection $classSection): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ public function delete(User $user, ClassSection $classSection): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ public function seedDefaults(User $user): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ private function isAdmin(User $user): bool
+ {
+ $roles = $user->roles()
+ ->pluck('roles.name')
+ ->map(fn ($name) => strtolower((string) $name))
+ ->toArray();
+
+ foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $role) {
+ if (in_array($role, $roles, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Policies/IpAttemptPolicy.php b/app/Policies/IpAttemptPolicy.php
new file mode 100644
index 00000000..17bf6c23
--- /dev/null
+++ b/app/Policies/IpAttemptPolicy.php
@@ -0,0 +1,50 @@
+isAdmin($user);
+ }
+
+ public function view(User $user, IpAttempt $ipAttempt): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ public function create(User $user): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ public function update(User $user, IpAttempt $ipAttempt): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ public function delete(User $user, IpAttempt $ipAttempt): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ private function isAdmin(User $user): bool
+ {
+ $roles = $user->roles()
+ ->pluck('roles.name')
+ ->map(fn ($name) => strtolower((string) $name))
+ ->toArray();
+
+ foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $role) {
+ if (in_array($role, $roles, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Policies/LateSlipLogPolicy.php b/app/Policies/LateSlipLogPolicy.php
new file mode 100644
index 00000000..b143d7f2
--- /dev/null
+++ b/app/Policies/LateSlipLogPolicy.php
@@ -0,0 +1,40 @@
+isAdmin($user);
+ }
+
+ public function view(User $user, LateSlipLog $log): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ public function delete(User $user, LateSlipLog $log): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ private function isAdmin(User $user): bool
+ {
+ $roles = $user->roles()
+ ->pluck('roles.name')
+ ->map(fn ($name) => strtolower((string) $name))
+ ->toArray();
+
+ foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $role) {
+ if (in_array($role, $roles, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Policies/MessagePolicy.php b/app/Policies/MessagePolicy.php
new file mode 100644
index 00000000..7d98e084
--- /dev/null
+++ b/app/Policies/MessagePolicy.php
@@ -0,0 +1,55 @@
+owns($user, $message) || $this->isAdmin($user);
+ }
+
+ public function create(User $user): bool
+ {
+ return $user !== null;
+ }
+
+ public function update(User $user, Message $message): bool
+ {
+ return $this->owns($user, $message) || $this->isAdmin($user);
+ }
+
+ public function delete(User $user, Message $message): bool
+ {
+ return $this->owns($user, $message) || $this->isAdmin($user);
+ }
+
+ private function owns(User $user, Message $message): bool
+ {
+ return $message->sender_id === $user->id || $message->recipient_id === $user->id;
+ }
+
+ private function isAdmin(User $user): bool
+ {
+ $roles = $user->roles()
+ ->pluck('roles.name')
+ ->map(fn ($name) => strtolower((string) $name))
+ ->toArray();
+
+ foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $role) {
+ if (in_array($role, $roles, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Policies/NavItemPolicy.php b/app/Policies/NavItemPolicy.php
new file mode 100644
index 00000000..f6022037
--- /dev/null
+++ b/app/Policies/NavItemPolicy.php
@@ -0,0 +1,15 @@
+roles()->pluck('roles.name')->map(fn ($name) => strtolower((string) $name))->toArray();
+ return in_array('admin', $roles, true) || in_array('administrator', $roles, true);
+ }
+}
diff --git a/app/Policies/PreferencesPolicy.php b/app/Policies/PreferencesPolicy.php
new file mode 100644
index 00000000..7ce69ca0
--- /dev/null
+++ b/app/Policies/PreferencesPolicy.php
@@ -0,0 +1,50 @@
+isAdmin($user);
+ }
+
+ public function view(User $user, Preferences $preferences): bool
+ {
+ return $preferences->user_id === $user->id || $this->isAdmin($user);
+ }
+
+ public function update(User $user, Preferences $preferences): bool
+ {
+ return $preferences->user_id === $user->id || $this->isAdmin($user);
+ }
+
+ public function delete(User $user, Preferences $preferences): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ public function create(User $user): bool
+ {
+ return $user !== null;
+ }
+
+ private function isAdmin(User $user): bool
+ {
+ $roles = $user->roles()
+ ->pluck('roles.name')
+ ->map(fn ($name) => strtolower((string) $name))
+ ->toArray();
+
+ foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $role) {
+ if (in_array($role, $roles, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Policies/SettingPolicy.php b/app/Policies/SettingPolicy.php
new file mode 100644
index 00000000..974410c2
--- /dev/null
+++ b/app/Policies/SettingPolicy.php
@@ -0,0 +1,35 @@
+isAdmin($user);
+ }
+
+ public function update(User $user, Setting $setting): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ private function isAdmin(User $user): bool
+ {
+ $roles = $user->roles()
+ ->pluck('roles.name')
+ ->map(fn ($name) => strtolower((string) $name))
+ ->toArray();
+
+ foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $role) {
+ if (in_array($role, $roles, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Policies/StaffPolicy.php b/app/Policies/StaffPolicy.php
new file mode 100644
index 00000000..90e6143e
--- /dev/null
+++ b/app/Policies/StaffPolicy.php
@@ -0,0 +1,50 @@
+isAdmin($user);
+ }
+
+ public function view(User $user, Staff $staff): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ public function create(User $user): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ public function update(User $user, Staff $staff): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ public function delete(User $user, Staff $staff): bool
+ {
+ return $this->isAdmin($user);
+ }
+
+ private function isAdmin(User $user): bool
+ {
+ $roles = $user->roles()
+ ->pluck('roles.name')
+ ->map(fn ($name) => strtolower((string) $name))
+ ->toArray();
+
+ foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $role) {
+ if (in_array($role, $roles, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Policies/SupportRequestPolicy.php b/app/Policies/SupportRequestPolicy.php
new file mode 100644
index 00000000..c623c169
--- /dev/null
+++ b/app/Policies/SupportRequestPolicy.php
@@ -0,0 +1,40 @@
+user_id === $user->id || $this->isAdmin($user);
+ }
+
+ public function create(User $user): bool
+ {
+ return $user !== null;
+ }
+
+ private function isAdmin(User $user): bool
+ {
+ $roles = $user->roles()
+ ->pluck('roles.name')
+ ->map(fn ($name) => strtolower((string) $name))
+ ->toArray();
+
+ foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $role) {
+ if (in_array($role, $roles, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 28f26a46..812b79af 100755
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -12,6 +12,25 @@ use App\Services\Invoices\InvoiceConfigService;
use App\Services\Invoices\InvoiceGradeService;
use App\Services\Invoices\InvoiceTuitionService;
use App\Services\Staff\StaffTimeOffLinkService;
+use App\Models\NavItem;
+use App\Models\ClassSection;
+use App\Models\IpAttempt;
+use App\Models\LateSlipLog;
+use App\Models\Message;
+use App\Models\Preferences;
+use App\Models\Staff;
+use App\Models\SupportRequest;
+use App\Models\Setting;
+use App\Policies\NavItemPolicy;
+use App\Policies\ClassSectionPolicy;
+use App\Policies\IpAttemptPolicy;
+use App\Policies\LateSlipLogPolicy;
+use App\Policies\MessagePolicy;
+use App\Policies\PreferencesPolicy;
+use App\Policies\StaffPolicy;
+use App\Policies\SupportRequestPolicy;
+use App\Policies\SettingPolicy;
+use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Routing\Redirector;
use Illuminate\Support\ServiceProvider;
@@ -48,6 +67,16 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
+ Gate::policy(NavItem::class, NavItemPolicy::class);
+ Gate::policy(ClassSection::class, ClassSectionPolicy::class);
+ Gate::policy(IpAttempt::class, IpAttemptPolicy::class);
+ Gate::policy(LateSlipLog::class, LateSlipLogPolicy::class);
+ Gate::policy(Message::class, MessagePolicy::class);
+ Gate::policy(Preferences::class, PreferencesPolicy::class);
+ Gate::policy(Staff::class, StaffPolicy::class);
+ Gate::policy(SupportRequest::class, SupportRequestPolicy::class);
+ Gate::policy(Setting::class, SettingPolicy::class);
+
$this->app->resolving(FormRequest::class, function (FormRequest $request, $app): void {
$request->setContainer($app)->setRedirector($app->make(Redirector::class));
});
diff --git a/app/Services/Attendance/LateSlipLogCommandService.php b/app/Services/Attendance/LateSlipLogCommandService.php
new file mode 100644
index 00000000..6987dc89
--- /dev/null
+++ b/app/Services/Attendance/LateSlipLogCommandService.php
@@ -0,0 +1,13 @@
+delete();
+ }
+}
diff --git a/app/Services/Attendance/LateSlipLogQueryService.php b/app/Services/Attendance/LateSlipLogQueryService.php
new file mode 100644
index 00000000..5d27271c
--- /dev/null
+++ b/app/Services/Attendance/LateSlipLogQueryService.php
@@ -0,0 +1,63 @@
+where('school_year', (string) $filters['school_year']);
+ }
+
+ if (!empty($filters['semester'])) {
+ $query->where('semester', (string) $filters['semester']);
+ }
+
+ if (!empty($filters['q'])) {
+ $query->where('student_name', 'like', '%' . $filters['q'] . '%');
+ }
+
+ $dateFrom = $this->toDbDate($filters['date_from'] ?? null);
+ if ($dateFrom) {
+ $query->where('slip_date', '>=', $dateFrom);
+ }
+
+ $dateTo = $this->toDbDate($filters['date_to'] ?? null);
+ if ($dateTo) {
+ $query->where('slip_date', '<=', $dateTo);
+ }
+
+ $sortBy = (string) ($filters['sort_by'] ?? 'id');
+ $sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
+ $allowedSorts = ['id', 'slip_date', 'student_name', 'printed_at', 'updated_at'];
+
+ if (in_array($sortBy, $allowedSorts, true)) {
+ $query->orderBy($sortBy, $sortDir);
+ } else {
+ $query->orderBy('id', 'desc');
+ }
+
+ return $query->paginate($perPage, ['*'], 'page', $page);
+ }
+
+ public function find(int $id): ?LateSlipLog
+ {
+ return LateSlipLog::query()->find($id);
+ }
+
+ private function toDbDate(?string $value): ?string
+ {
+ $value = trim((string) $value);
+ if ($value === '') {
+ return null;
+ }
+ $ts = strtotime($value);
+ return $ts ? date('Y-m-d', $ts) : null;
+ }
+}
diff --git a/app/Services/Auth/RegistrationCaptchaService.php b/app/Services/Auth/RegistrationCaptchaService.php
index 47c79924..90cfc3fd 100644
--- a/app/Services/Auth/RegistrationCaptchaService.php
+++ b/app/Services/Auth/RegistrationCaptchaService.php
@@ -4,6 +4,14 @@ namespace App\Services\Auth;
class RegistrationCaptchaService
{
+ private function cacheKey(): string
+ {
+ $ip = (string) (request()?->ip() ?? 'unknown');
+ $ua = (string) (request()?->header('User-Agent') ?? '');
+ $hash = substr(sha1($ua), 0, 12);
+ return 'captcha:' . $ip . ':' . $hash;
+ }
+
public function generate(?int $length = null): string
{
$length = $length ?? random_int(4, 8);
@@ -14,14 +22,14 @@ class RegistrationCaptchaService
$captchaText .= $characters[random_int(0, strlen($characters) - 1)];
}
- session()->put('captcha_answer', $captchaText);
+ cache()->put($this->cacheKey(), $captchaText, now()->addMinutes(10));
return $captchaText;
}
public function verify(string $input): bool
{
- $expected = (string) session()->get('captcha_answer', '');
+ $expected = (string) cache()->get($this->cacheKey(), '');
if ($expected === '') {
return false;
}
@@ -31,6 +39,6 @@ class RegistrationCaptchaService
public function clear(): void
{
- session()->forget('captcha_answer');
+ cache()->forget($this->cacheKey());
}
}
diff --git a/app/Services/Auth/SessionActivityService.php b/app/Services/Auth/SessionActivityService.php
deleted file mode 100644
index a4ea4934..00000000
--- a/app/Services/Auth/SessionActivityService.php
+++ /dev/null
@@ -1,39 +0,0 @@
-session->has('last_activity');
- }
-
- public function getLastActivity(): ?int
- {
- $value = $this->session->get('last_activity');
- return $value !== null ? (int) $value : null;
- }
-
- public function setLastActivity(int $timestamp): void
- {
- $this->session->put('last_activity', $timestamp);
- }
-
- public function clearLastActivity(): void
- {
- $this->session->forget('last_activity');
- }
-
- public function invalidate(): void
- {
- $this->session->invalidate();
- $this->session->regenerateToken();
- }
-}
diff --git a/app/Services/Auth/SessionTimeoutConfigService.php b/app/Services/Auth/SessionTimeoutConfigService.php
deleted file mode 100644
index ac3e416b..00000000
--- a/app/Services/Auth/SessionTimeoutConfigService.php
+++ /dev/null
@@ -1,21 +0,0 @@
- SessionTimeout::TIMEOUT_DURATION,
- 'warning_time' => SessionTimeout::WARNING_THRESHOLD,
- 'check_interval' => SessionTimeout::CHECK_INTERVAL,
- 'logout_url' => URL::to('/auth/logout'),
- 'keep_alive_url' => route('session.timeout.ping'),
- 'check_url' => route('session.timeout.check'),
- ];
- }
-}
diff --git a/app/Services/Auth/SessionTimeoutService.php b/app/Services/Auth/SessionTimeoutService.php
deleted file mode 100644
index 54f9f15b..00000000
--- a/app/Services/Auth/SessionTimeoutService.php
+++ /dev/null
@@ -1,72 +0,0 @@
-activityService->hasLastActivity()) {
- return $this->expireSession();
- }
-
- $lastActivity = (int) $this->activityService->getLastActivity();
- $elapsed = time() - $lastActivity;
-
- if ($elapsed > SessionTimeout::TIMEOUT_DURATION) {
- return $this->expireSession();
- }
-
- if ($elapsed > SessionTimeout::WARNING_THRESHOLD) {
- return [
- 'status' => 'warning',
- 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed,
- ];
- }
-
- return [
- 'status' => 'active',
- 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed,
- ];
- }
-
- public function pingActivity(): array
- {
- if (
- !$this->activityService->hasLastActivity()
- || (time() - (int) $this->activityService->getLastActivity() > SessionTimeout::TIMEOUT_DURATION)
- ) {
- return $this->expireSession();
- }
-
- $this->activityService->setLastActivity(time());
-
- return [
- 'status' => 'active',
- 'time_remaining' => SessionTimeout::TIMEOUT_DURATION,
- ];
- }
-
- private function expireSession(): array
- {
- Log::info('Session expired due to inactivity.');
- $this->activityService->clearLastActivity();
- $this->activityService->invalidate();
-
- return [
- 'status' => 'expired',
- 'redirect' => URL::to('/login'),
- 'message' => 'Your session has expired due to inactivity.',
- 'time_remaining' => 0,
- ];
- }
-}
diff --git a/app/Services/Auth/UserRoleService.php b/app/Services/Auth/UserRoleService.php
new file mode 100644
index 00000000..b1f8abc8
--- /dev/null
+++ b/app/Services/Auth/UserRoleService.php
@@ -0,0 +1,54 @@
+where('user_id', $userId)
+ ->whereNull('deleted_at')
+ ->pluck('role_id')
+ ->map(fn ($id) => (int) $id)
+ ->all();
+ }
+
+ public function hasPermissionForCrud(array $roleIds, string $permissionName, string $crudAction = 'read'): bool
+ {
+ if (empty($roleIds)) {
+ return false;
+ }
+
+ $rows = DB::table('role_permissions as rp')
+ ->join('permissions as p', 'p.id', '=', 'rp.permission_id')
+ ->whereIn('rp.role_id', $roleIds)
+ ->where('p.name', $permissionName)
+ ->select('rp.can_create', 'rp.can_read', 'rp.can_update', 'rp.can_delete', 'rp.can_manage')
+ ->get();
+
+ foreach ($rows as $row) {
+ $manage = (bool) ($row->can_manage ?? false);
+ if ($manage) {
+ return true;
+ }
+
+ if ($crudAction === 'create' && !empty($row->can_create)) {
+ return true;
+ }
+ if ($crudAction === 'read' && !empty($row->can_read)) {
+ return true;
+ }
+ if ($crudAction === 'update' && !empty($row->can_update)) {
+ return true;
+ }
+ if ($crudAction === 'delete' && !empty($row->can_delete)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Services/ClassPreparation/ClassPreparationAdjustmentWriterService.php b/app/Services/ClassPreparation/ClassPreparationAdjustmentWriterService.php
new file mode 100644
index 00000000..fb2b0942
--- /dev/null
+++ b/app/Services/ClassPreparation/ClassPreparationAdjustmentWriterService.php
@@ -0,0 +1,51 @@
+ $adjustment) {
+ $item = (string) $itemName;
+ if ($item === '' || !isset($allowed[$item])) {
+ continue;
+ }
+
+ $row = ClassPrepAdjustment::query()
+ ->where('class_section_id', $classSectionId)
+ ->where('school_year', $schoolYear)
+ ->where('item_name', $item)
+ ->first();
+
+ if ($row) {
+ $row->update([
+ 'adjustment' => (int) $adjustment,
+ 'adjustable' => 1,
+ ]);
+ $count++;
+ continue;
+ }
+
+ ClassPrepAdjustment::query()->create([
+ 'class_section_id' => $classSectionId,
+ 'item_name' => $item,
+ 'adjustment' => (int) $adjustment,
+ 'school_year' => $schoolYear,
+ 'adjustable' => 1,
+ ]);
+ $count++;
+ }
+
+ return $count;
+ });
+ }
+}
diff --git a/app/Services/ClassPreparation/ClassPreparationPrintService.php b/app/Services/ClassPreparation/ClassPreparationPrintService.php
new file mode 100644
index 00000000..ae9128f2
--- /dev/null
+++ b/app/Services/ClassPreparation/ClassPreparationPrintService.php
@@ -0,0 +1,60 @@
+context->getSemester();
+ $limitToSemester = $this->context->hasRosterForSemester($schoolYear, $semester);
+
+ $studentCount = $this->roster->getStudentCountForSection($schoolYear, $semester, $limitToSemester, $classSectionId);
+ $classLevel = $this->calculator->getClassLevelBySection($classSectionId);
+ $className = ClassSection::getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
+
+ $items = $this->calculator->calculatePrepItems($studentCount, $classLevel, $classSectionId);
+ [$items, $adjMap] = $this->adjustments->applyAdjustments($items, $classSectionId, $schoolYear);
+
+ $printedAt = $this->utcNow();
+ $ok = $this->logs->createLog($classSectionId, $className, $schoolYear, $items, $printedAt);
+
+ if (!$ok) {
+ Log::warning('Failed to store class preparation log.', [
+ 'class_section_id' => $classSectionId,
+ 'school_year' => $schoolYear,
+ ]);
+ }
+
+ return [
+ 'ok' => $ok,
+ 'class_section_id' => $classSectionId,
+ 'class_section' => $className,
+ 'school_year' => $schoolYear,
+ 'semester' => $semester,
+ 'prep_items' => $items,
+ 'adjustments' => $adjMap,
+ 'printed_at' => $ok ? $printedAt : null,
+ ];
+ }
+
+ private function utcNow(): string
+ {
+ if (function_exists('utc_now')) {
+ return (string) utc_now();
+ }
+ return now('UTC')->toDateTimeString();
+ }
+}
diff --git a/app/Services/ClassPreparation/ClassPreparationService.php b/app/Services/ClassPreparation/ClassPreparationService.php
index dc20a7f4..d24e210e 100644
--- a/app/Services/ClassPreparation/ClassPreparationService.php
+++ b/app/Services/ClassPreparation/ClassPreparationService.php
@@ -12,6 +12,8 @@ class ClassPreparationService
private ClassPreparationCalculatorService $calculator;
private ClassPreparationAdjustmentService $adjustments;
private ClassPreparationLogService $logs;
+ private ClassPreparationAdjustmentWriterService $adjustmentWriter;
+ private ClassPreparationPrintService $printService;
public function __construct(
ClassPreparationContextService $context,
@@ -19,7 +21,9 @@ class ClassPreparationService
ClassPreparationInventoryService $inventory,
ClassPreparationCalculatorService $calculator,
ClassPreparationAdjustmentService $adjustments,
- ClassPreparationLogService $logs
+ ClassPreparationLogService $logs,
+ ClassPreparationAdjustmentWriterService $adjustmentWriter,
+ ClassPreparationPrintService $printService
) {
$this->context = $context;
$this->roster = $roster;
@@ -27,6 +31,8 @@ class ClassPreparationService
$this->calculator = $calculator;
$this->adjustments = $adjustments;
$this->logs = $logs;
+ $this->adjustmentWriter = $adjustmentWriter;
+ $this->printService = $printService;
}
public function listPrep(?string $schoolYear = null, ?string $semester = null): array
@@ -111,6 +117,23 @@ class ClassPreparationService
return $count;
}
+ public function saveAdjustments(string $classSectionId, string $schoolYear, array $adjustments): int
+ {
+ $schoolYear = $schoolYear !== '' ? $schoolYear : $this->context->getSchoolYear();
+
+ return $this->adjustmentWriter->saveAdjustments(
+ $classSectionId,
+ $schoolYear,
+ $adjustments,
+ $this->calculator->getAllowedCategories()
+ );
+ }
+
+ public function printPrep(string $classSectionId, string $schoolYear, ?string $semester = null): array
+ {
+ return $this->printService->printPrep($classSectionId, $schoolYear, $semester);
+ }
+
private function utcNow(): string
{
if (function_exists('utc_now')) {
diff --git a/app/Services/ClassSections/ClassAttendanceService.php b/app/Services/ClassSections/ClassAttendanceService.php
new file mode 100644
index 00000000..46e28922
--- /dev/null
+++ b/app/Services/ClassSections/ClassAttendanceService.php
@@ -0,0 +1,32 @@
+find($teacherId);
+ $teacherName = trim((string) ($teacher?->firstname ?? '') . ' ' . (string) ($teacher?->lastname ?? ''));
+
+ $className = ClassSection::getClassSectionNameBySectionId($classSectionId) ?? (string) $classSectionId;
+ $students = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, $teacherId);
+
+ return [
+ 'teacher_name' => $teacherName,
+ 'class_name' => $className,
+ 'class_section_id' => $classSectionId,
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ 'students' => $students,
+ ];
+ }
+}
diff --git a/app/Services/ClassSections/ClassSectionCommandService.php b/app/Services/ClassSections/ClassSectionCommandService.php
new file mode 100644
index 00000000..db0d68e3
--- /dev/null
+++ b/app/Services/ClassSections/ClassSectionCommandService.php
@@ -0,0 +1,26 @@
+create($data);
+ }
+
+ public function update(ClassSection $section, array $data): ClassSection
+ {
+ $section->fill($data);
+ $section->save();
+
+ return $section->refresh();
+ }
+
+ public function delete(ClassSection $section): bool
+ {
+ return (bool) $section->delete();
+ }
+}
diff --git a/app/Services/ClassSections/ClassSectionQueryService.php b/app/Services/ClassSections/ClassSectionQueryService.php
new file mode 100644
index 00000000..517d4922
--- /dev/null
+++ b/app/Services/ClassSections/ClassSectionQueryService.php
@@ -0,0 +1,62 @@
+leftJoin('classes', 'classSection.class_id', '=', 'classes.id')
+ ->select('classSection.*', 'classes.class_name');
+
+ if (!empty($filters['search'])) {
+ $term = '%' . strtolower((string) $filters['search']) . '%';
+ $query->where(function ($q) use ($term) {
+ $q->whereRaw('LOWER(classSection.class_section_name) LIKE ?', [$term])
+ ->orWhereRaw('LOWER(classes.class_name) LIKE ?', [$term]);
+ });
+ }
+
+ if (!empty($filters['class_id'])) {
+ $query->where('classSection.class_id', (int) $filters['class_id']);
+ }
+
+ if (!empty($filters['school_year'])) {
+ $query->where('classSection.school_year', (string) $filters['school_year']);
+ }
+
+ if (!empty($filters['semester'])) {
+ $query->where('classSection.semester', (string) $filters['semester']);
+ }
+
+ $sortBy = (string) ($filters['sort_by'] ?? 'class_section_name');
+ $sortDir = strtolower((string) ($filters['sort_dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
+
+ $allowedSorts = [
+ 'class_section_name' => 'classSection.class_section_name',
+ 'class_section_id' => 'classSection.class_section_id',
+ 'class_id' => 'classSection.class_id',
+ 'school_year' => 'classSection.school_year',
+ 'semester' => 'classSection.semester',
+ 'class_name' => 'classes.class_name',
+ ];
+
+ $sortColumn = $allowedSorts[$sortBy] ?? $allowedSorts['class_section_name'];
+ $query->orderBy($sortColumn, $sortDir);
+
+ return $query->paginate($perPage, ['*'], 'page', $page);
+ }
+
+ public function find(int $id): ?ClassSection
+ {
+ return ClassSection::query()
+ ->leftJoin('classes', 'classSection.class_id', '=', 'classes.id')
+ ->select('classSection.*', 'classes.class_name')
+ ->where('classSection.id', $id)
+ ->first();
+ }
+}
diff --git a/app/Services/ClassSections/ClassSectionSeedService.php b/app/Services/ClassSections/ClassSectionSeedService.php
new file mode 100644
index 00000000..9fa45d44
--- /dev/null
+++ b/app/Services/ClassSections/ClassSectionSeedService.php
@@ -0,0 +1,52 @@
+ 'Class 1', 'schedule' => 'Monday 9:00 AM - 10:00 AM', 'capacity' => 30],
+ ['class_name' => 'Class 2', 'schedule' => 'Tuesday 9:00 AM - 10:00 AM', 'capacity' => 30],
+ ['class_name' => 'Class 3', 'schedule' => 'Wednesday 9:00 AM - 10:00 AM', 'capacity' => 30],
+ ['class_name' => 'Class 4', 'schedule' => 'Thursday 9:00 AM - 10:00 AM', 'capacity' => 30],
+ ['class_name' => 'Class 5', 'schedule' => 'Friday 9:00 AM - 10:00 AM', 'capacity' => 30],
+ ['class_name' => 'Class 6', 'schedule' => 'Monday 10:00 AM - 11:00 AM', 'capacity' => 30],
+ ['class_name' => 'Class 7', 'schedule' => 'Tuesday 10:00 AM - 11:00 AM', 'capacity' => 30],
+ ['class_name' => 'Class 8', 'schedule' => 'Wednesday 10:00 AM - 11:00 AM', 'capacity' => 30],
+ ['class_name' => 'Class 9', 'schedule' => 'Thursday 10:00 AM - 11:00 AM', 'capacity' => 30],
+ ['class_name' => 'Youth', 'schedule' => 'Friday 10:00 AM - 11:00 AM', 'capacity' => 30],
+ ];
+
+ return DB::transaction(function () use ($classes, $semester, $schoolYear) {
+ $created = 0;
+ foreach ($classes as $class) {
+ $row = SchoolClass::query()->firstOrCreate(
+ [
+ 'class_name' => $class['class_name'],
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ ],
+ [
+ 'schedule' => $class['schedule'],
+ 'capacity' => $class['capacity'],
+ ]
+ );
+
+ if ($row->wasRecentlyCreated) {
+ $created++;
+ }
+ }
+
+ return $created;
+ });
+ }
+}
diff --git a/app/Services/Dashboard/DashboardRouteService.php b/app/Services/Dashboard/DashboardRouteService.php
new file mode 100644
index 00000000..cbd2b9a1
--- /dev/null
+++ b/app/Services/Dashboard/DashboardRouteService.php
@@ -0,0 +1,69 @@
+ self::FALLBACK_ROUTE,
+ 'role' => null,
+ 'role_slug' => null,
+ 'roles' => [],
+ ];
+ }
+
+ $roles = $user->roles()
+ ->pluck('roles.name')
+ ->map(fn ($name) => strtolower((string) $name))
+ ->toArray();
+
+ if (empty($roles)) {
+ Log::warning('Dashboard route requested with no roles assigned.', [
+ 'user_id' => $user->id,
+ ]);
+
+ return [
+ 'route' => self::FALLBACK_ROUTE,
+ 'role' => null,
+ 'role_slug' => null,
+ 'roles' => [],
+ ];
+ }
+
+ $rows = Role::findByNamesOrSlugs($roles);
+
+ if (!empty($rows)) {
+ $role = $rows[0];
+ $route = $role->dashboard_route ?: self::FALLBACK_ROUTE;
+
+ return [
+ 'route' => $route,
+ 'role' => $role->name ?? null,
+ 'role_slug' => $role->slug ?? null,
+ 'roles' => $roles,
+ ];
+ }
+
+ Log::warning('No matching role dashboard route found.', [
+ 'user_id' => $user->id,
+ 'roles' => $roles,
+ ]);
+
+ return [
+ 'route' => self::FALLBACK_ROUTE,
+ 'role' => null,
+ 'role_slug' => null,
+ 'roles' => $roles,
+ ];
+ }
+}
diff --git a/app/Services/Docs/OpenApiRouteExporter.php b/app/Services/Docs/OpenApiRouteExporter.php
index 883e1ccb..82dec7ed 100644
--- a/app/Services/Docs/OpenApiRouteExporter.php
+++ b/app/Services/Docs/OpenApiRouteExporter.php
@@ -29,7 +29,7 @@ class OpenApiRouteExporter
private function mergeRoute(array &$paths, Route $route): void
{
- if (!$this->isApiControllerRoute($route)) {
+ if (!$this->isDocumentableRoute($route)) {
return;
}
@@ -70,14 +70,27 @@ class OpenApiRouteExporter
}
}
- private function isApiControllerRoute(Route $route): bool
+ private function isDocumentableRoute(Route $route): bool
{
+ $uri = ltrim($route->uri(), '/');
+ if (!str_starts_with($uri, 'api/v1') && !str_starts_with($uri, 'v1/')) {
+ return false;
+ }
+
$action = $route->getActionName();
if (!is_string($action)) {
return false;
}
- return str_starts_with($action, 'App\\Http\\Controllers\\Api\\');
+ if (str_contains($action, 'DocsController')) {
+ return false;
+ }
+
+ if ($action === 'Closure') {
+ return true;
+ }
+
+ return true;
}
private function resolveTag(string $uri): string
diff --git a/app/Services/Frontend/ContactSubmissionService.php b/app/Services/Frontend/ContactSubmissionService.php
new file mode 100644
index 00000000..71ce244f
--- /dev/null
+++ b/app/Services/Frontend/ContactSubmissionService.php
@@ -0,0 +1,62 @@
+configService->getSchoolYear() ?? '';
+ $semester = $payload['semester'] ?? $this->configService->getSemester() ?? '';
+
+ $record = null;
+ try {
+ $record = ContactUs::query()->create([
+ 'sender_id' => (int) ($payload['sender_id'] ?? 0),
+ 'reciever_id' => (int) ($payload['receiver_id'] ?? 0),
+ 'subject' => $subject,
+ 'message' => $message,
+ 'semester' => $semester ?: 'Fall',
+ 'school_year' => $schoolYear ?: '2025-2026',
+ ]);
+ } catch (\Throwable $e) {
+ Log::error('Contact submission save failed: ' . $e->getMessage());
+ }
+
+ $recipient = Configuration::getConfig('administrator_email')
+ ?: (string) config('mail.from.address', '')
+ ?: 'alrahma.isgl@gmail.com';
+
+ $htmlMessage = nl2br('You have received a new message from ' . $email . ":\n\n" . $message);
+
+ $emailSent = false;
+ if (!app()->runningUnitTests()) {
+ $emailSent = $this->emailService->send($recipient, $subject, $htmlMessage, null, $email, $email);
+ if (!$emailSent) {
+ Log::error('Contact submission email failed for ' . $email);
+ }
+ }
+
+ return [
+ 'record' => $record,
+ 'email_sent' => $emailSent,
+ 'recipient' => $recipient,
+ ];
+ }
+}
diff --git a/app/Services/Frontend/FrontendPageService.php b/app/Services/Frontend/FrontendPageService.php
new file mode 100644
index 00000000..360c3f3c
--- /dev/null
+++ b/app/Services/Frontend/FrontendPageService.php
@@ -0,0 +1,13 @@
+ $name,
+ ];
+ }
+}
diff --git a/app/Services/Frontend/LandingPageContextService.php b/app/Services/Frontend/LandingPageContextService.php
new file mode 100644
index 00000000..3aa528b9
--- /dev/null
+++ b/app/Services/Frontend/LandingPageContextService.php
@@ -0,0 +1,18 @@
+ (string) (Configuration::getConfig('school_year') ?? ''),
+ 'semester' => (string) (Configuration::getConfig('semester') ?? ''),
+ 'last_day_of_registration' => (string) (Configuration::getConfig('enrollment_deadline') ?? 'Not set'),
+ 'refund_deadline' => (string) (Configuration::getConfig('refund_deadline') ?? 'Not set'),
+ ];
+ }
+}
diff --git a/app/Services/Frontend/LandingPageParentDashboardService.php b/app/Services/Frontend/LandingPageParentDashboardService.php
new file mode 100644
index 00000000..04b4b260
--- /dev/null
+++ b/app/Services/Frontend/LandingPageParentDashboardService.php
@@ -0,0 +1,148 @@
+context->context();
+ $schoolYear = (string) ($context['school_year'] ?? '');
+ $semester = (string) ($context['semester'] ?? '');
+
+ $parentId = $this->resolveParentId($parentUserId, $userType);
+ if (!$parentId) {
+ return [
+ 'ok' => false,
+ 'message' => 'Unable to retrieve student data. Please contact support.',
+ ];
+ }
+
+ $notifications = $this->notificationsForParent($parentId);
+
+ $students = DB::table('students')
+ ->where('parent_id', $parentId)
+ ->get()
+ ->map(function ($row) use ($schoolYear) {
+ $student = (array) $row;
+ $classSection = StudentClass::getClassSectionsByStudentId($student['id'], $schoolYear);
+ $student['class_section'] = $classSection;
+ $student['grade'] = $classSection;
+ return $student;
+ })
+ ->all();
+
+ $attendance = DB::table('attendance_data')
+ ->select('attendance_data.*', 'students.firstname', 'students.lastname')
+ ->join('students', 'students.id', '=', 'attendance_data.student_id')
+ ->where('students.parent_id', $parentId)
+ ->where('attendance_data.school_year', $schoolYear)
+ ->where('attendance_data.semester', $semester)
+ ->orderBy('attendance_data.date', 'DESC')
+ ->get()
+ ->map(fn ($row) => (array) $row)
+ ->all();
+
+ $grades = DB::table('final_score')
+ ->select('final_score.*', 'students.firstname', 'students.lastname')
+ ->join('students', 'students.id', '=', 'final_score.student_id')
+ ->where('students.parent_id', $parentId)
+ ->where('final_score.school_year', $schoolYear)
+ ->where('final_score.semester', $semester)
+ ->orderBy('final_score.created_at', 'DESC')
+ ->get()
+ ->map(fn ($row) => (array) $row)
+ ->all();
+
+ $enrollments = DB::table('enrollments')
+ ->select('enrollments.*', 'classes.class_name')
+ ->join('students', 'students.id', '=', 'enrollments.student_id')
+ ->join('classes', 'classes.id', '=', 'enrollments.class_section_id')
+ ->where('students.parent_id', $parentId)
+ ->where('enrollments.school_year', $schoolYear)
+ ->where('enrollments.semester', $semester)
+ ->orderBy('enrollments.enrollment_date', 'DESC')
+ ->get()
+ ->map(fn ($row) => (array) $row)
+ ->all();
+
+ $paymentBalance = Invoice::getLatestInvoiceTotalAmount($parentId);
+
+ return [
+ 'ok' => true,
+ 'notifications' => $notifications,
+ 'students' => $students,
+ 'attendance' => $attendance,
+ 'grades' => $grades,
+ 'enrollments' => $enrollments,
+ 'last_day_of_registration' => $context['last_day_of_registration'] ?? 'Not set',
+ 'withdrawal_deadline' => $context['refund_deadline'] ?? 'Not set',
+ 'payment_balance' => $paymentBalance,
+ ];
+ }
+
+ private function resolveParentId(int $userId, string $userType): ?int
+ {
+ if ($userType === 'primary') {
+ return $userId;
+ }
+
+ if ($userType === 'secondary') {
+ $row = DB::table('parents')
+ ->select('parent_id')
+ ->where('secondparent_user_id', $userId)
+ ->first();
+ return $row ? (int) $row->parent_id : null;
+ }
+
+ if ($userType === 'tertiary') {
+ $row = DB::table('authorized_users')
+ ->select('user_id as parent_id')
+ ->where('authorized_user_id', $userId)
+ ->first();
+ return $row ? (int) $row->parent_id : null;
+ }
+
+ return null;
+ }
+
+ private function notificationsForParent(int $parentId): array
+ {
+ return DB::table('notifications')
+ ->select([
+ 'notifications.id',
+ 'notifications.title',
+ 'notifications.message',
+ 'notifications.target_group',
+ 'notifications.created_at',
+ 'notifications.expires_at',
+ 'user_notifications.user_id',
+ DB::raw("CASE WHEN user_notifications.user_id IS NOT NULL THEN 'personal' ELSE 'broadcast' END as notification_type"),
+ ])
+ ->leftJoin('user_notifications', function ($join) use ($parentId) {
+ $join->on('user_notifications.notification_id', '=', 'notifications.id')
+ ->where('user_notifications.user_id', '=', $parentId);
+ })
+ ->where(function ($q) use ($parentId) {
+ $q->where('notifications.target_group', 'parent')
+ ->orWhere('user_notifications.user_id', $parentId);
+ })
+ ->whereNull('notifications.deleted_at')
+ ->where(function ($q) {
+ $q->whereNull('notifications.expires_at')
+ ->orWhere('notifications.expires_at', '>', now());
+ })
+ ->orderBy('notifications.created_at', 'DESC')
+ ->get()
+ ->map(fn ($row) => (array) $row)
+ ->all();
+ }
+}
diff --git a/app/Services/Frontend/LandingPageRoleService.php b/app/Services/Frontend/LandingPageRoleService.php
new file mode 100644
index 00000000..54f82436
--- /dev/null
+++ b/app/Services/Frontend/LandingPageRoleService.php
@@ -0,0 +1,31 @@
+join('roles as r', 'r.id', '=', 'ur.role_id')
+ ->where('ur.user_id', $user->id)
+ ->whereNull('ur.deleted_at')
+ ->orderBy('r.priority', 'asc')
+ ->pluck('r.name')
+ ->map(fn ($r) => strtolower((string) $r))
+ ->all();
+
+ if (!empty($roles)) {
+ return $roles[0];
+ }
+
+ return 'guest';
+ }
+}
diff --git a/app/Services/Frontend/LandingPageService.php b/app/Services/Frontend/LandingPageService.php
new file mode 100644
index 00000000..7bdb6376
--- /dev/null
+++ b/app/Services/Frontend/LandingPageService.php
@@ -0,0 +1,90 @@
+roleService->resolveRole($user);
+
+ return match ($role) {
+ 'administrator' => $this->administrator(),
+ 'admin' => $this->admin(),
+ 'teacher', 'teacher_assistant' => $this->teacher($user, $classSectionId),
+ 'student' => $this->student(),
+ 'parent', 'authorized_user' => $this->parent($user),
+ default => $this->guest(),
+ };
+ }
+
+ public function administrator(): array
+ {
+ return [
+ 'role' => 'administrator',
+ 'dashboard' => 'administrator',
+ ];
+ }
+
+ public function admin(): array
+ {
+ return [
+ 'role' => 'admin',
+ 'dashboard' => 'admin',
+ ];
+ }
+
+ public function teacher(?User $user, ?int $classSectionId = null): array
+ {
+ if (!$user) {
+ return ['role' => 'teacher', 'dashboard' => 'teacher', 'summary' => []];
+ }
+
+ return [
+ 'role' => 'teacher',
+ 'dashboard' => 'teacher',
+ 'summary' => $this->teacherSummaryService->summary($user->id, $classSectionId),
+ ];
+ }
+
+ public function student(): array
+ {
+ return [
+ 'role' => 'student',
+ 'dashboard' => 'student',
+ ];
+ }
+
+ public function parent(?User $user): array
+ {
+ if (!$user) {
+ return ['role' => 'parent', 'dashboard' => 'parent', 'summary' => []];
+ }
+
+ $userType = (string) ($user->user_type ?? 'primary');
+
+ return [
+ 'role' => 'parent',
+ 'dashboard' => 'parent',
+ 'summary' => $this->parentDashboardService->summary($user->id, $userType),
+ ];
+ }
+
+ public function guest(): array
+ {
+ return [
+ 'role' => 'guest',
+ 'dashboard' => 'guest',
+ ];
+ }
+}
diff --git a/app/Services/Frontend/LandingPageTeacherSummaryService.php b/app/Services/Frontend/LandingPageTeacherSummaryService.php
new file mode 100644
index 00000000..bd2fe95a
--- /dev/null
+++ b/app/Services/Frontend/LandingPageTeacherSummaryService.php
@@ -0,0 +1,595 @@
+context->context();
+ $schoolYear = (string) ($context['school_year'] ?? '');
+ $semester = (string) ($context['semester'] ?? '');
+
+ $assignments = TeacherClass::getClassAssignmentsByUserId($teacherId, $schoolYear, $semester);
+ $availableIds = array_values(array_filter(array_unique(array_map(
+ static fn (array $row) => (int) ($row['class_section_id'] ?? 0),
+ $assignments
+ ))));
+
+ $activeId = null;
+ if ($requestedClassSectionId && in_array($requestedClassSectionId, $availableIds, true)) {
+ $activeId = $requestedClassSectionId;
+ } elseif (!empty($availableIds)) {
+ $activeId = $availableIds[0];
+ }
+
+ $activeName = null;
+ foreach ($assignments as $row) {
+ if ((int) ($row['class_section_id'] ?? 0) === (int) $activeId) {
+ $activeName = $row['class_section_name'] ?? null;
+ break;
+ }
+ }
+
+ $normalizedSemester = ucfirst(strtolower(trim((string) $semester)));
+ if (!in_array($normalizedSemester, ['Fall', 'Spring'], true)) {
+ $normalizedSemester = 'Fall';
+ }
+
+ $classSectionIds = $availableIds;
+ $dashboardSummary = $this->buildTeacherDashboardSummary($classSectionIds, $normalizedSemester, $schoolYear);
+
+ return [
+ 'class_section_id' => $activeId,
+ 'active_class_name' => $activeName,
+ 'classes' => $assignments,
+ 'jobCount' => count($assignments),
+ 'scoreSummary' => $dashboardSummary['scoreSummary'],
+ 'commentSummary' => $dashboardSummary['commentSummary'],
+ 'attendanceSummary' => $dashboardSummary['attendanceSummary'],
+ 'deadlineEvents' => $dashboardSummary['deadlineEvents'],
+ 'participationSummary' => $dashboardSummary['participationSummary'],
+ 'attendanceStatus' => $dashboardSummary['attendanceStatus'],
+ 'dashboardNotifications' => $dashboardSummary['notifications'],
+ 'school_year' => $schoolYear,
+ 'semester' => $semester,
+ ];
+ }
+
+ private function buildTeacherDashboardSummary(array $classSectionIds, string $semester, string $schoolYear): array
+ {
+ $scoreField = $semester === 'Spring' ? 'final_exam_score' : 'midterm_exam_score';
+ $scoreLabel = $semester === 'Spring' ? 'Final Exam' : 'Midterm';
+
+ $summary = [
+ 'scoreSummary' => [
+ 'completionPct' => 0,
+ 'missing' => 0,
+ 'expected' => 0,
+ 'filled' => 0,
+ 'fieldLabel' => $scoreLabel,
+ ],
+ 'commentSummary' => [
+ 'total' => 0,
+ 'pendingReview' => 0,
+ 'reviewed' => 0,
+ 'byType' => [],
+ ],
+ 'attendanceSummary' => [
+ 'recorded' => 0,
+ 'students' => 0,
+ 'completionPct' => 0,
+ 'avgAbsences' => 0,
+ ],
+ 'participationSummary' => [
+ 'filled' => 0,
+ 'missing' => 0,
+ 'completionPct' => 0,
+ 'expected' => 0,
+ ],
+ 'deadlineEvents' => [],
+ 'notifications' => [],
+ 'attendanceStatus' => [
+ 'date' => '',
+ 'missingSections' => [],
+ 'missingNames' => [],
+ 'submitted' => true,
+ ],
+ ];
+
+ $uniqueStudentIds = [];
+ if (!empty($classSectionIds)) {
+ $studentRows = DB::table('student_class as sc')
+ ->select('sc.student_id', 'sc.class_section_id')
+ ->join('students as s', 's.id', '=', 'sc.student_id')
+ ->whereIn('sc.class_section_id', $classSectionIds)
+ ->where('s.is_active', 1)
+ ->where('sc.school_year', $schoolYear)
+ ->get();
+
+ foreach ($studentRows as $row) {
+ $studentId = (int) ($row->student_id ?? 0);
+ if ($studentId > 0) {
+ $uniqueStudentIds[] = $studentId;
+ }
+ }
+ }
+
+ $uniqueStudentIds = array_values(array_unique($uniqueStudentIds));
+ $totalStudents = count($uniqueStudentIds);
+ $summary['attendanceSummary']['students'] = $totalStudents;
+
+ $scoreRows = [];
+ if (!empty($classSectionIds)) {
+ $scoreRows = SemesterScore::query()
+ ->select(['student_id', 'class_section_id', 'midterm_exam_score', 'final_exam_score', 'participation_score'])
+ ->whereIn('class_section_id', $classSectionIds)
+ ->where('semester', $semester)
+ ->where('school_year', $schoolYear)
+ ->get()
+ ->toArray();
+ }
+
+ $scoreTypesConfig = [
+ 'participation' => ['label' => 'Participation', 'field' => 'participation_score'],
+ ];
+ if ($semester === 'Fall') {
+ $scoreTypesConfig = array_merge([
+ 'midterm' => ['label' => 'Midterm', 'field' => 'midterm_exam_score'],
+ ], $scoreTypesConfig);
+ }
+ if ($semester === 'Spring') {
+ $scoreTypesConfig = array_merge([
+ 'final' => ['label' => 'Final exam', 'field' => 'final_exam_score'],
+ ], $scoreTypesConfig);
+ }
+
+ $filledCounts = array_fill_keys(array_keys($scoreTypesConfig), 0);
+ foreach ($scoreRows as $row) {
+ foreach ($scoreTypesConfig as $key => $cfg) {
+ $value = trim((string) ($row[$cfg['field']] ?? ''));
+ if ($value !== '') {
+ $filledCounts[$key]++;
+ }
+ }
+ }
+
+ $expectedScoreCount = $totalStudents > 0 ? $totalStudents : count($scoreRows);
+ $scoreDetails = [];
+ foreach ($scoreTypesConfig as $key => $cfg) {
+ $filled = $filledCounts[$key] ?? 0;
+ $missing = max(0, $expectedScoreCount - $filled);
+ $completionPct = $expectedScoreCount > 0 ? (int) round(min(100, ($filled / $expectedScoreCount) * 100)) : 0;
+ $scoreDetails[$key] = [
+ 'label' => $cfg['label'],
+ 'field' => $cfg['field'],
+ 'filled' => $filled,
+ 'missing' => $missing,
+ 'expected' => $expectedScoreCount,
+ 'completionPct' => $completionPct,
+ ];
+ }
+
+ $scoreTypeByField = [];
+ if ($semester === 'Fall') {
+ $scoreTypeByField['midterm_exam_score'] = 'midterm';
+ }
+ if ($semester === 'Spring') {
+ $scoreTypeByField['final_exam_score'] = 'final';
+ }
+ $activeScoreType = $scoreTypeByField[$scoreField] ?? array_key_first($scoreDetails);
+ $activeScoreDetails = $scoreDetails[$activeScoreType] ?? array_values($scoreDetails)[0];
+
+ $summary['scoreSummary'] = [
+ 'completionPct' => $activeScoreDetails['completionPct'],
+ 'missing' => $activeScoreDetails['missing'],
+ 'expected' => $activeScoreDetails['expected'],
+ 'filled' => $activeScoreDetails['filled'],
+ 'fieldLabel' => $scoreLabel,
+ 'types' => $scoreDetails,
+ ];
+
+ $participationMetrics = $scoreDetails['participation'];
+ $summary['participationSummary'] = [
+ 'filled' => $participationMetrics['filled'],
+ 'missing' => $participationMetrics['missing'],
+ 'completionPct' => $participationMetrics['completionPct'],
+ 'expected' => $participationMetrics['expected'],
+ ];
+
+ $commentRows = [];
+ if (!empty($uniqueStudentIds)) {
+ $commentRows = ScoreComment::query()
+ ->select(['student_id', 'score_type', 'comment', 'comment_review'])
+ ->whereIn('student_id', $uniqueStudentIds)
+ ->where('semester', $semester)
+ ->where('school_year', $schoolYear)
+ ->get()
+ ->toArray();
+ }
+
+ $expectedCommentTypes = array_values(array_filter([
+ 'ptap',
+ $semester === 'Fall' ? 'midterm' : null,
+ $semester === 'Spring' ? 'final' : null,
+ ]));
+
+ $commentStats = [];
+ $commentsByStudent = [];
+ foreach ($commentRows as $row) {
+ $sid = (int) ($row['student_id'] ?? 0);
+ $type = strtolower(trim((string) ($row['score_type'] ?? '')));
+ if ($type === '') {
+ $type = 'general';
+ }
+ if (!isset($commentStats[$type])) {
+ $commentStats[$type] = ['pending' => 0, 'reviewed' => 0];
+ }
+
+ $commentText = trim((string) ($row['comment'] ?? ''));
+ $reviewValue = trim((string) ($row['comment_review'] ?? ''));
+
+ if ($sid > 0 && $commentText !== '') {
+ $commentsByStudent[$sid][$type] = $commentText;
+ }
+
+ if ($commentText === '') {
+ continue;
+ }
+
+ if ($reviewValue === '') {
+ $commentStats[$type]['pending']++;
+ } else {
+ $commentStats[$type]['reviewed']++;
+ }
+ }
+
+ foreach ($expectedCommentTypes as $type) {
+ if (!isset($commentStats[$type])) {
+ $commentStats[$type] = ['pending' => 0, 'reviewed' => 0];
+ }
+ }
+
+ $missingComments = 0;
+ $missingDetail = [];
+ foreach ($uniqueStudentIds as $sid) {
+ foreach ($expectedCommentTypes as $type) {
+ $text = $commentsByStudent[$sid][$type] ?? '';
+ if ($text === '') {
+ $missingDetail[$type] = ($missingDetail[$type] ?? 0) + 1;
+ $missingComments++;
+ }
+ }
+ }
+
+ $commentTypes = [];
+ foreach ($expectedCommentTypes as $type) {
+ $stats = $commentStats[$type];
+ $filled = $stats['pending'] + $stats['reviewed'];
+ $completionPct = $totalStudents > 0 ? (int) round(min(100, ($filled / $totalStudents) * 100)) : 0;
+
+ $commentTypes[$type] = [
+ 'label' => ucfirst($type) . ' comments',
+ 'pending' => $stats['pending'],
+ 'reviewed' => $stats['reviewed'],
+ 'filled' => $filled,
+ 'missing' => $missingDetail[$type] ?? 0,
+ 'expected' => $totalStudents,
+ 'completionPct' => $completionPct,
+ ];
+ }
+
+ $totalPending = array_sum(array_column($commentTypes, 'pending'));
+ $totalReviewed = array_sum(array_column($commentTypes, 'reviewed'));
+ $totalFilled = array_sum(array_column($commentTypes, 'filled'));
+ $totalExpected = array_sum(array_column($commentTypes, 'expected'));
+ $commentCompletionPct = $totalExpected > 0 ? (int) round(min(100, ($totalFilled / $totalExpected) * 100)) : 0;
+
+ $summary['commentSummary'] = [
+ 'total' => $totalFilled,
+ 'pendingReview' => $totalPending,
+ 'reviewed' => $totalReviewed,
+ 'missing' => $missingComments,
+ 'missingDetail' => $missingDetail,
+ 'completionPct' => $commentCompletionPct,
+ 'types' => $commentTypes,
+ ];
+
+ $attendanceRows = [];
+ if (!empty($classSectionIds)) {
+ $attendanceRows = AttendanceRecord::query()
+ ->select(['student_id', 'class_section_id', 'total_absence'])
+ ->whereIn('class_section_id', $classSectionIds)
+ ->where('semester', $semester)
+ ->where('school_year', $schoolYear)
+ ->get()
+ ->toArray();
+ }
+
+ $attendanceSet = [];
+ $totalAbsences = 0;
+ foreach ($attendanceRows as $record) {
+ $studentId = (int) ($record['student_id'] ?? 0);
+ $sectionId = (int) ($record['class_section_id'] ?? 0);
+ if ($studentId <= 0 || $sectionId <= 0) {
+ continue;
+ }
+ $key = "{$sectionId}:{$studentId}";
+ $attendanceSet[$key] = true;
+ $totalAbsences += (int) ($record['total_absence'] ?? 0);
+ }
+
+ $attendanceRecorded = count($attendanceSet);
+ $attendancePct = $totalStudents > 0 ? (int) round(min(100, ($attendanceRecorded / $totalStudents) * 100)) : 0;
+ $avgAbsence = $attendanceRecorded > 0 ? round($totalAbsences / $attendanceRecorded, 1) : 0;
+
+ $summary['attendanceSummary'] = [
+ 'recorded' => $attendanceRecorded,
+ 'students' => $totalStudents,
+ 'completionPct' => $attendancePct,
+ 'avgAbsences' => $avgAbsence,
+ ];
+
+ $timezone = new DateTimeZone(date_default_timezone_get() ?: 'UTC');
+ $today = (new DateTimeImmutable('now', $timezone))->format('Y-m-d');
+
+ $attendanceStatus = $this->buildAttendanceSubmissionStatus($classSectionIds, $semester, $schoolYear, $today);
+ $summary['attendanceStatus'] = $attendanceStatus;
+
+ $summary['deadlineEvents'] = Calendar::query()
+ ->where('notify_teacher', 1)
+ ->where('school_year', $schoolYear)
+ ->where('semester', $semester)
+ ->where('date', '>=', $today)
+ ->orderBy('date', 'ASC')
+ ->limit(5)
+ ->get()
+ ->toArray();
+
+ if ($attendanceStatus['submitted'] === false && !empty($classSectionIds)) {
+ $summary['attendanceSummary']['completionPct'] = 0;
+ $summary['attendanceSummary']['recorded'] = 0;
+ }
+
+ $summary['notifications'] = $this->buildDashboardNotifications($summary);
+
+ return $summary;
+ }
+
+ private function buildDashboardNotifications(array $summary): array
+ {
+ $notifications = [];
+ $buildScoreLink = static function (?string $focus): string {
+ $base = '/teacher/scores';
+ return $focus ? $base . '?focus=' . urlencode($focus) : $base;
+ };
+ $determineFocus = static function (array $event): string {
+ $eventTypeText = strtolower(trim((string) ($event['event_type'] ?? '')));
+ if ($eventTypeText !== '' && str_contains($eventTypeText, 'draft')) {
+ return 'comments';
+ }
+ $text = strtolower(trim(($event['title'] ?? '') . ' ' . ($event['description'] ?? '')));
+ if ($text === '') {
+ return 'scores';
+ }
+ if (str_contains($text, 'comment') || str_contains($text, 'ptap')) {
+ return 'comments';
+ }
+ return 'scores';
+ };
+ $calendarLink = '/teacher/calendar';
+ $attendanceLink = '/teacher/showupdate_attendance';
+ $timezone = new DateTimeZone(date_default_timezone_get() ?: 'UTC');
+ $todayDate = (new DateTimeImmutable('now', $timezone))->setTime(0, 0);
+
+ $calendarNotificationConfig = [
+ '1st Semester Scores' => 14,
+ '2nd Semester Scores' => 14,
+ 'Final' => 14,
+ 'Final Exam Draft' => 42,
+ 'Midterm' => 14,
+ 'Midterm Exam Draft' => 42,
+ ];
+
+ $activeDeadline = false;
+ foreach ($summary['deadlineEvents'] ?? [] as $event) {
+ $eventTypeRaw = trim((string) ($event['event_type'] ?? ''));
+ if ($eventTypeRaw === '') {
+ continue;
+ }
+ $matchedType = null;
+ $thresholdDays = null;
+ foreach ($calendarNotificationConfig as $type => $days) {
+ if (strcasecmp($eventTypeRaw, $type) === 0) {
+ $matchedType = $type;
+ $thresholdDays = $days;
+ break;
+ }
+ }
+ if ($matchedType === null || $thresholdDays === null) {
+ continue;
+ }
+ $rawDate = $event['date'] ?? '';
+ if ($rawDate === '') {
+ continue;
+ }
+ try {
+ $eventDateTime = new DateTimeImmutable($rawDate, $timezone);
+ } catch (\Exception $e) {
+ continue;
+ }
+ $eventDateTime = $eventDateTime->setTime(0, 0);
+ $daysUntil = (int) $todayDate->diff($eventDateTime)->format('%r%a');
+
+ if ($daysUntil <= 0) {
+ $activeDeadline = true;
+ $eventFocus = $determineFocus($event);
+ $notifications[] = [
+ 'type' => 'danger',
+ 'message' => sprintf(
+ "Deadline today: %s is due. Submit the remaining entries before the day ends.",
+ $matchedType
+ ),
+ 'link' => $buildScoreLink($eventFocus),
+ 'linkText' => 'Submit now',
+ ];
+ continue;
+ }
+
+ if ($daysUntil > $thresholdDays) {
+ continue;
+ }
+
+ $relativeText = $daysUntil === 1 ? ' (tomorrow)' : " (in {$daysUntil} days)";
+ $notifications[] = [
+ 'type' => 'warning',
+ 'message' => sprintf(
+ "%s is due on %s%s. Review it on the calendar so you don’t miss it.",
+ $matchedType,
+ $eventDateTime->format('M j, Y'),
+ $relativeText
+ ),
+ 'link' => $calendarLink,
+ 'linkText' => 'View deadline',
+ ];
+ }
+
+ if (!$activeDeadline) {
+ $scoreLabel = $summary['scoreSummary']['fieldLabel'] ?? 'Score';
+ if (!empty($summary['scoreSummary']['missing'] ?? 0)) {
+ $notifications[] = [
+ 'type' => 'warning',
+ 'message' => sprintf(
+ "You have %s %s entries missing on the scores page. Please finish submitting them.",
+ (int) $summary['scoreSummary']['missing'],
+ $scoreLabel
+ ),
+ 'link' => $buildScoreLink('scores'),
+ 'linkText' => 'Score sheet',
+ ];
+ }
+
+ foreach (['midterm', 'final', 'ptap'] as $type) {
+ $count = (int) ($summary['commentSummary']['missingDetail'][$type] ?? 0);
+ if ($count <= 0) {
+ continue;
+ }
+ $notifications[] = [
+ 'type' => 'warning',
+ 'message' => sprintf(
+ "%s comment missing for %s student(s). Leave comments on the scores page.",
+ ucfirst($type),
+ $count
+ ),
+ 'link' => $buildScoreLink('comments'),
+ 'linkText' => ucfirst($type) . ' comments',
+ ];
+ }
+
+ if (!empty($summary['participationSummary']['missing'] ?? 0)) {
+ $notifications[] = [
+ 'type' => 'warning',
+ 'message' => sprintf(
+ "Participation entries are still missing for %s student(s). Please add them from the scores page.",
+ (int) $summary['participationSummary']['missing']
+ ),
+ 'link' => $buildScoreLink('scores'),
+ 'linkText' => 'Participation',
+ ];
+ }
+ }
+
+ if (!empty($summary['attendanceStatus']['missingSections'] ?? [])) {
+ $missingNames = $summary['attendanceStatus']['missingNames'] ?? [];
+ $label = '';
+ if (!empty($missingNames)) {
+ $label = ' (' . implode(', ', array_slice($missingNames, 0, 3)) . (count($missingNames) > 3 ? '…' : '') . ')';
+ }
+ $notifications[] = [
+ 'type' => 'danger',
+ 'message' => "Today's attendance is still unsubmitted for {$summary['attendanceStatus']['date']}{$label}. Please submit it now.",
+ 'link' => $attendanceLink,
+ 'linkText' => 'Record attendance',
+ ];
+ }
+
+ return $notifications;
+ }
+
+ private function buildAttendanceSubmissionStatus(array $classSectionIds, string $semester, string $schoolYear, string $date): array
+ {
+ if (empty($classSectionIds)) {
+ return [
+ 'date' => $date,
+ 'missingSections' => [],
+ 'missingNames' => [],
+ 'submitted' => true,
+ ];
+ }
+
+ $rows = AttendanceDay::query()
+ ->select(['class_section_id', 'status'])
+ ->whereIn('class_section_id', $classSectionIds)
+ ->where('date', $date)
+ ->where('semester', $semester)
+ ->where('school_year', $schoolYear)
+ ->get()
+ ->toArray();
+
+ $statusMap = [];
+ foreach ($rows as $row) {
+ $csId = (int) ($row['class_section_id'] ?? 0);
+ if ($csId <= 0) {
+ continue;
+ }
+ $statusMap[$csId] = strtolower(trim((string) ($row['status'] ?? '')));
+ }
+
+ $missingSections = [];
+ foreach ($classSectionIds as $classSectionId) {
+ $status = $statusMap[$classSectionId] ?? '';
+ if (!in_array($status, ['submitted', 'published', 'finalized'], true)) {
+ $missingSections[] = $classSectionId;
+ }
+ }
+
+ $missingNames = [];
+ if (!empty($missingSections)) {
+ $sectionRows = ClassSection::query()
+ ->select(['class_section_id', 'class_section_name'])
+ ->whereIn('class_section_id', $missingSections)
+ ->get()
+ ->toArray();
+ foreach ($sectionRows as $row) {
+ $name = trim((string) ($row['class_section_name'] ?? ''));
+ if ($name === '') {
+ $name = (string) ($row['class_section_id'] ?? '');
+ }
+ $missingNames[] = $name;
+ }
+ }
+
+ return [
+ 'date' => $date,
+ 'missingSections' => $missingSections,
+ 'missingNames' => $missingNames,
+ 'submitted' => empty($missingSections),
+ ];
+ }
+}
diff --git a/app/Services/Frontend/ProfileIconService.php b/app/Services/Frontend/ProfileIconService.php
new file mode 100644
index 00000000..97be1848
--- /dev/null
+++ b/app/Services/Frontend/ProfileIconService.php
@@ -0,0 +1,35 @@
+find($userId);
+ if (!$user) {
+ return [
+ 'userName' => 'User not found',
+ 'userInitials' => '??',
+ ];
+ }
+
+ $firstname = (string) ($user->firstname ?? '');
+ $lastname = (string) ($user->lastname ?? '');
+
+ $initials = '';
+ if ($firstname !== '') {
+ $initials .= strtoupper($firstname[0]);
+ }
+ if ($lastname !== '') {
+ $initials .= strtoupper($lastname[0]);
+ }
+
+ return [
+ 'userName' => trim($firstname . ' ' . $lastname),
+ 'userInitials' => $initials !== '' ? $initials : '??',
+ ];
+ }
+}
diff --git a/app/Services/Frontend/StaticPageService.php b/app/Services/Frontend/StaticPageService.php
new file mode 100644
index 00000000..eea1eca6
--- /dev/null
+++ b/app/Services/Frontend/StaticPageService.php
@@ -0,0 +1,33 @@
+ false,
+ 'error' => 'Page content not available.',
+ ];
+ }
+
+ $content = file_get_contents($path);
+ if ($content === false) {
+ return [
+ 'ok' => false,
+ 'error' => 'Page content not available.',
+ ];
+ }
+
+ return [
+ 'ok' => true,
+ 'content' => $content,
+ 'type' => $type,
+ ];
+ }
+}
diff --git a/app/Services/Messaging/MessageCommandService.php b/app/Services/Messaging/MessageCommandService.php
new file mode 100644
index 00000000..d4a8b54a
--- /dev/null
+++ b/app/Services/Messaging/MessageCommandService.php
@@ -0,0 +1,126 @@
+recipientIdFromRole($payload['recipient_role']);
+ }
+
+ if (!$recipientId) {
+ throw new RuntimeException('Recipient is required.');
+ }
+
+ if ((int) $recipientId === $senderId) {
+ throw new RuntimeException('Cannot send a message to yourself.');
+ }
+
+ return DB::transaction(function () use ($senderId, $payload, $recipientId, $attachment) {
+ $path = null;
+ if ($attachment) {
+ $path = $attachment->store('messages', ['disk' => 'public']);
+ }
+
+ $message = Message::query()->create([
+ 'sender_id' => $senderId,
+ 'recipient_id' => (int) $recipientId,
+ 'subject' => (string) $payload['subject'],
+ 'message' => (string) $payload['message'],
+ 'sent_datetime' => now(),
+ 'read_status' => 0,
+ 'message_number' => 'MSG-' . now()->format('YmdHis'),
+ 'priority' => $payload['priority'] ?? 'normal',
+ 'attachment' => $path,
+ 'status' => $payload['status'] ?? 'sent',
+ 'semester' => $payload['semester'] ?? $this->configService->getSemester() ?? '',
+ 'school_year' => $payload['school_year'] ?? $this->configService->getSchoolYear() ?? '',
+ ]);
+
+ if (!$message) {
+ throw new RuntimeException('Failed to send message.');
+ }
+
+ return $message;
+ });
+ }
+
+ public function update(Message $message, array $payload): Message
+ {
+ return DB::transaction(function () use ($message, $payload) {
+ $updates = [];
+ if (array_key_exists('status', $payload)) {
+ $updates['status'] = $payload['status'];
+ }
+ if (array_key_exists('priority', $payload)) {
+ $updates['priority'] = $payload['priority'];
+ }
+ if (array_key_exists('read_status', $payload)) {
+ $read = (int) $payload['read_status'];
+ $updates['read_status'] = $read;
+ if ($read === 1) {
+ $updates['read_datetime'] = now();
+ }
+ }
+
+ if (!empty($updates)) {
+ $message->fill($updates);
+ $message->save();
+ }
+
+ return $message->fresh();
+ });
+ }
+
+ public function trash(Message $message): bool
+ {
+ return DB::transaction(function () use ($message) {
+ $message->status = 'trashed';
+ return (bool) $message->save();
+ });
+ }
+
+ public function markRead(Message $message): bool
+ {
+ if ($message->read_status) {
+ return true;
+ }
+
+ $message->read_status = 1;
+ $message->read_datetime = now();
+
+ return (bool) $message->save();
+ }
+
+ private function recipientIdFromRole(string $roleName): int
+ {
+ $map = [
+ 'teacher' => 1,
+ 'parent' => 2,
+ 'admin' => 3,
+ 'student' => 4,
+ 'guest' => 5,
+ 'administrator' => 6,
+ ];
+
+ $key = strtolower(trim($roleName));
+ if (!isset($map[$key])) {
+ throw new RuntimeException('Invalid recipient role.');
+ }
+
+ return $map[$key];
+ }
+}
diff --git a/app/Services/Messaging/MessageQueryService.php b/app/Services/Messaging/MessageQueryService.php
new file mode 100644
index 00000000..ed90bfb0
--- /dev/null
+++ b/app/Services/Messaging/MessageQueryService.php
@@ -0,0 +1,115 @@
+with('roles')->find($userId);
+ if (!$user) {
+ return null;
+ }
+
+ $role = $user->roles->sortBy('id')->first();
+ return $role ? (string) $role->name : null;
+ }
+
+ public function inbox(int $userId, array $filters, int $page, int $perPage): LengthAwarePaginator
+ {
+ return $this->baseQuery($filters)
+ ->where('recipient_id', $userId)
+ ->whereIn('status', ['sent', 'received'])
+ ->paginate($perPage, ['*'], 'page', $page);
+ }
+
+ public function sent(int $userId, array $filters, int $page, int $perPage): LengthAwarePaginator
+ {
+ return $this->baseQuery($filters)
+ ->where('sender_id', $userId)
+ ->where('status', 'sent')
+ ->paginate($perPage, ['*'], 'page', $page);
+ }
+
+ public function drafts(int $userId, array $filters, int $page, int $perPage): LengthAwarePaginator
+ {
+ return $this->baseQuery($filters)
+ ->where('sender_id', $userId)
+ ->where('status', 'draft')
+ ->paginate($perPage, ['*'], 'page', $page);
+ }
+
+ public function trash(int $userId, array $filters, int $page, int $perPage): LengthAwarePaginator
+ {
+ return $this->baseQuery($filters)
+ ->where('status', 'trashed')
+ ->where(function ($q) use ($userId) {
+ $q->where('sender_id', $userId)
+ ->orWhere('recipient_id', $userId);
+ })
+ ->paginate($perPage, ['*'], 'page', $page);
+ }
+
+ public function receivedAndMarkRead(int $userId): array
+ {
+ $messages = Message::query()
+ ->with(['sender', 'recipient'])
+ ->where('recipient_id', $userId)
+ ->orderByDesc('sent_datetime')
+ ->get();
+
+ $ids = $messages
+ ->where('read_status', false)
+ ->pluck('id')
+ ->all();
+
+ if (!empty($ids)) {
+ Message::query()
+ ->whereIn('id', $ids)
+ ->update([
+ 'read_status' => 1,
+ 'read_datetime' => now(),
+ ]);
+ }
+
+ return $messages->all();
+ }
+
+ public function findForUser(int $messageId): ?Message
+ {
+ return Message::query()
+ ->with(['sender', 'recipient'])
+ ->find($messageId);
+ }
+
+ private function baseQuery(array $filters)
+ {
+ $query = Message::query()->with(['sender', 'recipient']);
+
+ if (!empty($filters['priority'])) {
+ $query->where('priority', $filters['priority']);
+ }
+
+ if (isset($filters['read_status'])) {
+ $query->where('read_status', (int) $filters['read_status']);
+ }
+
+ if (!empty($filters['search'])) {
+ $term = '%' . $filters['search'] . '%';
+ $query->where(function ($q) use ($term) {
+ $q->where('subject', 'like', $term)
+ ->orWhere('message', 'like', $term);
+ });
+ }
+
+ $sortBy = $filters['sort_by'] ?? 'sent_datetime';
+ $sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
+ $query->orderBy($sortBy, $sortDir);
+
+ return $query;
+ }
+}
diff --git a/app/Services/Messaging/MessageRecipientService.php b/app/Services/Messaging/MessageRecipientService.php
new file mode 100644
index 00000000..f75c6cdf
--- /dev/null
+++ b/app/Services/Messaging/MessageRecipientService.php
@@ -0,0 +1,90 @@
+select('teacher_class.teacher_id', 'users.firstname', 'users.lastname')
+ ->join('users', 'users.id', '=', 'teacher_class.teacher_id')
+ ->orderBy('users.firstname')
+ ->orderBy('users.lastname')
+ ->get();
+
+ return $rows
+ ->map(fn ($row) => [
+ 'id' => (int) $row->teacher_id,
+ 'name' => trim($row->firstname . ' ' . $row->lastname),
+ ])
+ ->unique('id')
+ ->values()
+ ->all();
+ }
+
+ public function parents(): array
+ {
+ $sectionIds = TeacherClass::query()->pluck('class_section_id')->filter()->unique()->values()->all();
+ if (empty($sectionIds)) {
+ return [];
+ }
+
+ $studentIds = StudentClass::query()
+ ->active()
+ ->whereIn('student_class.class_section_id', $sectionIds)
+ ->pluck('student_class.student_id')
+ ->filter()
+ ->unique()
+ ->values()
+ ->all();
+
+ if (empty($studentIds)) {
+ return [];
+ }
+
+ $parentIds = Student::query()
+ ->whereIn('id', $studentIds)
+ ->where('is_active', 1)
+ ->pluck('parent_id')
+ ->filter()
+ ->unique()
+ ->values()
+ ->all();
+
+ if (empty($parentIds)) {
+ return [];
+ }
+
+ $secondParentIds = ParentModel::query()
+ ->whereIn('firstparent_id', $parentIds)
+ ->pluck('secondparent_id')
+ ->filter()
+ ->unique()
+ ->values()
+ ->all();
+
+ $allIds = collect($parentIds)->merge($secondParentIds)->unique()->values()->all();
+
+ $users = User::query()
+ ->whereIn('id', $allIds)
+ ->orderBy('firstname')
+ ->orderBy('lastname')
+ ->get();
+
+ return $users
+ ->map(fn ($row) => [
+ 'id' => (int) $row->id,
+ 'name' => trim($row->firstname . ' ' . $row->lastname),
+ ])
+ ->unique('id')
+ ->values()
+ ->all();
+ }
+}
diff --git a/app/Services/Navigation/NavBuilderService.php b/app/Services/Navigation/NavBuilderService.php
new file mode 100644
index 00000000..24dda604
--- /dev/null
+++ b/app/Services/Navigation/NavBuilderService.php
@@ -0,0 +1,216 @@
+orderBy('menu_parent_id', 'asc')
+ ->orderBy('label', 'asc')
+ ->orderBy('id', 'asc')
+ ->get()
+ ->toArray();
+
+ $byId = [];
+ foreach ($all as $row) {
+ $row['children'] = [];
+ $byId[$row['id']] = $row;
+ }
+
+ $tree = [];
+ foreach ($byId as $id => &$node) {
+ $pid = $node['menu_parent_id'] ?: null;
+ if ($pid && isset($byId[$pid])) {
+ $byId[$pid]['children'][] = &$node;
+ } else {
+ $tree[] = &$node;
+ }
+ }
+ unset($node);
+
+ $this->sortTreeAlpha($tree);
+
+ $flatAlpha = $all;
+ usort($flatAlpha, fn ($a, $b) => strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')));
+
+ $roleAssignments = $this->buildRoleAssignments();
+ $flattened = $this->flattenTreeForResponse($tree, $roleAssignments);
+
+ $parentOptions = array_map(static function ($row) {
+ return [
+ 'id' => (int) ($row['id'] ?? 0),
+ 'label' => (string) ($row['label'] ?? ''),
+ ];
+ }, $flatAlpha);
+
+ $roles = Role::query()->orderBy('name')->get(['id', 'name'])->toArray();
+
+ return [
+ 'items' => $flattened,
+ 'roles' => $roles,
+ 'parentOptions' => $parentOptions,
+ ];
+ }
+
+ public function save(array $payload): array
+ {
+ $id = (int) ($payload['id'] ?? 0);
+ $menuParentId = $payload['menu_parent_id'] ?? $payload['parent_id'] ?? null;
+ $menuParentId = ($menuParentId === '' || $menuParentId === null) ? null : (int) $menuParentId;
+ if ($id && $menuParentId === $id) {
+ $menuParentId = null;
+ }
+
+ if ($menuParentId !== null && !NavItem::query()->whereKey($menuParentId)->exists()) {
+ return ['ok' => false, 'message' => 'Selected parent does not exist.'];
+ }
+
+ $data = [
+ 'menu_parent_id' => $menuParentId,
+ 'label' => trim((string) ($payload['label'] ?? '')),
+ 'url' => trim((string) ($payload['url'] ?? '')) ?: null,
+ 'icon_class' => trim((string) ($payload['icon_class'] ?? '')) ?: null,
+ 'target' => trim((string) ($payload['target'] ?? '')) ?: null,
+ 'sort_order' => (int) ($payload['sort_order'] ?? 0),
+ 'is_enabled' => !empty($payload['is_enabled']) ? 1 : 0,
+ ];
+
+ $roleIds = array_values(array_unique(array_filter(
+ array_map('intval', (array) ($payload['roles'] ?? [])),
+ fn ($v) => $v > 0
+ )));
+
+ $navItemId = null;
+
+ DB::transaction(function () use ($id, $data, $roleIds, &$navItemId): void {
+ if ($id > 0) {
+ NavItem::query()->whereKey($id)->update($data);
+ $navItemId = $id;
+ } else {
+ $navItemId = (int) NavItem::query()->create($data)->id;
+ }
+
+ RoleNavItem::query()->where('nav_item_id', $navItemId)->delete();
+ foreach ($roleIds as $rid) {
+ RoleNavItem::query()->create([
+ 'role_id' => $rid,
+ 'nav_item_id' => $navItemId,
+ ]);
+ }
+ });
+
+ $this->navbarService->clearCache();
+
+ return ['ok' => true, 'id' => $navItemId];
+ }
+
+ public function delete(int $id): bool
+ {
+ $deleted = (bool) NavItem::query()->whereKey($id)->delete();
+ $this->navbarService->clearCache();
+
+ return $deleted;
+ }
+
+ public function reorder(array $orders): void
+ {
+ DB::transaction(function () use ($orders): void {
+ foreach ($orders as $id => $order) {
+ NavItem::query()->whereKey((int) $id)->update(['sort_order' => (int) $order]);
+ }
+ });
+
+ $this->navbarService->clearCache();
+ }
+
+ private function buildRoleAssignments(): array
+ {
+ $rows = RoleNavItem::query()
+ ->select('role_nav_items.nav_item_id', 'roles.id as role_id', 'roles.name as role_name')
+ ->join('roles', 'roles.id', '=', 'role_nav_items.role_id')
+ ->get()
+ ->toArray();
+
+ $roleAssignments = [];
+ foreach ($rows as $row) {
+ $navId = (int) ($row['nav_item_id'] ?? 0);
+ if ($navId <= 0) {
+ continue;
+ }
+
+ $roleId = (int) ($row['role_id'] ?? 0);
+ $roleName = $row['role_name'] ?? ($roleId ? ('#' . $roleId) : null);
+
+ if ($roleId > 0) {
+ $roleAssignments[$navId]['ids'][] = $roleId;
+ }
+ if ($roleName !== null) {
+ $roleAssignments[$navId]['names'][] = $roleName;
+ }
+ }
+
+ return $roleAssignments;
+ }
+
+ private function flattenTreeForResponse(array $tree, array $roleAssignments, int $depth = 0): array
+ {
+ $output = [];
+ foreach ($tree as $node) {
+ $id = (int) ($node['id'] ?? 0);
+ $roles = $roleAssignments[$id] ?? ['ids' => [], 'names' => []];
+
+ $output[] = [
+ 'id' => $id,
+ 'label' => (string) ($node['label'] ?? ''),
+ 'url' => $node['url'] ?? null,
+ 'icon_class' => $node['icon_class'] ?? null,
+ 'target' => $node['target'] ?? null,
+ 'menu_parent_id' => $node['menu_parent_id'] ?? null,
+ 'sort_order' => (int) ($node['sort_order'] ?? 0),
+ 'is_enabled' => (int) ($node['is_enabled'] ?? 0),
+ 'depth' => $depth,
+ 'roles' => [
+ 'ids' => array_values(array_unique($roles['ids'] ?? [])),
+ 'names' => array_values(array_unique($roles['names'] ?? [])),
+ ],
+ ];
+
+ if (!empty($node['children'])) {
+ $output = array_merge($output, $this->flattenTreeForResponse($node['children'], $roleAssignments, $depth + 1));
+ }
+ }
+
+ return $output;
+ }
+
+ private function labelKey(string $s): string
+ {
+ $s = trim(preg_replace('/\s+/', ' ', $s));
+ $s = preg_replace('/^[^[:alnum:]]+/u', '', $s);
+ $s = preg_replace('/^(?i)(the|an|a)\s+/', '', $s);
+ return mb_strtolower($s, 'UTF-8');
+ }
+
+ private function sortTreeAlpha(array &$nodes): void
+ {
+ usort($nodes, fn ($a, $b) => strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')));
+ foreach ($nodes as &$node) {
+ if (!empty($node['children'])) {
+ $this->sortTreeAlpha($node['children']);
+ }
+ }
+ unset($node);
+ }
+}
diff --git a/app/Services/Navigation/NavbarService.php b/app/Services/Navigation/NavbarService.php
new file mode 100644
index 00000000..6ee84f6c
--- /dev/null
+++ b/app/Services/Navigation/NavbarService.php
@@ -0,0 +1,55 @@
+ strtolower(trim((string) $r)), $roles)));
+ $cacheKey = 'navbar_' . md5(json_encode($roles));
+
+ return Cache::remember($cacheKey, 300, function () use ($roles) {
+ $allowedIds = RoleNavItem::getNavItemIdsForRoles($roles);
+ if (empty($allowedIds)) {
+ return [];
+ }
+
+ $rows = NavItem::query()
+ ->where('is_enabled', 1)
+ ->orderBy('sort_order', 'asc')
+ ->get()
+ ->toArray();
+
+ $byId = [];
+ foreach ($rows as $row) {
+ if (in_array((int) $row['id'], $allowedIds, true)) {
+ $row['children'] = [];
+ $byId[$row['id']] = $row;
+ }
+ }
+
+ $tree = [];
+ foreach ($byId as $id => &$node) {
+ $pid = $node['menu_parent_id'] ?? null;
+ if ($pid && isset($byId[$pid])) {
+ $byId[$pid]['children'][] = &$node;
+ } else {
+ $tree[] = &$node;
+ }
+ }
+ unset($node);
+
+ return $tree;
+ });
+ }
+
+ public function clearCache(): void
+ {
+ Cache::flush();
+ }
+}
diff --git a/app/Services/Payments/PaypalPaymentService.php b/app/Services/Payments/PaypalPaymentService.php
index 0fdf387c..a1ada492 100644
--- a/app/Services/Payments/PaypalPaymentService.php
+++ b/app/Services/Payments/PaypalPaymentService.php
@@ -3,6 +3,7 @@
namespace App\Services\Payments;
use App\Models\Payment;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class PaypalPaymentService
@@ -55,12 +56,13 @@ class PaypalPaymentService
$paypalPayment->setRedirectUrls($redirectUrls);
$paypalPayment->create($apiContext);
- session()->put('paypalPaymentId', $paypalPayment->getId());
- session()->put('paymentId', $paymentId);
+ Cache::put($this->cacheKey((string) $paypalPayment->getId()), $paymentId, now()->addHours(2));
return [
'redirect_url' => $paypalPayment->getApprovalLink(),
'mode' => 'sdk',
+ 'paypal_payment_id' => $paypalPayment->getId(),
+ 'payment_id' => $paymentId,
];
} catch (\Throwable $e) {
Log::error('PayPal create payment failed: ' . $e->getMessage());
@@ -68,27 +70,33 @@ class PaypalPaymentService
}
}
- public function executePayment(string $payerId, ?string $paypalPaymentId = null): void
+ public function executePayment(string $payerId, ?string $paypalPaymentId = null, ?int $paymentId = null): void
{
if (!$this->sdkAvailable()) {
throw new \RuntimeException('PayPal SDK not installed.');
}
- $paymentId = $paypalPaymentId ?: (string) session()->get('paypalPaymentId');
- if (!$paymentId) {
+ $paypalId = $paypalPaymentId ?: '';
+ if ($paypalId === '') {
throw new \RuntimeException('Missing PayPal payment ID.');
}
$apiContext = $this->buildApiContext();
- $payment = \PayPal\Api\Payment::get($paymentId, $apiContext);
+ $payment = \PayPal\Api\Payment::get($paypalId, $apiContext);
$execution = new \PayPal\Api\PaymentExecution();
$execution->setPayerId($payerId);
$payment->execute($execution, $apiContext);
- $internalPaymentId = (int) session()->get('paymentId');
+ $internalPaymentId = $paymentId;
+ if (!$internalPaymentId && $paypalId !== '') {
+ $internalPaymentId = (int) Cache::get($this->cacheKey($paypalId));
+ }
if ($internalPaymentId) {
Payment::query()->whereKey($internalPaymentId)->update(['status' => 'Completed']);
+ if ($paypalId !== '') {
+ Cache::forget($this->cacheKey($paypalId));
+ }
}
}
@@ -120,4 +128,9 @@ class PaypalPaymentService
return $apiContext;
}
+
+ private function cacheKey(string $paypalPaymentId): string
+ {
+ return 'paypal_payment:' . $paypalPaymentId;
+ }
}
diff --git a/app/Services/Phone/PhoneFormatterService.php b/app/Services/Phone/PhoneFormatterService.php
new file mode 100644
index 00000000..aa7a6597
--- /dev/null
+++ b/app/Services/Phone/PhoneFormatterService.php
@@ -0,0 +1,22 @@
+ 'school_policy.html',
+ 'picture' => 'picture_policy.html',
+ ];
+
+ public function getPolicy(string $type): array
+ {
+ $type = strtolower(trim($type));
+ if (!isset(self::TYPES[$type])) {
+ throw new \InvalidArgumentException('Unsupported policy type.');
+ }
+
+ $filename = self::TYPES[$type];
+ $path = resource_path('policies/' . $filename);
+
+ if (!is_file($path)) {
+ Log::warning('Policy file missing.', ['type' => $type, 'path' => $path]);
+ return [
+ 'type' => $type,
+ 'title' => $this->titleFor($type),
+ 'content' => '',
+ 'format' => 'html',
+ 'source' => $path,
+ 'updated_at' => null,
+ ];
+ }
+
+ return [
+ 'type' => $type,
+ 'title' => $this->titleFor($type),
+ 'content' => (string) file_get_contents($path),
+ 'format' => 'html',
+ 'source' => $path,
+ 'updated_at' => date('Y-m-d H:i:s', filemtime($path)),
+ ];
+ }
+
+ private function titleFor(string $type): string
+ {
+ return $type === 'picture' ? 'Picture Policy' : 'School Policy';
+ }
+}
diff --git a/app/Services/Preferences/PreferencesCommandService.php b/app/Services/Preferences/PreferencesCommandService.php
new file mode 100644
index 00000000..ff629909
--- /dev/null
+++ b/app/Services/Preferences/PreferencesCommandService.php
@@ -0,0 +1,56 @@
+mapPayload($payload);
+ $data['user_id'] = $userId;
+
+ return DB::transaction(function () use ($userId, $data) {
+ $existing = Preferences::query()->where('user_id', $userId)->first();
+
+ if ($existing) {
+ $existing->fill($data);
+ $existing->save();
+ return $existing->refresh();
+ }
+
+ return Preferences::query()->create($data);
+ });
+ }
+
+ public function delete(Preferences $preferences): bool
+ {
+ return (bool) $preferences->delete();
+ }
+
+ private function mapPayload(array $payload): array
+ {
+ $email = $payload['receive_email_notifications'] ?? $payload['notification_email'] ?? null;
+ $sms = $payload['receive_sms_notifications'] ?? $payload['notification_sms'] ?? null;
+
+ $style = $this->options->normalizeStyle($payload['style_color'] ?? null);
+ $menu = $this->options->normalizeMenu($payload['menu_color'] ?? null);
+
+ $data = [
+ 'notification_email' => $email !== null ? (int) filter_var($email, FILTER_VALIDATE_BOOLEAN) : null,
+ 'notification_sms' => $sms !== null ? (int) filter_var($sms, FILTER_VALIDATE_BOOLEAN) : null,
+ 'theme' => $payload['theme'] ?? null,
+ 'language' => $payload['language'] ?? null,
+ 'style_color' => $style,
+ 'menu_color' => $menu,
+ ];
+
+ return array_filter($data, static fn ($value) => $value !== null);
+ }
+}
diff --git a/app/Services/Preferences/PreferencesOptionsService.php b/app/Services/Preferences/PreferencesOptionsService.php
new file mode 100644
index 00000000..09cf286f
--- /dev/null
+++ b/app/Services/Preferences/PreferencesOptionsService.php
@@ -0,0 +1,48 @@
+ true,
+ 'receive_sms_notifications' => true,
+ 'theme' => 'light',
+ 'language' => 'en',
+ 'style_color' => 'blue',
+ 'menu_color' => 'white',
+ ];
+ }
+
+ public function styleOptions(): array
+ {
+ return ['blue', 'green', 'red', 'orange', 'gray', 'black', 'white'];
+ }
+
+ public function menuOptions(): array
+ {
+ return ['white', 'gray', 'black', 'blue', 'green'];
+ }
+
+ public function normalizeStyle(?string $style): ?string
+ {
+ if (!$style) {
+ return null;
+ }
+
+ $style = trim($style);
+ return in_array($style, $this->styleOptions(), true) ? $style : null;
+ }
+
+ public function normalizeMenu(?string $menu): ?string
+ {
+ if (!$menu) {
+ return null;
+ }
+
+ $menu = trim($menu);
+ return in_array($menu, $this->menuOptions(), true) ? $menu : null;
+ }
+}
diff --git a/app/Services/Preferences/PreferencesQueryService.php b/app/Services/Preferences/PreferencesQueryService.php
new file mode 100644
index 00000000..bd546226
--- /dev/null
+++ b/app/Services/Preferences/PreferencesQueryService.php
@@ -0,0 +1,83 @@
+where('user_id', $userId)->first();
+
+ $base = $this->options->defaultPreferences();
+ if (!$row) {
+ return [
+ 'preferences' => $base,
+ 'options' => $this->optionsPayload(),
+ ];
+ }
+
+ return [
+ 'preferences' => array_merge($base, $this->mapRow($row)),
+ 'options' => $this->optionsPayload(),
+ ];
+ }
+
+ public function paginate(array $filters, int $page = 1, int $perPage = 20): LengthAwarePaginator
+ {
+ $query = Preferences::query()
+ ->leftJoin('users', 'users.id', '=', 'user_preferences.user_id')
+ ->select('user_preferences.*', 'users.firstname', 'users.lastname', 'users.email');
+
+ if (!empty($filters['user_id'])) {
+ $query->where('user_preferences.user_id', (int) $filters['user_id']);
+ }
+
+ if (!empty($filters['q'])) {
+ $term = '%' . strtolower(trim((string) $filters['q'])) . '%';
+ $query->where(function ($q) use ($term) {
+ $q->whereRaw('LOWER(users.firstname) LIKE ?', [$term])
+ ->orWhereRaw('LOWER(users.lastname) LIKE ?', [$term])
+ ->orWhereRaw('LOWER(users.email) LIKE ?', [$term]);
+ });
+ }
+
+ $sortBy = (string) ($filters['sort_by'] ?? 'updated_at');
+ $sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
+ $allowedSorts = ['updated_at', 'created_at', 'user_id'];
+
+ if (in_array($sortBy, $allowedSorts, true)) {
+ $query->orderBy('user_preferences.' . $sortBy, $sortDir);
+ } else {
+ $query->orderBy('user_preferences.updated_at', 'desc');
+ }
+
+ return $query->paginate($perPage, ['*'], 'page', $page);
+ }
+
+ private function optionsPayload(): array
+ {
+ return [
+ 'style_options' => $this->options->styleOptions(),
+ 'menu_options' => $this->options->menuOptions(),
+ ];
+ }
+
+ private function mapRow(Preferences $row): array
+ {
+ return [
+ 'receive_email_notifications' => (bool) ($row->notification_email ?? false),
+ 'receive_sms_notifications' => (bool) ($row->notification_sms ?? false),
+ 'theme' => $row->theme ?? null,
+ 'language' => $row->language ?? null,
+ 'style_color' => $row->style_color ?? null,
+ 'menu_color' => $row->menu_color ?? null,
+ ];
+ }
+}
diff --git a/app/Services/Roles/RoleSwitchService.php b/app/Services/Roles/RoleSwitchService.php
index 57eba73a..0a0ecd10 100644
--- a/app/Services/Roles/RoleSwitchService.php
+++ b/app/Services/Roles/RoleSwitchService.php
@@ -3,7 +3,6 @@
namespace App\Services\Roles;
use App\Models\UserRole;
-use Illuminate\Support\Facades\Session;
class RoleSwitchService
{
@@ -19,7 +18,6 @@ class RoleSwitchService
public function switchRole(string $role): string
{
- Session::put('active_role', $role);
return $this->dashboardService->bestDashboardRouteFor([$role]);
}
}
diff --git a/app/Services/School/SemesterSelectionService.php b/app/Services/School/SemesterSelectionService.php
index 77ead4c6..228f9907 100644
--- a/app/Services/School/SemesterSelectionService.php
+++ b/app/Services/School/SemesterSelectionService.php
@@ -8,16 +8,6 @@ class SemesterSelectionService
{
public function selectedTeacherSemester(): string
{
- $selected = trim((string) (session()->get('teacher_scores_selected_semester') ?? ''));
- if ($selected !== '') {
- return $this->normalize($selected);
- }
-
- $fallback = trim((string) (session()->get('semester') ?? ''));
- if ($fallback !== '') {
- return $this->normalize($fallback);
- }
-
$configSemester = trim((string) (Configuration::getConfig('semester') ?? ''));
if ($configSemester !== '') {
return $this->normalize($configSemester);
diff --git a/app/Services/Scores/ScoreCommentService.php b/app/Services/Scores/ScoreCommentService.php
index b4a91cf8..4204be4a 100644
--- a/app/Services/Scores/ScoreCommentService.php
+++ b/app/Services/Scores/ScoreCommentService.php
@@ -328,13 +328,28 @@ class ScoreCommentService
private function isReviewer(): bool
{
- $currentUserRole = session()->get('role');
+ $user = auth()->user();
+ if (!$user) {
+ return false;
+ }
+
+ $currentUserRoles = $user->roles()
+ ->pluck('roles.name')
+ ->map(fn ($name) => strtolower((string) $name))
+ ->unique()
+ ->values()
+ ->toArray();
+
$reviewerRoles = (string) (Configuration::getConfig('comment_reviewer') ?? '');
if ($reviewerRoles === '') {
return false;
}
- $allowedRoles = array_map('trim', explode(',', $reviewerRoles));
- return in_array($currentUserRole, $allowedRoles, true);
+ $allowedRoles = array_map(
+ static fn ($role) => strtolower(trim((string) $role)),
+ explode(',', $reviewerRoles)
+ );
+
+ return !empty(array_intersect($currentUserRoles, $allowedRoles));
}
}
diff --git a/app/Services/Security/IpBanCommandService.php b/app/Services/Security/IpBanCommandService.php
new file mode 100644
index 00000000..26b92a0f
--- /dev/null
+++ b/app/Services/Security/IpBanCommandService.php
@@ -0,0 +1,81 @@
+findRow($id, $ip);
+ if (!$row) {
+ return null;
+ }
+
+ $blockedUntil = $this->addHours($hours);
+ $attempts = max((int) $row->attempts, 10);
+
+ return DB::transaction(function () use ($row, $blockedUntil, $attempts) {
+ $row->blocked_until = $blockedUntil;
+ $row->attempts = $attempts;
+ $row->save();
+ return $row->refresh();
+ });
+ }
+
+ public function unbanOne(?int $id, ?string $ip): ?IpAttempt
+ {
+ $row = $this->findRow($id, $ip);
+ if (!$row) {
+ return null;
+ }
+
+ return DB::transaction(function () use ($row) {
+ $row->blocked_until = null;
+ $row->attempts = 0;
+ $row->save();
+ return $row->refresh();
+ });
+ }
+
+ public function unbanAll(): int
+ {
+ return (int) IpAttempt::query()
+ ->where('blocked_until', '>', $this->utcNow())
+ ->update([
+ 'blocked_until' => null,
+ 'attempts' => 0,
+ ]);
+ }
+
+ private function findRow(?int $id, ?string $ip): ?IpAttempt
+ {
+ if ($id && $id > 0) {
+ return IpAttempt::query()->find($id);
+ }
+ if ($ip !== null && $ip !== '') {
+ return IpAttempt::query()->where('ip_address', $ip)->first();
+ }
+
+ return null;
+ }
+
+ private function utcNow(): string
+ {
+ if (function_exists('utc_now')) {
+ return (string) utc_now();
+ }
+ return now('UTC')->toDateTimeString();
+ }
+
+ private function addHours(int $hours): string
+ {
+ $hours = max(1, $hours);
+ if (function_exists('utc_now')) {
+ return (string) date('Y-m-d H:i:s', strtotime("+{$hours} hours", strtotime((string) utc_now())));
+ }
+ return now('UTC')->addHours($hours)->toDateTimeString();
+ }
+}
diff --git a/app/Services/Security/IpBanQueryService.php b/app/Services/Security/IpBanQueryService.php
new file mode 100644
index 00000000..d3468abb
--- /dev/null
+++ b/app/Services/Security/IpBanQueryService.php
@@ -0,0 +1,46 @@
+orderBy('updated_at', 'desc');
+
+ $status = strtolower(trim((string) ($filters['status'] ?? 'all')));
+ if ($status === 'active') {
+ $query->where('blocked_until', '>', $this->utcNow());
+ }
+
+ if (!empty($filters['search'])) {
+ $term = '%' . trim((string) $filters['search']) . '%';
+ $query->where('ip_address', 'like', $term);
+ }
+
+ $sortBy = (string) ($filters['sort_by'] ?? 'updated_at');
+ $sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
+ $allowedSorts = ['updated_at', 'blocked_until', 'attempts', 'ip_address', 'last_attempt_at'];
+ if (in_array($sortBy, $allowedSorts, true)) {
+ $query->orderBy($sortBy, $sortDir);
+ }
+
+ return $query->paginate($perPage, ['*'], 'page', $page);
+ }
+
+ public function find(int $id): ?IpAttempt
+ {
+ return IpAttempt::query()->find($id);
+ }
+
+ private function utcNow(): string
+ {
+ if (function_exists('utc_now')) {
+ return (string) utc_now();
+ }
+ return now('UTC')->toDateTimeString();
+ }
+}
diff --git a/app/Services/Settings/SettingsService.php b/app/Services/Settings/SettingsService.php
new file mode 100644
index 00000000..045ae1a4
--- /dev/null
+++ b/app/Services/Settings/SettingsService.php
@@ -0,0 +1,48 @@
+ $settings->name,
+ 'timezone' => $settings->timezone,
+ 'administrator_email' => Configuration::getConfig('administrator_email') ?? '',
+ ];
+ }
+
+ public function update(array $payload, int $userId): array
+ {
+ return DB::transaction(function () use ($payload, $userId) {
+ $settings = Setting::singleton();
+
+ $updates = [];
+ if (array_key_exists('site_name', $payload)) {
+ $updates['name'] = $payload['site_name'];
+ }
+ if (array_key_exists('timezone', $payload)) {
+ $updates['timezone'] = $payload['timezone'];
+ }
+
+ if (!empty($updates)) {
+ $updates['updated_by'] = $userId;
+ $settings->fill($updates);
+ $settings->save();
+ }
+
+ if (array_key_exists('administrator_email', $payload)) {
+ Configuration::setConfigValueByKey('administrator_email', (string) $payload['administrator_email']);
+ }
+
+ return $this->get();
+ });
+ }
+}
diff --git a/app/Services/Staff/StaffCommandService.php b/app/Services/Staff/StaffCommandService.php
new file mode 100644
index 00000000..9aab50c5
--- /dev/null
+++ b/app/Services/Staff/StaffCommandService.php
@@ -0,0 +1,100 @@
+resolveUserId($payload);
+
+ $roleName = (string) ($payload['role_name'] ?? '');
+ $activeRole = strtolower($roleName);
+ if (!empty($payload['status']) && strtolower((string) $payload['status']) === 'inactive') {
+ $activeRole = 'inactive';
+ }
+
+ $schoolYear = (string) ($payload['school_year'] ?? $this->configService->getSchoolYear() ?? '');
+
+ $staff = Staff::query()->create([
+ 'user_id' => $userId,
+ 'firstname' => (string) $payload['firstname'],
+ 'lastname' => (string) $payload['lastname'],
+ 'email' => (string) $payload['email'],
+ 'phone' => $payload['phone'] ?? null,
+ 'role_name' => $roleName,
+ 'active_role' => $activeRole,
+ 'school_year' => $schoolYear,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ if (!$staff) {
+ throw new RuntimeException('Failed to create staff member.');
+ }
+
+ return $staff;
+ });
+ }
+
+ public function update(Staff $staff, array $payload): Staff
+ {
+ return DB::transaction(function () use ($staff, $payload) {
+ $updates = [];
+ foreach (['firstname', 'lastname', 'email', 'phone', 'role_name', 'school_year'] as $field) {
+ if (array_key_exists($field, $payload) && $payload[$field] !== '') {
+ $updates[$field] = $payload[$field];
+ }
+ }
+
+ if (array_key_exists('role_name', $updates)) {
+ $updates['active_role'] = strtolower((string) $updates['role_name']);
+ }
+ if (!empty($payload['status']) && strtolower((string) $payload['status']) === 'inactive') {
+ $updates['active_role'] = 'inactive';
+ }
+
+ $updates['updated_at'] = now();
+
+ if (!empty($updates)) {
+ $staff->fill($updates);
+ $staff->save();
+ }
+
+ return $staff->fresh();
+ });
+ }
+
+ public function delete(Staff $staff): bool
+ {
+ return (bool) $staff->delete();
+ }
+
+ private function resolveUserId(array $payload): int
+ {
+ if (!empty($payload['user_id'])) {
+ return (int) $payload['user_id'];
+ }
+
+ $email = $payload['email'] ?? null;
+ if ($email) {
+ $userId = User::query()->where('email', $email)->value('id');
+ if ($userId) {
+ return (int) $userId;
+ }
+ }
+
+ throw new RuntimeException('A valid user_id or existing email is required.');
+ }
+}
diff --git a/app/Services/Staff/StaffQueryService.php b/app/Services/Staff/StaffQueryService.php
new file mode 100644
index 00000000..5e465d95
--- /dev/null
+++ b/app/Services/Staff/StaffQueryService.php
@@ -0,0 +1,112 @@
+whereNotIn(DB::raw('LOWER(active_role)'), $excluded);
+
+ if (!empty($filters['role'])) {
+ $role = strtolower(trim((string) $filters['role']));
+ $query->whereRaw('LOWER(active_role) = ?', [$role]);
+ }
+
+ $sortBy = $filters['sort_by'] ?? 'created_at';
+ $sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
+
+ $paginator = $query
+ ->orderBy($sortBy, $sortDir)
+ ->paginate($perPage, ['*'], 'page', $page);
+
+ $schoolYear = (string) ($filters['school_year'] ?? $this->configService->getSchoolYear() ?? '');
+ $semester = (string) ($filters['semester'] ?? $this->configService->getSemester() ?? '');
+
+ $assignByTeacher = $this->buildAssignments($schoolYear);
+
+ $issuesCount = 0;
+ $items = $paginator->getCollection()->map(function ($staff) use ($assignByTeacher, &$issuesCount) {
+ $staff->school_id = User::getSchoolIdByUserId((int) ($staff->user_id ?? 0));
+
+ $role = strtolower((string) ($staff->active_role ?? ''));
+ if (in_array($role, ['teacher', 'teacher_assistant'], true)) {
+ $tid = (int) ($staff->user_id ?? 0);
+ $labels = $assignByTeacher[$tid] ?? [];
+ if (!empty($labels)) {
+ $staff->class_section = implode(', ', array_unique($labels));
+ $staff->verification_issue = false;
+ } else {
+ $staff->class_section = 'No class assigned';
+ $staff->verification_issue = true;
+ $issuesCount++;
+ }
+ } else {
+ $staff->class_section = '—';
+ $staff->verification_issue = false;
+ }
+
+ return $staff;
+ });
+
+ $paginator->setCollection($items);
+
+ return [
+ 'paginator' => $paginator,
+ 'issues_count' => $issuesCount,
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ ];
+ }
+
+ public function find(int $id): ?Staff
+ {
+ return Staff::query()->find($id);
+ }
+
+ private function buildAssignments(string $schoolYear): array
+ {
+ if ($schoolYear === '') {
+ return [];
+ }
+
+ $rows = TeacherClass::query()
+ ->select('teacher_class.teacher_id', 'teacher_class.position', 'classSection.class_section_name')
+ ->leftJoin('classSection', 'classSection.class_section_id', '=', 'teacher_class.class_section_id')
+ ->where('teacher_class.school_year', $schoolYear)
+ ->get();
+
+ $assignByTeacher = [];
+ foreach ($rows as $row) {
+ $tid = (int) ($row->teacher_id ?? 0);
+ if ($tid <= 0) {
+ continue;
+ }
+ $name = trim((string) ($row->class_section_name ?? ''));
+ if ($name === '') {
+ continue;
+ }
+ $pos = strtolower((string) ($row->position ?? ''));
+ if (!in_array($pos, ['main', 'ta'], true)) {
+ continue;
+ }
+ $assignByTeacher[$tid][] = $name . ' (' . $pos . ')';
+ }
+
+ return $assignByTeacher;
+ }
+}
diff --git a/app/Services/Support/ContactMessageService.php b/app/Services/Support/ContactMessageService.php
new file mode 100644
index 00000000..4ca3882d
--- /dev/null
+++ b/app/Services/Support/ContactMessageService.php
@@ -0,0 +1,41 @@
+From: {$name} ({$email})
";
+ $formatted .= "Subject: {$subject}
";
+ $formatted .= '' . nl2br($message) . '
';
+
+ $recipient = (string) config('support.contact_email', 'support@alrahmaisgl.org');
+
+ $emailSent = false;
+ if (!app()->runningUnitTests()) {
+ $emailSent = $this->emailService->send($recipient, $subject, $formatted, null, $email, $name);
+ }
+
+ if (!$emailSent) {
+ Log::warning('Contact email failed for ' . $email);
+ }
+
+ return [
+ 'email_sent' => $emailSent,
+ 'recipient' => $recipient,
+ ];
+ }
+}
diff --git a/app/Services/Support/SupportRequestService.php b/app/Services/Support/SupportRequestService.php
new file mode 100644
index 00000000..a70e90cf
--- /dev/null
+++ b/app/Services/Support/SupportRequestService.php
@@ -0,0 +1,80 @@
+with('user');
+
+ if (!$asAdmin) {
+ $query->where('user_id', $userId);
+ } elseif (!empty($filters['user_id'])) {
+ $query->where('user_id', (int) $filters['user_id']);
+ }
+
+ if (!empty($filters['status'])) {
+ $query->where('status', $filters['status']);
+ }
+
+ $sortBy = $filters['sort_by'] ?? 'created_at';
+ $sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
+
+ return $query->orderBy($sortBy, $sortDir)
+ ->paginate($perPage, ['*'], 'page', $page);
+ }
+
+ public function create(int $userId, array $payload): array
+ {
+ $semester = $this->configService->getSemester() ?? '';
+ $schoolYear = $this->configService->getSchoolYear() ?? '';
+
+ return DB::transaction(function () use ($userId, $payload, $semester, $schoolYear) {
+ $record = SupportRequest::query()->create([
+ 'user_id' => $userId,
+ 'subject' => (string) $payload['subject'],
+ 'message' => (string) $payload['message'],
+ 'status' => 'open',
+ 'semester' => $semester ?: 'Fall',
+ 'school_year' => $schoolYear ?: '2025-2026',
+ ]);
+
+ $recipient = (string) config('support.contact_email', 'support@alrahmaisgl.org');
+ $emailSent = false;
+ if (!app()->runningUnitTests()) {
+ $emailSent = $this->emailService->send(
+ $recipient,
+ (string) $payload['subject'],
+ nl2br((string) $payload['message']),
+ null,
+ (string) ($payload['user_email'] ?? null),
+ (string) ($payload['user_name'] ?? null)
+ );
+ }
+
+ if (!$emailSent) {
+ Log::warning('Support email failed for user ' . $userId);
+ }
+
+ return [
+ 'record' => $record,
+ 'email_sent' => $emailSent,
+ 'recipient' => $recipient,
+ ];
+ });
+ }
+}
diff --git a/app/Services/System/CleanupSchedulerService.php b/app/Services/System/CleanupSchedulerService.php
new file mode 100644
index 00000000..31e299e7
--- /dev/null
+++ b/app/Services/System/CleanupSchedulerService.php
@@ -0,0 +1,29 @@
+diffInMinutes($lastRun) >= $minutes;
+ }
+
+ public function markRun(string $cacheKey): void
+ {
+ Cache::put($cacheKey, now(), now()->addDay());
+ }
+
+ public function runUnverifiedCleanup(): void
+ {
+ Artisan::call('users:delete-unverified');
+ }
+}
diff --git a/app/Services/System/DatabaseHealthService.php b/app/Services/System/DatabaseHealthService.php
new file mode 100644
index 00000000..53895117
--- /dev/null
+++ b/app/Services/System/DatabaseHealthService.php
@@ -0,0 +1,24 @@
+ false,
+ 'error' => $e->getMessage(),
+ ];
+ }
+
+ return [
+ 'ok' => true,
+ ];
+ }
+}
diff --git a/app/Services/System/HealthCheckService.php b/app/Services/System/HealthCheckService.php
new file mode 100644
index 00000000..1ecb2c31
--- /dev/null
+++ b/app/Services/System/HealthCheckService.php
@@ -0,0 +1,64 @@
+ $uploadsBase,
+ 'uploads/reimbursements' => $uploadsBase . DIRECTORY_SEPARATOR . 'reimbursements',
+ 'uploads/receipts' => $uploadsBase . DIRECTORY_SEPARATOR . 'receipts',
+ 'uploads/checks' => $uploadsBase . DIRECTORY_SEPARATOR . 'checks',
+ 'uploads/early_dismissal_signatures' => $uploadsBase . DIRECTORY_SEPARATOR . 'early_dismissal_signatures',
+ ];
+
+ $pathsStatus = [];
+ foreach ($paths as $label => $path) {
+ $pathsStatus[] = [
+ 'label' => $label,
+ 'path' => $path,
+ 'exists' => is_dir($path),
+ 'writable' => is_writable($path),
+ ];
+ }
+
+ $dbChecks = [
+ 'user_preferences_exists' => Schema::hasTable('user_preferences'),
+ 'settings_exists' => Schema::hasTable('settings'),
+ 'migrations_exists' => Schema::hasTable('migrations'),
+ ];
+
+ $dbChecks['user_preferences_has_timezone'] = $dbChecks['user_preferences_exists']
+ ? Schema::hasColumn('user_preferences', 'timezone')
+ : false;
+
+ $dbChecks['settings_has_timezone'] = $dbChecks['settings_exists']
+ ? Schema::hasColumn('settings', 'timezone')
+ : false;
+
+ $okPaths = array_reduce($pathsStatus, function (bool $carry, array $row) {
+ return $carry && $row['exists'] && $row['writable'];
+ }, true);
+
+ $okDb = true;
+ if ($dbChecks['user_preferences_exists'] && !$dbChecks['user_preferences_has_timezone']) {
+ $okDb = false;
+ }
+ if ($dbChecks['settings_exists'] && !$dbChecks['settings_has_timezone']) {
+ $okDb = false;
+ }
+
+ return [
+ 'ok' => $okPaths && $okDb,
+ 'paths' => $pathsStatus,
+ 'database' => $dbChecks,
+ 'write_path' => storage_path(),
+ 'timestamp' => now()->toIso8601String(),
+ ];
+ }
+}
diff --git a/app/Services/System/TimeService.php b/app/Services/System/TimeService.php
index 87190d6d..2a90a6ed 100644
--- a/app/Services/System/TimeService.php
+++ b/app/Services/System/TimeService.php
@@ -20,6 +20,18 @@ class TimeService
return (string) (config('app.timezone') ?: 'UTC');
}
+ public function primeFromRequest($request): void
+ {
+ $headerTz = (string) ($request?->header('X-Timezone') ?? '');
+ if ($headerTz === '') {
+ $headerTz = (string) ($request?->header('Time-Zone') ?? '');
+ }
+
+ if ($headerTz !== '' && in_array($headerTz, \DateTimeZone::listIdentifiers(), true)) {
+ // No session-backed storage; header is read per request if needed.
+ }
+ }
+
public function serverTimezone(): string
{
return (string) (config('app.timezone') ?: 'UTC');
diff --git a/app/Services/Ui/UiStyleService.php b/app/Services/Ui/UiStyleService.php
new file mode 100644
index 00000000..76483dcf
--- /dev/null
+++ b/app/Services/Ui/UiStyleService.php
@@ -0,0 +1,94 @@
+buildUpdates($payload);
+
+ $prefs = Preferences::query()->where('user_id', $userId)->first();
+ if ($prefs) {
+ $prefs->fill($updates)->save();
+ return $prefs->fresh();
+ }
+
+ $updates['user_id'] = $userId;
+ return Preferences::query()->create($updates);
+ }
+
+ public function buildUpdates(array $payload): array
+ {
+ $updates = [];
+ $accent = $this->sanitizePaletteKey($payload['accent'] ?? null);
+ $menu = $this->sanitizePaletteKey($payload['menu'] ?? null);
+ $menuBg = $this->normalizeHex($payload['menu_bg'] ?? null);
+ $menuTx = $this->normalizeHex($payload['menu_text'] ?? null);
+ $menuMode = $this->normalizeMenuMode($payload['menu_mode'] ?? null);
+
+ $stylePalettes = (array) config('ui.style_palettes', []);
+ $menuPalettes = (array) config('ui.menu_palettes', []);
+
+ if ($accent !== null && (empty($stylePalettes) || array_key_exists($accent, $stylePalettes))) {
+ $updates['style_color'] = $accent;
+ }
+
+ if ($menu !== null && (empty($menuPalettes) || array_key_exists($menu, $menuPalettes))) {
+ $updates['menu_color'] = $menu;
+ $updates['menu_custom_bg'] = null;
+ $updates['menu_custom_text'] = null;
+ $updates['menu_custom_mode'] = null;
+ }
+
+ if ($menu === 'custom' || $menuBg !== null || $menuTx !== null) {
+ if ($menuBg === null && $menuTx === null) {
+ return $updates;
+ }
+ $updates['menu_color'] = 'custom';
+ $updates['menu_custom_bg'] = $menuBg ?: '#0F172A';
+ $updates['menu_custom_text'] = $menuTx ?: '#FFFFFF';
+ $updates['menu_custom_mode'] = $menuMode;
+ }
+
+ return $updates;
+ }
+
+ private function sanitizePaletteKey(?string $value): ?string
+ {
+ $value = trim((string) $value);
+ return $value === '' ? null : $value;
+ }
+
+ private function normalizeMenuMode(?string $mode): string
+ {
+ $mode = strtolower(trim((string) $mode));
+ if (in_array($mode, ['light', 'dark', 'auto'], true)) {
+ return $mode;
+ }
+ return 'auto';
+ }
+
+ private function normalizeHex(?string $hex): ?string
+ {
+ $h = trim((string) $hex);
+ if ($h === '') {
+ return null;
+ }
+ if ($h[0] !== '#') {
+ $h = '#' . $h;
+ }
+ if (preg_match('/^#([0-9a-fA-F]{3})$/', $h, $m)) {
+ $r = $m[1][0];
+ $g = $m[1][1];
+ $b = $m[1][2];
+ return strtoupper('#' . $r . $r . $g . $g . $b . $b);
+ }
+ if (preg_match('/^#([0-9a-fA-F]{6})$/', $h)) {
+ return strtoupper($h);
+ }
+ return null;
+ }
+}
diff --git a/app/old/ApiClient.php b/app/old/ApiClient.php
deleted file mode 100644
index 8dc3ae8a..00000000
--- a/app/old/ApiClient.php
+++ /dev/null
@@ -1,107 +0,0 @@
-http = $http;
- $this->config = $config;
- }
-
- public function get(string $uri, array $query = [], array $headers = []): array
- {
- return $this->request('get', $uri, [
- 'headers' => $this->mergeHeaders($headers),
- 'query' => $query,
- ]);
- }
-
- public function post(string $uri, array $data = [], array $headers = []): array
- {
- return $this->request('post', $uri, [
- 'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']),
- 'json' => $data,
- ]);
- }
-
- public function put(string $uri, array $data = [], array $headers = []): array
- {
- return $this->request('put', $uri, [
- 'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']),
- 'json' => $data,
- ]);
- }
-
- public function delete(string $uri, array $data = [], array $headers = []): array
- {
- return $this->request('delete', $uri, [
- 'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']),
- 'json' => $data,
- ]);
- }
-
- /**
- * Low-level access for uncommon HTTP methods or options
- */
- public function request(string $method, string $uri, array $options = []): array
- {
- $url = $this->fullUrl($uri);
- $options['timeout'] = $options['timeout'] ?? $this->config->timeout;
-
- $response = $this->http->request(strtoupper($method), $url, $options);
-
- $status = $response->getStatusCode();
- $body = (string) $response->getBody();
-
- $decoded = null;
- if ($this->isJson($body)) {
- $decoded = json_decode($body, true);
- }
-
- return [
- 'ok' => $status >= 200 && $status < 300,
- 'status' => $status,
- 'headers' => $response->getHeaderLine('Content-Type'),
- 'data' => $decoded,
- 'raw' => $decoded === null ? $body : null,
- ];
- }
-
- protected function fullUrl(string $uri): string
- {
- if (preg_match('#^https?://#i', $uri)) {
- return $uri;
- }
-
- $base = rtrim($this->config->baseURL ?? '', '/');
- if ($base === '') {
- return '/' . ltrim($uri, '/');
- }
- return $base . '/' . ltrim($uri, '/');
- }
-
- protected function mergeHeaders(array $override = [], array $extra = []): array
- {
- $base = $this->config->defaultHeaders ?? [];
- return array_filter(array_merge($base, $extra, $override), static function ($v) {
- return $v !== null && $v !== '';
- });
- }
-
- protected function isJson(string $string): bool
- {
- json_decode($string);
- return json_last_error() === JSON_ERROR_NONE;
- }
-}
-
-?>
-
diff --git a/app/old/ClassController.php b/app/old/ClassController.php
deleted file mode 100644
index 413e349b..00000000
--- a/app/old/ClassController.php
+++ /dev/null
@@ -1,157 +0,0 @@
-db = \Config\Database::connect();
- $this->classsectionModel = new ClassSectionModel();
-
- // Load necessary models
- $this->studentModel = new StudentModel();
- $this->teacherModel = new TeacherModel();
- $this->configModel = new ConfigurationModel();
-
- // Get the semester from the configuration table
- $this->semester = $this->configModel->getConfig('semester');
- $this->schoolYear = $this->configModel->getConfig('school_year');
- }
-
- public function index()
- {
- // Retrieve all classes from the database
- $classsection = $this->classsectionModel->getAllClasses();
- // Pass the classes to the view
- return view('administrator/class_section', ['classsection' => $classsection]);
- }
-
- public function create()
- {
- // Show the form to create a new class
- return view('administrator/create_class');
- }
-
- public function store()
- {
- // Handle the form submission to create a new class
- $data = [
- 'name' => $this->request->getPost('name'),
- 'description' => $this->request->getPost('description'),
- ];
- //$this->classModel->createClass($data);
- return redirect()->to('/administrator/classes');
- }
-
- public function edit($id)
- {
- // Retrieve the class details to edit
- //$class = $this->ClassSectionModel->getClassById($id);
- //return view('administrator/edit_class', ['class' => $class]);
- }
-
- public function updateClass($id)
- {
- // Handle the form submission to update an existing class
- $data = [
- 'name' => $this->request->getPost('name'),
- 'description' => $this->request->getPost('description'),
- ];
- //$this->classModel->updateClass($id, $data);
- return redirect()->to('/administrator/classes');
- }
-
- public function destroyClass($id)
- {
- // Delete the class
- //$this->classModel->deleteClass($id);
- return redirect()->to('/administrator/classes');
- }
-
- public function addClasses()
- {
- // Include the shared database connection file
- $file = __DIR__ . '/../db_connection.php';
-
- if (file_exists($file)) {
- require_once $file;
- } else {
- die("Error: Could not find the required file '$file'.");
- }
-
-
- $classes = [
- ['class_name' => 'Class 1', 'teacher_id' => 1, 'schedule' => 'Monday 9:00 AM - 10:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 2', 'teacher_id' => 2, 'schedule' => 'Tuesday 9:00 AM - 10:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 3', 'teacher_id' => 3, 'schedule' => 'Wednesday 9:00 AM - 10:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 4', 'teacher_id' => 4, 'schedule' => 'Thursday 9:00 AM - 10:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 5', 'teacher_id' => 5, 'schedule' => 'Friday 9:00 AM - 10:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 6', 'teacher_id' => 6, 'schedule' => 'Monday 10:00 AM - 11:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 7', 'teacher_id' => 7, 'schedule' => 'Tuesday 10:00 AM - 11:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 8', 'teacher_id' => 8, 'schedule' => 'Wednesday 10:00 AM - 11:00 AM', 'capacity' => 30],
- ['class_name' => 'Class 9', 'teacher_id' => 9, 'schedule' => 'Thursday 10:00 AM - 11:00 AM', 'capacity' => 30],
- ['class_name' => 'Youth', 'teacher_id' => 10, 'schedule' => 'Friday 10:00 AM - 11:00 AM', 'capacity' => 30],
- ];
-
- foreach ($classes as $class) {
- $stmt = $conn->prepare("INSERT INTO classes (class_name, teacher_id, schedule, capacity) VALUES (?, ?, ?, ?)");
- if (!$stmt) {
- die("Prepare failed: (" . $conn->errno . ") " . $conn->error);
- }
- $stmt->bind_param("sisi", $class['class_name'], $class['teacher_id'], $class['schedule'], $class['capacity']);
- $stmt->execute();
- $stmt->close();
- }
-
- $conn->close();
-
- return redirect()->to('/parent/classes')->with('success', 'Classes added successfully.');
- }
-
- public function classAttendance($class_section_id)
- {
- // Get the teacher's ID from the session
- $teacherId = session()->get('user_id');
-
- // Fetch teacher data
- $teacherData = $this->teacherModel->find($teacherId);
-
- // Fetch students for the class
- $studentsData = $this->studentModel->join('student_class', 'students.id = student_class.student_id')
- ->where('student_class.class_section_id', $class_section_id)
- ->select('students.*, student_class.class_section_id')
- ->findAll();
-
- // Get class name (assuming it's stored in the students data)
- $className = !empty($studentsData) && isset($studentsData[0]['class_name']) ? $studentsData[0]['class_name'] : 'Class Name Not Available';
-
- // Check if the teacher data and students data are available
- if (empty($teacherData) || empty($studentsData)) {
- return redirect()->to('/teacher/classes')->with('error', 'No data found for this class.');
- }
-
- // Pass data to the view
- return view('/teacher/classes', [
- 'teacher_name' => $teacherData['teacher_first'] . ' ' . $teacherData['teacher_last'],
- 'students' => $studentsData,
- 'class_name' => $className,
- 'semester' => $this->semester,
- 'class_section_id' => $class_section_id, // Pass the class_id for attendance update forms
- ]);
- }
-}
diff --git a/app/old/ContactController.php b/app/old/ContactController.php
deleted file mode 100644
index 1f0a109f..00000000
--- a/app/old/ContactController.php
+++ /dev/null
@@ -1,66 +0,0 @@
-setRules([
- 'name' => 'required|min_length[3]',
- 'email' => 'required|valid_email',
- 'subject' => 'required|min_length[3]',
- 'message' => 'required|min_length[10]'
- ]);
-
- if (!$validation->withRequest($this->request)->run()) {
- return view('/parent/contact', [
- 'validation' => $validation
- ]);
- }
-
- // Process form data
- $name = $this->request->getPost('name');
- $email = strtolower($this->request->getPost('email'));
- $subject = $this->request->getPost('subject');
- $message = $this->request->getPost('message');
-
- // Initialize the EmailController
- $emailController = new \App\Controllers\View\EmailController();
-
- // Prepare the message to send
- $formattedMessage = "
- From: {$name} ({$email})
- Subject: {$subject}
- {$message}
- ";
-
- // Send the email using EmailController
- if ($emailController->sendEmail('support@alrahmaisgl.org', $subject, $formattedMessage)) {
- $session = session();
- $session->setFlashdata('success', 'Thank you for contacting us! We will get back to you soon.');
- } else {
- $session = session();
- $session->setFlashdata('error', 'There was an error sending your message. Please try again later.');
- }
-
- return redirect()->to('/parent/contact');
- }
-}
\ No newline at end of file
diff --git a/app/old/DashboardRedirectController.php b/app/old/DashboardRedirectController.php
deleted file mode 100644
index 2e00b040..00000000
--- a/app/old/DashboardRedirectController.php
+++ /dev/null
@@ -1,48 +0,0 @@
-get('roles') ?? [];
-
- // Fallback: if only a single role is stored
- if (empty($roles) && $session->get('role')) {
- $roles = [$session->get('role')];
- }
-
- // Use your existing method
- return $this->redirectToDashboard($roles);
- }
-
- // Your existing function (unchanged)
-
-private function redirectToDashboard(array $roles)
-{
- if (empty($roles)) {
- log_message('error', 'Empty roles array passed to redirectToDashboard.');
- return redirect()->to('/landing_page/guest_dashboard');
- }
-
- $roleModel = new RoleModel();
-
- // Resolve all candidate roles (by name or slug), ordered by priority ASC
- $rows = $roleModel->findByNamesOrSlugs($roles);
-
- if (!empty($rows)) {
- $route = $rows[0]['dashboard_route'] ?? '/landing_page/guest_dashboard';
- log_message('debug', 'Redirecting user to: ' . $route);
- return redirect()->to($route);
- }
-
- log_message('warning', 'No matching role found. Redirecting to guest dashboard.');
- return redirect()->to('/landing_page/guest_dashboard');
-}
-
-}
\ No newline at end of file
diff --git a/app/old/DocsController.php b/app/old/DocsController.php
deleted file mode 100644
index 093ff067..00000000
--- a/app/old/DocsController.php
+++ /dev/null
@@ -1,25 +0,0 @@
- 'Administrator API',
- 'url' => '/docs/openapi_administrator_controller.yaml',
- ],
- [
- 'name' => 'Parent API',
- 'url' => '/docs/openapi_parent_controller.yaml',
- ],
- ];
-
- return view('swagger_ui', ['specs' => $specs]);
- }
-}
diff --git a/app/old/Filters/.gitkeep b/app/old/Filters/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/app/old/Filters/ApiAuthFilter.php b/app/old/Filters/ApiAuthFilter.php
deleted file mode 100644
index a91c3f9c..00000000
--- a/app/old/Filters/ApiAuthFilter.php
+++ /dev/null
@@ -1,59 +0,0 @@
-getHeaderLine('Authorization');
- if (!$authorization || stripos($authorization, 'Bearer ') !== 0) {
- return $this->unauthorized('Missing or invalid Authorization header');
- }
-
- $token = trim(substr($authorization, 7));
- if ($token === '') {
- return $this->unauthorized('Bearer token is required');
- }
-
- $secret = env('JWT_SECRET', 'change-me-in-env');
- $payload = jwt_decode($token, $secret);
-
- if (!$payload || empty($payload['sub'])) {
- return $this->unauthorized('Invalid or malformed token');
- }
-
- if (isset($payload['exp']) && (int) $payload['exp'] < time()) {
- return $this->unauthorized('Token has expired');
- }
-
- $session = Services::session();
- $session->start();
-
- $session->set([
- 'user_id' => (int) $payload['sub'],
- 'roles' => (array) ($payload['roles'] ?? []),
- ]);
-
- return null;
- }
-
- public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
- {
- return $response;
- }
-
- private function unauthorized(string $message)
- {
- $response = Services::response();
- return $response->setStatusCode(401)->setJSON([
- 'status' => false,
- 'message' => $message,
- ]);
- }
-}
diff --git a/app/old/Filters/ApiDocsAuthFilter.php b/app/old/Filters/ApiDocsAuthFilter.php
deleted file mode 100644
index 4938b9ed..00000000
--- a/app/old/Filters/ApiDocsAuthFilter.php
+++ /dev/null
@@ -1,55 +0,0 @@
-getHeaderLine('Authorization');
-
- if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
- $token = trim(substr($authHeader, 7));
-
- try {
- $key = getenv('JWT_SECRET') ?: 'your_default_secret';
- $decoded = JWT::decode($token, new Key($key, 'HS256'));
-
- if (!isset($decoded->roles) || empty($decoded->roles->admin)) {
- return $this->denyAccess('Admin privileges required.', ResponseInterface::HTTP_FORBIDDEN);
- }
- } catch (\Throwable $e) {
- return $this->denyAccess('Invalid or expired token.', ResponseInterface::HTTP_UNAUTHORIZED);
- }
- } else {
- $session = session();
- if (!$session->get('isLoggedIn') || $session->get('role') !== 'admin') {
- if (strpos($request->getHeaderLine('Accept'), 'text/html') !== false) {
- return redirect()->to(base_url('login'))->with('error', 'Please log in to access API documentation.');
- }
- return $this->denyAccess('Unauthorized access to documentation.', ResponseInterface::HTTP_UNAUTHORIZED);
- }
- }
- }
-
- private function denyAccess(string $message, int $status)
- {
- return Services::response()
- ->setJSON([
- 'status' => false,
- 'message' => $message,
- ])
- ->setStatusCode($status);
- }
-
- public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
- {
- }
-}
diff --git a/app/old/Filters/AuthFilter.php b/app/old/Filters/AuthFilter.php
deleted file mode 100644
index f3fcae66..00000000
--- a/app/old/Filters/AuthFilter.php
+++ /dev/null
@@ -1,200 +0,0 @@
- ['permission' => 'nav_builder_access', 'crud' => 'read'],
- // 'admin/enrollment/new-students' => ['permission' => 'view_new_students', 'crud' => 'read'],
- // 'administrator/contact_information' => ['permission' => 'view_contact_info', 'crud' => 'read'],
- // You can also allow specific role IDs directly:
- // 'some/admin/page' => ['roles' => [1, 2]], // role_id 1 or 2
- ];
-
- public function __construct()
- {
- $this->db = \Config\Database::connect();
- }
-
- public function before(RequestInterface $request, $arguments = null)
- {
- $session = session();
- $userRoles = $session->get('roles'); // not used here, but keep if other code expects it
- $userId = $session->get('user_id');
- $loginTime = (int) $session->get('login_time');
-
- // Must be logged in
- if (empty($userRoles) || empty($userId)) {
- return redirect()->to('/login');
- }
-
- // Enforce 12-hour session lifetime
- if ($loginTime <= 0 || (time() - $loginTime) >= self::LOGIN_TTL) {
- return $this->handleExpiredSession($request);
- }
-
- // Load role IDs for this user
- $roleIdRows = $this->db->table('user_roles')
- ->select('role_id')
- ->where('user_id', $userId)
- ->get()->getResultArray();
-
- $roleIds = array_map('intval', array_column($roleIdRows, 'role_id'));
- if (empty($roleIds)) {
- return $this->deny($request, "You don't have permission to use this feature.");
- }
-
- // Route arguments: ['filter' => 'auth:permission_name|alt_permission,update']
- // Route arguments patterns supported:
- // auth -> default 'read'
- // auth:read -> CRUD only
- // auth:create -> CRUD only
- // auth:update -> CRUD only
- // auth:delete -> CRUD only
- // auth:permA|permB,read -> permission + CRUD
- $requiredPermission = null;
- $crudAction = 'read';
-
- if (!empty($arguments)) {
- $arg0 = strtolower((string) $arguments[0]);
-
- // If only one argument and it's a CRUD keyword, treat as CRUD-only
- if (count($arguments) === 1 && in_array($arg0, ['create', 'read', 'update', 'delete'], true)) {
- $crudAction = $arg0;
- } else {
- // Otherwise, first arg is permission(s); optional second is CRUD
- $requiredPermission = $arguments[0]; // e.g., 'edit_student|manage_students'
- $crudAction = isset($arguments[1]) ? strtolower((string) $arguments[1]) : 'read';
- if (!in_array($crudAction, ['create', 'read', 'update', 'delete'], true)) {
- $crudAction = 'read';
- }
- }
- }
-
-
- // No explicit route permission: fall back to menu rules
- if ($this->isAllowedByMenu($request, $roleIds)) {
- return; // ✅ allowed
- }
-
- return $this->deny($request, "You don't have permission to use this feature.");
- }
-
- private function handleExpiredSession(RequestInterface $request)
- {
- session()->destroy();
-
- if ($request->isAJAX() || $request->getHeaderLine('Accept') === 'application/json') {
- return service('response')
- ->setStatusCode(401)
- ->setJSON([
- 'status' => 'expired',
- 'message' => 'Your session has expired. Please log in again.',
- 'redirect' => '/login',
- ]);
- }
-
- return redirect()->to('/login')->with('error', 'Your session has expired. Please log in again.');
- }
-
- public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
- {
- // No-op
- }
-
- /**
- * JSON for AJAX, redirect for normal requests.
- * Use RequestInterface here (your version used IncomingRequest which was too specific).
- */
- public function deny(RequestInterface $request, string $message)
- {
- if ($request->isAJAX() || $request->getHeaderLine('Accept') === 'application/json') {
- return service('response')
- ->setStatusCode(403)
- ->setJSON(['status' => 'error', 'message' => $message]);
- }
-
- session()->setFlashdata('error', $message);
- return redirect()->to('/access_denied');
- }
-
- /**
- * Checks roles against CRUD flags on a role_permissions row.
- */
- private function hasPermission(array $rolePermission, string $crudAction): bool
- {
- switch ($crudAction) {
- case 'create':
- return !empty($rolePermission['can_create']);
- case 'read':
- return !empty($rolePermission['can_read']);
- case 'update':
- return !empty($rolePermission['can_update']);
- case 'delete':
- return !empty($rolePermission['can_delete']);
- default:
- return false;
- }
- }
-
- /**
- * ✅ Missing method added:
- * Allow/deny based on $menuRules and the user's role IDs / permissions.
- * Defaults to ALLOW if the path has no rule.
- */
- private function isAllowedByMenu(RequestInterface $request, array $roleIds): bool
- {
- // ❌ was: $request->uri->getPath()
- $path = ltrim(preg_replace('#/+#', '/', $request->getUri()->getPath()), '/');
-
- $rule = $this->menuRules[$path] ?? null;
-
- if ($rule === null && str_starts_with($path, 'admin/')) {
- $rule = $this->menuRules[substr($path, 6)] ?? null;
- }
-
- if ($rule === null) {
- return true; // or false if you prefer default-deny
- }
-
- if (!empty($rule['roles']) && is_array($rule['roles'])) {
- foreach ($roleIds as $rid) {
- if (in_array((int)$rid, $rule['roles'], true)) return true;
- }
- // fall through to permission check (if present)
- }
-
- if (!empty($rule['permission'])) {
- $crud = $rule['crud'] ?? 'read';
- $rpRows = $this->db->table('role_permissions rp')
- ->join('permissions p', 'p.id = rp.permission_id')
- ->select('rp.*')
- ->whereIn('rp.role_id', $roleIds)
- ->where('p.name', $rule['permission'])
- ->get()->getResultArray();
-
- foreach ($rpRows as $rp) {
- if ($this->hasPermission($rp, $crud)) return true;
- }
- return false;
- }
-
- return true;
- }
-}
diff --git a/app/old/Filters/CleanupScheduler.php b/app/old/Filters/CleanupScheduler.php
deleted file mode 100644
index 555a8d71..00000000
--- a/app/old/Filters/CleanupScheduler.php
+++ /dev/null
@@ -1,36 +0,0 @@
-difference($lastRun)->getMinutes() >= 2) {
- // Trigger the cleanup
- $this->runCleanup();
-
- // Update the last run time
- cache()->save('last_cleanup_run', Time::now());
- }
- }
-
- public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
- {
- // Do nothing after the request
- }
-
- private function runCleanup()
- {
- // Call the cleanup controller method
- \CodeIgniter\CLI\CLI::init();
- command('cleanup:unverified_users');
- }
-}
diff --git a/app/old/Filters/PermissionFilter.php b/app/old/Filters/PermissionFilter.php
deleted file mode 100644
index 69b77165..00000000
--- a/app/old/Filters/PermissionFilter.php
+++ /dev/null
@@ -1,31 +0,0 @@
-get('is_logged_in')) {
- return redirect()->to('/user/login');
- }
-
- $required = $arguments[0] ?? null;
- if ($required) {
- $perms = session('permissions') ?? [];
- if (! in_array($required, $perms, true)) {
- return redirect()->to('/')->with('error', 'Access denied.');
- }
- }
-
- return null;
- }
-
- public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
- {
- }
-}
diff --git a/app/old/Filters/TimezoneFilter.php b/app/old/Filters/TimezoneFilter.php
deleted file mode 100644
index f11b8cc7..00000000
--- a/app/old/Filters/TimezoneFilter.php
+++ /dev/null
@@ -1,26 +0,0 @@
-primeFromRequest($request);
- } catch (\Throwable $e) {
- // ignore; fallback detection will still work lazily
- }
- }
-
- public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
- {
- // No-op
- }
-}
-
diff --git a/app/old/FrontendController.php b/app/old/FrontendController.php
deleted file mode 100644
index 9a70350c..00000000
--- a/app/old/FrontendController.php
+++ /dev/null
@@ -1,84 +0,0 @@
- 'User not logged in']);
- exit;
- }
-
- $userId = $_SESSION['user_id'];
-
- // Fetch user data from the database
- $sql = "SELECT firstname, lastname FROM users WHERE id = ?";
- $stmt = $conn->prepare($sql);
- $stmt->bind_param("i", $userId);
- $stmt->execute();
- $result = $stmt->get_result();
- $user = $result->fetch_assoc();
-
- if ($user) {
- echo json_encode($user);
- } else {
- echo json_encode(['error' => 'User not found']);
- }
-
- $stmt->close();
- $conn->close();
- }
-}
\ No newline at end of file
diff --git a/app/old/HealthController.php b/app/old/HealthController.php
deleted file mode 100644
index 675a6968..00000000
--- a/app/old/HealthController.php
+++ /dev/null
@@ -1,75 +0,0 @@
- $label,
- 'path' => $path,
- 'exists' => is_dir($path),
- 'writable' => is_writable($path),
- ];
- }
-
- public function index()
- {
- $uploadsBase = rtrim(WRITEPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'uploads';
- $paths = [
- 'uploads' => $uploadsBase,
- 'uploads/reimbursements' => $uploadsBase . DIRECTORY_SEPARATOR . 'reimbursements',
- 'uploads/receipts' => $uploadsBase . DIRECTORY_SEPARATOR . 'receipts',
- 'uploads/checks' => $uploadsBase . DIRECTORY_SEPARATOR . 'checks',
- 'uploads/early_dismissal_signatures' => $uploadsBase . DIRECTORY_SEPARATOR . 'early_dismissal_signatures',
- ];
-
- $pathsStatus = [];
- foreach ($paths as $label => $p) {
- $pathsStatus[] = $this->checkPath($label, $p);
- }
-
- $db = \Config\Database::connect();
- $dbChecks = [
- 'user_preferences_exists' => $db->tableExists('user_preferences'),
- 'settings_exists' => $db->tableExists('settings'),
- 'migrations_exists' => $db->tableExists('migrations'),
- ];
-
- $dbChecks['user_preferences_has_timezone'] = $dbChecks['user_preferences_exists']
- ? $db->fieldExists('timezone', 'user_preferences')
- : false;
-
- $dbChecks['settings_has_timezone'] = $dbChecks['settings_exists']
- ? $db->fieldExists('timezone', 'settings')
- : false;
-
- $okPaths = array_reduce($pathsStatus, function ($carry, $row) {
- return $carry && $row['exists'] && $row['writable'];
- }, true);
-
- $okDb = true;
- if ($dbChecks['user_preferences_exists'] && !$dbChecks['user_preferences_has_timezone']) {
- $okDb = false;
- }
- if ($dbChecks['settings_exists'] && !$dbChecks['settings_has_timezone']) {
- $okDb = false;
- }
-
- $ok = $okPaths && $okDb;
-
- $payload = [
- 'ok' => $ok,
- 'paths' => $pathsStatus,
- 'database' => $dbChecks,
- 'write_path' => WRITEPATH,
- 'timestamp' => date('c'),
- ];
-
- $status = $ok ? 200 : 503;
- return $this->response->setStatusCode($status)->setJSON($payload);
- }
-}
diff --git a/app/old/InfoIconController.php b/app/old/InfoIconController.php
deleted file mode 100644
index 82e78d2a..00000000
--- a/app/old/InfoIconController.php
+++ /dev/null
@@ -1,39 +0,0 @@
-get('user_id');
-
- if (!$userId) {
- return view('errors/html/error_401', ['message' => 'User not logged in']);
- }
-
- $userModel = new UserModel();
- $user = $userModel->getUserInfoById($userId);
-
- if ($user) {
- $data = [
- 'userName' => $user['firstname'] . ' ' . $user['lastname'],
- 'userInitials' => strtoupper($user['firstname'][0] . $user['lastname'][0])
- ];
- } else {
- $data = [
- 'userName' => 'User not found',
- 'userInitials' => '??'
- ];
- }
-
- return view('partials/header', $data);
-// return view('dashboard', ['content' => view('partials/user_icon', $data)]);
-
- }
-}
\ No newline at end of file
diff --git a/app/old/IpBanController.php b/app/old/IpBanController.php
deleted file mode 100644
index ee963155..00000000
--- a/app/old/IpBanController.php
+++ /dev/null
@@ -1,148 +0,0 @@
-ipAttemptModel = new IpAttemptModel();
- helper(['url', 'form']);
- }
-
- public function index()
- {
- $now = utc_now();
- $status = strtolower(trim((string)($this->request->getGet('status') ?? 'all')));
-
- $builder = $this->ipAttemptModel->orderBy('updated_at', 'DESC');
- if ($status === 'active') {
- $builder = $builder->where('blocked_until >', $now);
- }
-
- $banned = $builder->findAll();
-
- return view('administrator/ip_bans', [
- 'banned' => $banned,
- 'status' => $status,
- 'now' => $now,
- ]);
- }
-
- public function unban()
- {
- $isAjax = $this->request->isAJAX();
- $json = function(array $p, int $code=200){
- $p['csrfTokenName'] = csrf_token();
- $p['csrfHash'] = csrf_hash();
- $p[csrf_token()] = csrf_hash();
- return $this->response->setStatusCode($code)->setJSON($p);
- };
-
- $id = (int) $this->request->getPost('id');
- $ip = trim((string) $this->request->getPost('ip'));
- $all = (int) $this->request->getPost('all') === 1;
-
- try {
- if ($all) {
- // Clear all active bans
- $now = utc_now();
- $builder = $this->ipAttemptModel->builder();
- $builder->where('blocked_until >', $now)
- ->set(['blocked_until' => null, 'attempts' => 0])
- ->update();
-
- $cnt = $this->ipAttemptModel->db->affectedRows();
- $msg = $cnt . ' IP(s) unbanned.';
- return $isAjax ? $json(['ok'=>true,'message'=>$msg,'count'=>$cnt])
- : redirect()->to(site_url('administrator/ip_bans'))->with('success', $msg);
- }
-
- $row = null;
- if ($id > 0) {
- $row = $this->ipAttemptModel->find($id);
- } elseif ($ip !== '') {
- $row = $this->ipAttemptModel->where('ip_address', $ip)->first();
- }
-
- if (!$row) {
- $msg = 'IP record not found.';
- return $isAjax ? $json(['ok'=>false,'message'=>$msg],404)
- : redirect()->back()->with('error', $msg);
- }
-
- $ok = $this->ipAttemptModel->update($row['id'], [
- 'blocked_until' => null,
- 'attempts' => 0,
- ]);
-
- if (!$ok) {
- throw new \RuntimeException('Failed to update record.');
- }
-
- $msg = 'IP ' . $row['ip_address'] . ' unbanned.';
- return $isAjax ? $json(['ok'=>true,'message'=>$msg])
- : redirect()->to(site_url('administrator/ip_bans'))->with('success', $msg);
- } catch (\Throwable $e) {
- $msg = 'Unable to unban: ' . $e->getMessage();
- return $isAjax ? $json(['ok'=>false,'message'=>$msg],500)
- : redirect()->back()->with('error', $msg);
- }
- }
-
- public function banNow()
- {
- $isAjax = $this->request->isAJAX();
- $json = function(array $p, int $code=200){
- $p['csrfTokenName'] = csrf_token();
- $p['csrfHash'] = csrf_hash();
- $p[csrf_token()] = csrf_hash();
- return $this->response->setStatusCode($code)->setJSON($p);
- };
-
- // Accept both POST and GET params
- $id = (int) ($this->request->getPost('id') ?? $this->request->getGet('id') ?? 0);
- $ip = trim((string) ($this->request->getPost('ip') ?? $this->request->getGet('ip') ?? ''));
- $hours = (int) ($this->request->getPost('hours') ?? $this->request->getGet('hours') ?? 24);
- if ($hours <= 0) $hours = 24;
-
- try {
- $row = null;
- if ($id > 0) {
- $row = $this->ipAttemptModel->find($id);
- } elseif ($ip !== '') {
- $row = $this->ipAttemptModel->where('ip_address', $ip)->first();
- }
-
- if (!$row) {
- $msg = 'IP record not found.';
- return $isAjax ? $json(['ok'=>false,'message'=>$msg],404)
- : redirect()->to(site_url('administrator/ip_bans'))->with('error', $msg);
- }
-
- $blockedUntil = date('Y-m-d H:i:s', strtotime("+{$hours} hours"));
- $ok = $this->ipAttemptModel->update($row['id'], [
- 'blocked_until' => $blockedUntil,
- // Optionally set attempts to threshold for clarity
- 'attempts' => max((int)$row['attempts'], 10),
- ]);
-
- if (!$ok) {
- throw new \RuntimeException('Failed to update record.');
- }
-
- $msg = 'IP ' . $row['ip_address'] . ' banned for ' . $hours . 'h.';
- return $isAjax ? $json(['ok'=>true,'message'=>$msg])
- : redirect()->to(site_url('administrator/ip_bans'))->with('success', $msg);
- } catch (\Throwable $e) {
- $msg = 'Unable to ban: ' . $e->getMessage();
- return $isAjax ? $json(['ok'=>false,'message'=>$msg],500)
- : redirect()->back()->with('error', $msg);
- }
- }
-}
diff --git a/app/old/LandingPageController.php b/app/old/LandingPageController.php
deleted file mode 100644
index f7a707cb..00000000
--- a/app/old/LandingPageController.php
+++ /dev/null
@@ -1,882 +0,0 @@
-db = \Config\Database::connect();
- $this->configModel = new ConfigurationModel();
- $this->studentClassModel = new StudentClassModel();
- $this->invoiceModel = new InvoiceModel();
- $this->userRoleModel = new UserRoleModel();
- $this->teacherClassModel = new TeacherClassModel();
- $this->semesterScoreModel = new SemesterScoreModel();
- $this->scoreCommentModel = new ScoreCommentModel();
- $this->attendanceRecordModel = new AttendanceRecordModel();
- $this->attendanceDayModel = new AttendanceDayModel();
- $this->classSectionModel = new ClassSectionModel();
- $this->calendarModel = new CalendarModel();
-
- // Fetch Enrollment and Refund Deadlines from Configuration
- $this->schoolYear = $this->configModel->getConfig('school_year');
- $this->semester = $this->configModel->getConfig('semester');
- $this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline') ?? 'Not set';
- $this->refundDeadline = $this->configModel->getConfig('refund_deadline') ?? 'Not set';
-
- // Get class_id and store it in session
- $this->classSectionId = $this->classSectionId();
- session()->set('class_section_id', $this->classSectionId);
- }
-
- public function index()
- {
- $userRole = $this->getUserRole(); // Assume this function gets the user role
-
- switch (strtolower($userRole)) {
- case 'administrator':
- return $this->administrator();
- case 'admin':
- return $this->admin();
- case 'teacher':
- return $this->teacher();
- case 'student':
- return $this->student();
- case 'parent':
- case 'authorized_user':
- return $this->parentDashboard();
- default:
- return $this->guest();
- }
- }
-
- public function classSectionId()
- {
- // Get user_id from the session
- $user_id = session()->get('user_id');
-
- if (!$user_id) {
- return null;
- }
-
- // Get all class assignments for this teacher in the current term
- $assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
- (int)$user_id,
- (string)$this->schoolYear,
- (string)$this->semester
- );
-
- $ids = array_map(static fn($a) => (int)($a['class_section_id'] ?? 0), $assignments);
- $ids = array_values(array_filter(array_unique($ids)));
-
- if (empty($ids)) {
- log_message('error', "No class section found for user ID: $user_id");
- return null;
- }
-
- $current = (int)(session()->get('class_section_id') ?? 0);
- $chosen = in_array($current, $ids, true) ? $current : $ids[0];
-
- session()->set([
- 'class_section_id' => $chosen,
- 'class_section_ids' => $ids,
- ]);
-
- return $chosen;
- }
-
-
- public function administrator()
- {
- return view('administrator/administratordashboard');
- }
-
- public function admin()
- {
- return view('/landing_page/admin_dashboard');
- }
-
- public function teacher()
- {
- $userId = (int)(session()->get('user_id') ?? 0);
- $assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
- $userId,
- (string)$this->schoolYear,
- (string)$this->semester
- );
-
- $availableIds = array_map(static fn($a) => (int)($a['class_section_id'] ?? 0), $assignments);
- $availableIds = array_values(array_filter(array_unique($availableIds)));
-
- $requestedId = (int)($this->request->getGet('class_section_id') ?? 0);
- $activeId = null;
-
- if ($requestedId && in_array($requestedId, $availableIds, true)) {
- $activeId = $requestedId;
- } elseif (!empty($availableIds)) {
- $activeId = in_array((int)$this->classSectionId, $availableIds, true)
- ? (int)$this->classSectionId
- : $availableIds[0];
- }
-
- if ($activeId) {
- session()->set('class_section_id', $activeId);
- }
-
- $activeName = null;
- foreach ($assignments as $a) {
- if ((int)$a['class_section_id'] === (int)$activeId) {
- $activeName = $a['class_section_name'];
- break;
- }
- }
-
- $normalizedSemester = ucfirst(strtolower(trim((string)($this->semester ?? ''))));
- $classSectionIds = array_values(array_filter(array_unique(array_map(static fn($a) => (int)($a['class_section_id'] ?? 0), $assignments)), static fn($id) => $id > 0));
- $dashboardSummary = $this->buildTeacherDashboardSummary($classSectionIds, $normalizedSemester);
-
- return view('/landing_page/teacher_dashboard', [
- 'class_section_id' => $activeId,
- 'active_class_name' => $activeName,
- 'classes' => $assignments,
- 'jobs' => $assignments,
- 'jobCount' => count($assignments),
- 'scoreSummary' => $dashboardSummary['scoreSummary'],
- 'commentSummary' => $dashboardSummary['commentSummary'],
- 'attendanceSummary' => $dashboardSummary['attendanceSummary'],
- 'deadlineEvents' => $dashboardSummary['deadlineEvents'],
- 'participationSummary' => $dashboardSummary['participationSummary'],
- 'attendanceStatus' => $dashboardSummary['attendanceStatus'],
- 'dashboardNotifications' => $dashboardSummary['notifications'],
- 'school_year' => (string)$this->schoolYear,
- 'semester' => (string)$this->semester,
- ]);
- }
-
- private function buildTeacherDashboardSummary(array $classSectionIds, string $semester): array
- {
- $normalizedSemester = ucfirst(strtolower(trim((string)$semester)));
- if ($normalizedSemester === '' || !in_array($normalizedSemester, ['Fall', 'Spring'], true)) {
- $normalizedSemester = 'Fall';
- }
-
- $scoreField = ($normalizedSemester === 'Spring') ? 'final_exam_score' : 'midterm_exam_score';
- $scoreLabel = ($normalizedSemester === 'Spring') ? 'Final Exam' : 'Midterm';
-
- $summary = [
- 'scoreSummary' => [
- 'completionPct' => 0,
- 'missing' => 0,
- 'expected' => 0,
- 'filled' => 0,
- 'fieldLabel' => $scoreLabel,
- ],
- 'commentSummary' => [
- 'total' => 0,
- 'pendingReview' => 0,
- 'reviewed' => 0,
- 'byType' => [],
- ],
- 'attendanceSummary' => [
- 'recorded' => 0,
- 'students' => 0,
- 'completionPct' => 0,
- 'avgAbsences' => 0,
- ],
- 'participationSummary' => [
- 'filled' => 0,
- 'missing' => 0,
- 'completionPct' => 0,
- 'expected' => 0,
- ],
- 'deadlineEvents' => [],
- ];
-
- if (!empty($classSectionIds)) {
- $studentRows = $this->db->table('student_class sc')
- ->select('sc.student_id, sc.class_section_id')
- ->join('students s', 's.id = sc.student_id', 'inner')
- ->whereIn('sc.class_section_id', $classSectionIds)
- ->where('s.is_active', 1)
- ->where('sc.school_year', (string)$this->schoolYear)
- ->get()
- ->getResultArray();
-
- $studentIds = [];
- foreach ($studentRows as $row) {
- $studentId = (int)($row['student_id'] ?? 0);
- $sectionId = (int)($row['class_section_id'] ?? 0);
- if ($studentId <= 0 || $sectionId <= 0) {
- continue;
- }
- $studentIds[] = $studentId;
- }
- $uniqueStudentIds = array_values(array_unique($studentIds));
- } else {
- $uniqueStudentIds = [];
- }
-
- $totalStudents = count($uniqueStudentIds);
- $summary['attendanceSummary']['students'] = $totalStudents;
-
- $scoreRows = [];
- if (!empty($classSectionIds)) {
- $scoreRows = $this->semesterScoreModel
- ->select('student_id, class_section_id, midterm_exam_score, final_exam_score, participation_score')
- ->whereIn('class_section_id', $classSectionIds)
- ->where('semester', $normalizedSemester)
- ->where('school_year', (string)$this->schoolYear)
- ->findAll();
- }
-
- $scoreTypesConfig = [
- 'participation' => ['label' => 'Participation', 'field' => 'participation_score'],
- ];
- if ($normalizedSemester === 'Fall') {
- $scoreTypesConfig = array_merge([
- 'midterm' => ['label' => 'Midterm', 'field' => 'midterm_exam_score'],
- ], $scoreTypesConfig);
- }
- if ($normalizedSemester === 'Spring') {
- $scoreTypesConfig = array_merge([
- 'final' => ['label' => 'Final exam', 'field' => 'final_exam_score'],
- ], $scoreTypesConfig);
- }
-
- $filledCounts = array_fill_keys(array_keys($scoreTypesConfig), 0);
- foreach ($scoreRows as $row) {
- foreach ($scoreTypesConfig as $key => $cfg) {
- $value = trim((string)($row[$cfg['field']] ?? ''));
- if ($value !== '') {
- $filledCounts[$key]++;
- }
- }
- }
-
- $expectedScoreCount = $totalStudents > 0 ? $totalStudents : count($scoreRows);
- $scoreDetails = [];
- foreach ($scoreTypesConfig as $key => $cfg) {
- $filled = $filledCounts[$key] ?? 0;
- $missing = max(0, $expectedScoreCount - $filled);
- $completionPct = $expectedScoreCount > 0 ? (int)round(min(100, ($filled / $expectedScoreCount) * 100)) : 0;
- $scoreDetails[$key] = [
- 'label' => $cfg['label'],
- 'field' => $cfg['field'],
- 'filled' => $filled,
- 'missing' => $missing,
- 'expected' => $expectedScoreCount,
- 'completionPct' => $completionPct,
- ];
- }
-
- $scoreTypeByField = [];
- if ($normalizedSemester === 'Fall') {
- $scoreTypeByField['midterm_exam_score'] = 'midterm';
- }
- if ($normalizedSemester === 'Spring') {
- $scoreTypeByField['final_exam_score'] = 'final';
- }
- $activeScoreType = $scoreTypeByField[$scoreField] ?? 'midterm';
- $activeScoreDetails = $scoreDetails[$activeScoreType] ?? $scoreDetails['midterm'];
-
- $summary['scoreSummary'] = [
- 'completionPct' => $activeScoreDetails['completionPct'],
- 'missing' => $activeScoreDetails['missing'],
- 'expected' => $activeScoreDetails['expected'],
- 'filled' => $activeScoreDetails['filled'],
- 'fieldLabel' => $scoreLabel,
- 'types' => $scoreDetails,
- ];
-
- $participationMetrics = $scoreDetails['participation'];
- $summary['participationSummary'] = [
- 'filled' => $participationMetrics['filled'],
- 'missing' => $participationMetrics['missing'],
- 'completionPct' => $participationMetrics['completionPct'],
- 'expected' => $participationMetrics['expected'],
- ];
-
- if (!empty($uniqueStudentIds)) {
- $commentRows = $this->scoreCommentModel
- ->select('student_id, score_type, comment, comment_review')
- ->whereIn('student_id', $uniqueStudentIds)
- ->where('semester', $normalizedSemester)
- ->where('school_year', (string)$this->schoolYear)
- ->findAll();
- } else {
- $commentRows = [];
- }
-
- $expectedCommentTypes = array_values(array_filter([
- 'ptap',
- $normalizedSemester === 'Fall' ? 'midterm' : null,
- $normalizedSemester === 'Spring' ? 'final' : null,
- ]));
- $commentStats = [];
- $commentsByStudent = [];
-
- foreach ($commentRows as $row) {
- $sid = (int)($row['student_id'] ?? 0);
- $type = strtolower(trim((string)($row['score_type'] ?? '')));
- if ($type === '') {
- $type = 'general';
- }
- if (!isset($commentStats[$type])) {
- $commentStats[$type] = ['pending' => 0, 'reviewed' => 0];
- }
-
- $commentText = trim((string)($row['comment'] ?? ''));
- $reviewValue = trim((string)($row['comment_review'] ?? ''));
-
- if ($sid > 0 && $commentText !== '') {
- $commentsByStudent[$sid][$type] = $commentText;
- }
-
- if ($commentText === '') {
- continue;
- }
-
- if ($reviewValue === '') {
- $commentStats[$type]['pending']++;
- } else {
- $commentStats[$type]['reviewed']++;
- }
- }
-
- foreach ($expectedCommentTypes as $type) {
- if (!isset($commentStats[$type])) {
- $commentStats[$type] = ['pending' => 0, 'reviewed' => 0];
- }
- }
-
- $missingComments = 0;
- $missingDetail = [];
- foreach ($uniqueStudentIds as $sid) {
- foreach ($expectedCommentTypes as $type) {
- $text = $commentsByStudent[$sid][$type] ?? '';
- if ($text === '') {
- $missingDetail[$type] = ($missingDetail[$type] ?? 0) + 1;
- $missingComments++;
- }
- }
- }
-
- $commentTypes = [];
- foreach ($expectedCommentTypes as $type) {
- $stats = $commentStats[$type];
- $filled = $stats['pending'] + $stats['reviewed'];
- $completionPct = $totalStudents > 0 ? (int)round(min(100, ($filled / $totalStudents) * 100)) : 0;
-
- $commentTypes[$type] = [
- 'label' => ucfirst($type) . ' comments',
- 'pending' => $stats['pending'],
- 'reviewed' => $stats['reviewed'],
- 'filled' => $filled,
- 'missing' => $missingDetail[$type] ?? 0,
- 'expected' => $totalStudents,
- 'completionPct' => $completionPct,
- ];
- }
-
- $totalPending = array_sum(array_column($commentTypes, 'pending'));
- $totalReviewed = array_sum(array_column($commentTypes, 'reviewed'));
- $totalFilled = array_sum(array_column($commentTypes, 'filled'));
- $totalExpected = array_sum(array_column($commentTypes, 'expected'));
- $commentCompletionPct = $totalExpected > 0 ? (int)round(min(100, ($totalFilled / $totalExpected) * 100)) : 0;
-
- $summary['commentSummary'] = [
- 'total' => $totalFilled,
- 'pendingReview' => $totalPending,
- 'reviewed' => $totalReviewed,
- 'missing' => $missingComments,
- 'missingDetail' => $missingDetail,
- 'completionPct' => $commentCompletionPct,
- 'types' => $commentTypes,
- ];
-
- $attendanceRows = [];
- if (!empty($classSectionIds)) {
- $attendanceRows = $this->attendanceRecordModel
- ->select('student_id, class_section_id, total_absence')
- ->whereIn('class_section_id', $classSectionIds)
- ->where('semester', $normalizedSemester)
- ->where('school_year', (string)$this->schoolYear)
- ->findAll();
- }
-
- $attendanceSet = [];
- $totalAbsences = 0;
- foreach ($attendanceRows as $record) {
- $studentId = (int)($record['student_id'] ?? 0);
- $sectionId = (int)($record['class_section_id'] ?? 0);
- if ($studentId <= 0 || $sectionId <= 0) {
- continue;
- }
- $key = "{$sectionId}:{$studentId}";
- $attendanceSet[$key] = true;
- $totalAbsences += (int)($record['total_absence'] ?? 0);
- }
-
- $attendanceRecorded = count($attendanceSet);
- $attendancePct = $totalStudents > 0 ? (int)round(min(100, ($attendanceRecorded / $totalStudents) * 100)) : 0;
- $avgAbsence = $attendanceRecorded > 0 ? round($totalAbsences / $attendanceRecorded, 1) : 0;
-
- $summary['attendanceSummary'] = [
- 'recorded' => $attendanceRecorded,
- 'students' => $totalStudents,
- 'completionPct' => $attendancePct,
- 'avgAbsences' => $avgAbsence,
- ];
- $timezone = new DateTimeZone(date_default_timezone_get() ?: 'UTC');
- $today = (new DateTimeImmutable('now', $timezone))->format('Y-m-d');
-
- $attendanceStatus = $this->buildAttendanceSubmissionStatus($classSectionIds, $normalizedSemester, $today);
-
- $summary['deadlineEvents'] = $this->calendarModel
- ->where('notify_teacher', 1)
- ->where('school_year', (string)$this->schoolYear)
- ->where('semester', $normalizedSemester)
- ->where('date >=', $today)
- ->orderBy('date', 'ASC')
- ->limit(5)
- ->findAll();
-
- $summary['attendanceStatus'] = $attendanceStatus;
- if ($attendanceStatus['submitted'] === false && !empty($classSectionIds)) {
- $summary['attendanceSummary']['completionPct'] = 0;
- $summary['attendanceSummary']['recorded'] = 0;
- }
-
- $summary['notifications'] = $this->buildDashboardNotifications($summary);
-
- return $summary;
- }
-
- private function buildDashboardNotifications(array $summary): array
- {
- $notifications = [];
- $baseScoreLink = base_url('/teacher/scores');
- $buildScoreLink = static function (?string $focus) use ($baseScoreLink) {
- if (empty($focus)) {
- return $baseScoreLink;
- }
- return $baseScoreLink . '?focus=' . urlencode($focus);
- };
- $determineFocus = static function (array $event): string {
- $eventTypeText = strtolower(trim((string)($event['event_type'] ?? '')));
- if ($eventTypeText !== '' && str_contains($eventTypeText, 'draft')) {
- return 'comments';
- }
- $text = strtolower(trim(($event['title'] ?? '') . ' ' . ($event['description'] ?? '')));
- if ($text === '') {
- return 'scores';
- }
- if (str_contains($text, 'comment') || str_contains($text, 'ptap')) {
- return 'comments';
- }
- return 'scores';
- };
- $calendarLink = base_url('/teacher/calendar');
- $attendanceLink = base_url('/teacher/showupdate_attendance');
- $timezone = new DateTimeZone(date_default_timezone_get() ?: 'UTC');
- $todayDate = (new DateTimeImmutable('now', $timezone))->setTime(0, 0);
- $today = $todayDate->format('Y-m-d');
-
- $calendarNotificationConfig = [
- '1st Semester Scores' => 14,
- '2nd Semester Scores' => 14,
- 'Final' => 14,
- 'Final Exam Draft' => 42,
- 'Midterm' => 14,
- 'Midterm Exam Draft' => 42,
- ];
-
- $activeDeadline = false;
- foreach ($summary['deadlineEvents'] ?? [] as $event) {
- $eventTypeRaw = trim((string)($event['event_type'] ?? ''));
- if ($eventTypeRaw === '') {
- continue;
- }
- $matchedType = null;
- $thresholdDays = null;
- foreach ($calendarNotificationConfig as $type => $days) {
- if (strcasecmp($eventTypeRaw, $type) === 0) {
- $matchedType = $type;
- $thresholdDays = $days;
- break;
- }
- }
- if ($matchedType === null || $thresholdDays === null) {
- continue;
- }
- $rawDate = $event['date'] ?? '';
- if ($rawDate === '') {
- continue;
- }
- try {
- $eventDateTime = new DateTimeImmutable($rawDate, $timezone);
- } catch (\Exception $e) {
- continue;
- }
- $eventDateTime = $eventDateTime->setTime(0, 0);
- $daysUntil = (int)$todayDate->diff($eventDateTime)->format('%r%a');
-
- if ($daysUntil <= 0) {
- $activeDeadline = true;
- $eventFocus = $determineFocus($event);
- $notifications[] = [
- 'type' => 'danger',
- 'message' => sprintf(
- "Deadline today: %s is due. Submit the remaining entries before the day ends.",
- $matchedType
- ),
- 'link' => $buildScoreLink($eventFocus),
- 'linkText' => 'Submit now',
- ];
- continue;
- }
-
- if ($daysUntil > $thresholdDays) {
- continue;
- }
-
- $relativeText = $daysUntil === 1 ? ' (tomorrow)' : " (in {$daysUntil} days)";
- $notifications[] = [
- 'type' => 'warning',
- 'message' => sprintf(
- "%s is due on %s%s. Review it on the calendar so you don’t miss it.",
- $matchedType,
- $eventDateTime->format('M j, Y'),
- $relativeText
- ),
- 'link' => $calendarLink,
- 'linkText' => 'View deadline',
- ];
- }
-
- if (!$activeDeadline) {
- $scoreLabel = $summary['scoreSummary']['fieldLabel'] ?? 'Score';
- if (!empty($summary['scoreSummary']['missing'] ?? 0)) {
- $notifications[] = [
- 'type' => 'warning',
- 'message' => sprintf(
- "You have %s %s entries missing on the scores page. Please finish submitting them.",
- (int)$summary['scoreSummary']['missing'],
- $scoreLabel
- ),
- 'link' => $buildScoreLink('scores'),
- 'linkText' => 'Score sheet',
- ];
- }
-
- foreach (['midterm', 'final', 'ptap'] as $type) {
- $count = (int)($summary['commentSummary']['missingDetail'][$type] ?? 0);
- if ($count <= 0) {
- continue;
- }
- $notifications[] = [
- 'type' => 'warning',
- 'message' => sprintf(
- "%s comment missing for %s student(s). Leave comments on the scores page.",
- ucfirst($type),
- $count
- ),
- 'link' => $buildScoreLink('comments'),
- 'linkText' => ucfirst($type) . ' comments',
- ];
- }
-
- if (!empty($summary['participationSummary']['missing'] ?? 0)) {
- $notifications[] = [
- 'type' => 'warning',
- 'message' => sprintf(
- "Participation entries are still missing for %s student(s). Please add them from the scores page.",
- (int)$summary['participationSummary']['missing']
- ),
- 'link' => $buildScoreLink('scores'),
- 'linkText' => 'Participation',
- ];
- }
- }
-
- if (!empty($summary['attendanceStatus']['missingSections'] ?? [])) {
- $missingNames = $summary['attendanceStatus']['missingNames'] ?? [];
- $label = '';
- if (!empty($missingNames)) {
- $label = ' (' . implode(', ', array_slice($missingNames, 0, 3)) . (count($missingNames) > 3 ? '…' : '') . ')';
- }
- $notifications[] = [
- 'type' => 'danger',
- 'message' => "Today's attendance is still unsubmitted for {$summary['attendanceStatus']['date']}{$label}. Please submit it now.",
- 'link' => $attendanceLink,
- 'linkText' => 'Record attendance',
- ];
- }
-
- return $notifications;
- }
-
- private function buildAttendanceSubmissionStatus(array $classSectionIds, string $semester, string $date): array
- {
- if (empty($classSectionIds)) {
- return [
- 'date' => $date,
- 'missingSections' => [],
- 'missingNames' => [],
- 'submitted' => true,
- ];
- }
-
- $rows = $this->attendanceDayModel
- ->select('class_section_id, status')
- ->whereIn('class_section_id', $classSectionIds)
- ->where('date', $date)
- ->where('semester', $semester)
- ->where('school_year', (string)$this->schoolYear)
- ->findAll();
-
- $statusMap = [];
- foreach ($rows as $row) {
- $csId = (int)($row['class_section_id'] ?? 0);
- if ($csId <= 0) {
- continue;
- }
- $statusMap[$csId] = strtolower(trim((string)($row['status'] ?? '')));
- }
-
- $missingSections = [];
- foreach ($classSectionIds as $classSectionId) {
- $status = $statusMap[$classSectionId] ?? '';
- if (!in_array($status, ['submitted', 'published', 'finalized'], true)) {
- $missingSections[] = $classSectionId;
- }
- }
-
- $missingNames = [];
- if (!empty($missingSections)) {
- $sectionRows = $this->classSectionModel
- ->select('class_section_id, class_section_name')
- ->whereIn('class_section_id', $missingSections)
- ->findAll();
- foreach ($sectionRows as $row) {
- $name = trim((string)($row['class_section_name'] ?? ''));
- if ($name === '') {
- $name = (string)($row['class_section_id'] ?? '');
- }
- $missingNames[] = $name;
- }
- }
-
- return [
- 'date' => $date,
- 'missingSections' => $missingSections,
- 'missingNames' => $missingNames,
- 'submitted' => empty($missingSections),
- ];
- }
-
-
- public function student()
- {
- return view('/landing_page/student_dashboard');
- }
-
- public function parentDashboard()
- {
- $parentId = session()->get('user_id');
- $userType = $_SESSION['user_type'];
-
- // Map user type to roles
- if ($userType === 'primary') {
- // If user type is 'Primary', they are the firstparent
- $parentId = $parentId;
- } elseif ($userType === 'secondary') {
- // If user type is 'Secondary', find the parent_id from the parents table
- $parentData = $this->db->table('parents')
- ->select('parent_id')
- ->where('secondparent_user_id', $parentId)
- ->get()
- ->getRowArray();
-
- if ($parentData) {
- $parentId = $parentData['parent_id'];
- }
- } elseif ($userType === 'tertiary') {
- // If user type is 'Tertiary', find the parent_id from the authorized_users table
- $authUserData = $this->db->table('authorized_users')
- ->select('user_id as parent_id')
- ->where('authorized_user_id', $parentId)
- ->get()
- ->getRowArray();
-
- if ($authUserData) {
- $parentId = $authUserData['parent_id'];
- }
- }
-
- // If no firstparent ID is found, show an error or redirect
- if (!$parentId) {
- return redirect()->back()->with(
- 'error',
- 'Unable to retrieve student data. Please contact support.'
- );
- }
-
-
- // Fetch Notifications (only active, non-expired, non-deleted)
- $notifications = $this->db->table('notifications')
- ->select([
- 'notifications.id',
- 'notifications.title',
- 'notifications.message',
- 'notifications.target_group',
- 'notifications.created_at',
- 'notifications.expires_at',
- 'user_notifications.user_id',
- "CASE
- WHEN user_notifications.user_id IS NOT NULL THEN 'personal'
- ELSE 'broadcast'
- END as notification_type"
- ])
- ->join(
- 'user_notifications',
- 'user_notifications.notification_id = notifications.id AND user_notifications.user_id = ' . (int) $parentId,
- 'left'
- )
- ->groupStart()
- ->where('notifications.target_group', 'parent')
- ->orWhere('user_notifications.user_id', $parentId)
- ->groupEnd()
- ->where('notifications.deleted_at IS NULL') // Exclude soft-deleted notifications
- ->groupStart()
- ->where('notifications.expires_at IS NULL')
- ->orWhere('notifications.expires_at > NOW()') // Exclude expired
- ->groupEnd()
- ->orderBy('notifications.created_at', 'DESC')
- ->get()
- ->getResultArray();
-
- // Fetch Student Information (no filtering needed by school year or semester)
-
- $students = $this->db->table('students')
- ->where('parent_id', $parentId)
- ->get()
- ->getResultArray();
-
- foreach ($students as &$student) {
- // Get class_section_name and treat it as grade
- $classSection = $this->studentClassModel->getClassSectionsByStudentId($student['id'], $this->schoolYear);
- $student['class_section'] = $classSection;
- $student['grade'] = $classSection; // grade same as section
- }
- unset($student);
-
-
- // Fetch Attendance Records (filtered by most recent school year and semester)
- $attendanceData = $this->db->table('attendance_data')
- ->select('attendance_data.*, students.firstname, students.lastname')
- ->join('students', 'students.id = attendance_data.student_id')
- ->where('students.parent_id', $parentId)
- // ->orWhere('students.secondparent_user_id', $parentId)
- ->where('attendance_data.school_year', $this->schoolYear)
- ->where('attendance_data.semester', $this->semester)
- ->orderBy('attendance_data.date', 'DESC')
- ->get()
- ->getResultArray();
-
- // Fetch Grades (filtered by most recent school year and semester)
- $grades = $this->db->table('final_score') // Correct table name
- ->select('final_score.*, students.firstname, students.lastname') // Ensure table name is correct
- ->join('students', 'students.id = final_score.student_id')
- ->where('students.parent_id', $parentId)
- // ->orWhere('students.secondparent_user_id', $parentId)
- ->where('final_score.school_year', $this->schoolYear) // Ensure correct table column names
- ->where('final_score.semester', $this->semester)
- ->orderBy('final_score.created_at', 'DESC')
- ->get()
- ->getResultArray();
-
- // Fetch Enrollments (filtered by most recent school year and semester)
- $enrollments = $this->db->table('enrollments')
- ->select('enrollments.*, classes.class_name')
- ->join('students', 'students.id = enrollments.student_id')
- ->join('classes', 'classes.id = enrollments.class_section_id') // Ensure this join is correct
- ->where('students.parent_id', $parentId)
- // ->orWhere('students.secondparent_user_id', $parentId)
- ->where('enrollments.school_year', $this->schoolYear)
- ->where('enrollments.semester', $this->semester)
- ->orderBy('enrollments.enrollment_date', 'DESC')
- ->get()
- ->getResultArray();
-
- // Fetch latest invoice balance
- $paymentBalance = $this->invoiceModel->getLatestInvoiceTotalAmount($parentId);
-
- // Pass data to the view, including the deadlines
- return view('/landing_page/parent_dashboard', [
- 'notifications' => $notifications,
- 'students' => $students,
- 'attendance' => $attendanceData,
- 'grades' => $grades,
- 'enrollments' => $enrollments,
- 'lastDayOfRegistration' => $this->lastDayOfRegistration, // Add the enrollment deadline to the view
- 'withdrawalDeadline' => $this->refundDeadline, // Add the refund deadline to the view
- 'paymentBalance' => $paymentBalance, // 🔹 New
- ]);
- }
-
- public function guest()
- {
- return view('/landing_page/guest_dashboard');
- }
-
- protected function getUserRole()
- {
- // Assuming you have a session variable storing the user role
- return session()->get('user_role', 'guest'); // Default to guest if not set
- }
-
- protected function getUserRoleFromDatabase($user_id)
- {
- // Fetching user role from the database
- $role = $this->userRoleModel->getRoleByUserId($user_id);
-
- return $role ?? 'guest';
- }
-}
diff --git a/app/old/LateSlipLogsController.php b/app/old/LateSlipLogsController.php
deleted file mode 100644
index 443442d9..00000000
--- a/app/old/LateSlipLogsController.php
+++ /dev/null
@@ -1,63 +0,0 @@
-logModel = new LateSlipLogModel();
- $this->configModel = new ConfigurationModel();
- }
-
- public function index()
- {
- $req = $this->request;
-
- $defaultYear = (string) ($this->configModel->getConfig('school_year') ?? '');
- $defaultSem = (string) ($this->configModel->getConfig('semester') ?? '');
- $schoolYear = trim((string) ($req->getGet('school_year') ?? $defaultYear));
- $semester = trim((string) ($req->getGet('semester') ?? $defaultSem));
- $q = trim((string) ($req->getGet('q') ?? ''));
- $dateFrom = trim((string) ($req->getGet('date_from') ?? ''));
- $dateTo = trim((string) ($req->getGet('date_to') ?? ''));
-
- // Normalize dates to Y-m-d for filtering
- $toDbDate = static function (?string $s): ?string {
- $s = trim((string) $s);
- if ($s === '') return null;
- $ts = strtotime($s);
- return $ts ? date('Y-m-d', $ts) : null;
- };
- $df = $toDbDate($dateFrom);
- $dt = $toDbDate($dateTo);
-
- $model = $this->logModel;
- if ($schoolYear !== '') $model = $model->where('school_year', $schoolYear);
- if ($semester !== '') $model = $model->where('semester', $semester);
- if ($q !== '') $model = $model->like('student_name', $q);
- if ($df) $model = $model->where('slip_date >=', $df);
- if ($dt) $model = $model->where('slip_date <=', $dt);
-
- // Limit to most recent 200 entries
- $logs = $model->orderBy('id', 'DESC')->findAll(200);
-
- return view('administrator/late_slip_logs', [
- 'schoolYear' => $schoolYear,
- 'semester' => $semester,
- 'filters' => [
- 'q' => $q,
- 'date_from' => $dateFrom,
- 'date_to' => $dateTo,
- ],
- 'logs' => $logs,
- ]);
- }
-}
diff --git a/app/old/MessagesController.php b/app/old/MessagesController.php
deleted file mode 100644
index 4849191d..00000000
--- a/app/old/MessagesController.php
+++ /dev/null
@@ -1,249 +0,0 @@
-messageModel = new MessageModel();
- }
-
- public function inbox()
- {
- $userId = session()->get('user_id');
- $receivedMessages = $this->messageModel->getInboxMessages($userId);
-
- return view('messages/inbox', [
- 'receivedMessages' => $receivedMessages
- ]);
- }
-
- public function sent()
- {
- $userId = session()->get('user_id');
- $sentMessages = $this->messageModel->getSentMessages($userId);
-
- return view('messages/sent', [
- 'sentMessages' => $sentMessages
- ]);
- }
-
- public function drafts()
- {
- $userId = session()->get('user_id');
- $draftMessages = $this->messageModel->getDraftMessages($userId);
-
- return view('messages/drafts', [
- 'draftMessages' => $draftMessages
- ]);
- }
-
- public function trash()
- {
- $userId = session()->get('user_id');
- $trashedMessages = $this->messageModel->getTrashedMessages($userId);
-
- return view('messages/trash', [
- 'trashedMessages' => $trashedMessages
- ]);
- }
-
- public function index()
- {
- $userRoleModel = new UserRoleModel();
- //$role = session()->get('role');
- $userId = session()->get('user_id');
- // Fetch the user role from the user_roles and roles tables
- $role = $userRoleModel->select('roles.name')
- ->join('roles', 'roles.id = user_roles.role_id')
- ->where('user_roles.user_id', $userId)
- ->get()
- ->getRowArray();
-
- // Fetch received messages based on role
- $db = \Config\Database::connect();
- $builder = $db->table('messages');
- $builder->select('messages.*, users.firstname AS sender_name');
- $builder->join('users', 'messages.sender_id = users.id');
- $builder->where('messages.recipient_id', $userId);
- $query = $builder->get();
- $receivedMessages = $query->getResultArray();
-
- return view('/messages', [
- 'receivedMessages' => $receivedMessages,
- 'role' => $role['name']
- ]);
- }
-
- public function send()
- {
- $db = \Config\Database::connect();
- $userRoleModel = new UserRoleModel();
- //$role = session()->get('role');
- $userId = session()->get('user_id');
- // Fetch the user role from the user_roles and roles tables
- $role = $userRoleModel->select('roles.name')
- ->join('roles', 'roles.id = user_roles.role_id')
- ->where('user_roles.user_id', $userId)
- ->get()
- ->getRowArray();
-
- // Handle file upload (if any)
- $attachmentPath = null;
- $file = $this->request->getFile('attachment');
- if ($file && $file->isValid() && !$file->hasMoved()) {
- $attachmentPath = $file->store();
- }
-
- // Determine recipient based on role or input
- $recipientId = $this->getRecipientId($role);
-
- // Prepare data for insertion
- $data = [
- 'sender_id' => $userId,
- 'recipient_id' => $recipientId,
- 'subject' => $this->request->getPost('subject'),
- 'message' => $this->request->getPost('message'),
- 'sent_datetime' => utc_now(),
- 'message_number' => $this->generateMessageNumber(),
- 'priority' => $this->request->getPost('priority') ?: 'normal',
- 'attachment' => $attachmentPath,
- 'status' => 'sent'
- ];
-
- $builder = $db->table('messages');
- $builder->insert($data);
-
- return redirect()->to(base_url('messages'))->with('status', 'Message sent successfully');
- }
-
- public function receive()
- {
- $userId = session()->get('user_id');
-
- $db = \Config\Database::connect();
- $builder = $db->table('messages');
- $builder->select('messages.*, users.firstname AS sender_name');
- $builder->join('users', 'messages.sender_id = users.id');
- $builder->where('messages.recipient_id', $userId);
- $builder->orderBy('sent_datetime', 'DESC');
- $query = $builder->get();
- $receivedMessages = $query->getResultArray();
-
- // Mark messages as read
- foreach ($receivedMessages as $message) {
- if ($message['read_status'] == 0) {
- $this->markAsRead($message['id']);
- }
- }
- // Assuming $role['name'] contains the role name
- //$roleName = strtolower($role['name']); // Convert role name to lowercase if needed
-
- // Construct the view path dynamically
- // $viewPath = '/' . $roleName . '/' . $roleName . '_messages';
-
- // Return the view
- //return view($viewPath, ['receivedMessages' => $receivedMessages]);
- }
-
- private function generateMessageNumber()
- {
- return 'MSG-' . date('YmdHis');
- }
-
- private function markAsRead($messageId)
- {
- $db = \Config\Database::connect();
- $builder = $db->table('messages');
- $builder->where('id', $messageId);
- $builder->update([
- 'read_status' => 1,
- 'read_datetime' => utc_now()
- ]);
- }
-
- private function getRecipientId($role)
- {
- // You can customize this logic to retrieve recipient_id based on the role or input
- switch (strtolower($role['name'])) {
- case 'teacher':
- return 1; // Example: return teacher's ID
- case 'parent':
- return 2; // Example: return parent's ID
- case 'admin':
- return 3; // Example: return admin's ID
- case 'student':
- return 4; // Example: return student's ID
- case 'guest':
- return 5; // Example: return guest's ID
- case 'administrator':
- return 6; // Example: return administrator's ID
- default:
- throw new \Exception('Invalid role: ' . $role['name']);
- }
- }
-
- public function getRecipients($type)
- {
- $recipients = [];
- $teacherClassModel = new TeacherClassModel();
- $studentClassModel = new StudentClassModel();
- $studentsModel = new StudentModel();
- $teacherClasses = $teacherClassModel->findAll();
-
- if ($type === 'teacher') {
- $userModel = new UserModel(); // Assuming the UserModel is in the App\Models namespace
-
- foreach ($teacherClasses as $teacher) {
- $user = $userModel->find($teacher['teacher_id']);
- if ($user) {
- $recipients[] = [
- 'id' => $teacher['teacher_id'],
- 'name' => $user['firstname'] . ' ' . $user['lastname']
- ];
- }
- }
- } elseif ($type === 'parent') {
- foreach ($teacherClasses as $class) {
- $students = $studentClassModel
- ->active()
- ->where('student_class.class_section_id', $class['class_section_id'])
- ->findAll();
-
- foreach ($students as $student) {
- $studentData = $studentsModel
- ->where('id', $student['student_id'])
- ->where('is_active', 1)
- ->first();
- if ($studentData) {
- if ($studentData['firstparent']) {
- $recipients[] = [
- 'id' => $studentData['parent_id'],
- 'name' => $studentData['firstparent']
- ];
- }
- if ($studentData['secondparent']) {
- $recipients[] = [
- 'id' => $studentData['secondparent_user_id'],
- 'name' => $studentData['secondparent']
- ];
- }
- }
- }
- }
- }
-
- return $this->response->setJSON($recipients);
- }
-}
diff --git a/app/old/NavBuilderController.php b/app/old/NavBuilderController.php
deleted file mode 100644
index 6493f216..00000000
--- a/app/old/NavBuilderController.php
+++ /dev/null
@@ -1,303 +0,0 @@
-items = new NavItemModel();
- $this->maps = new RoleNavItemModel();
- $this->service = new NavbarService();
- }
-
-protected function ensureAdmin(): void
-{
- $sessionRole = session()->get('role'); // could be a string or array in your app
- $roleNames = is_array($sessionRole) ? $sessionRole : [$sessionRole];
- $roleNames = array_values(array_filter(array_map('strval', $roleNames)));
-
- if (empty($roleNames)) {
- throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
- }
-
- $db = \Config\Database::connect();
-
- // Map role names -> ids
- $roleIdRows = $db->table('roles')->select('id')->whereIn('name', $roleNames)->get()->getResultArray();
- $roleIds = array_map('intval', array_column($roleIdRows, 'id'));
- if (empty($roleIds)) {
- throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
- }
-
- // Is this route allowed for any of the user's roles?
- $allowed = $db->table('role_nav_items AS rni')
- ->select('1')
- ->join('nav_items AS ni', 'ni.id = rni.nav_item_id')
- ->where('ni.url', 'nav-builder') // IMPORTANT: your current route path
- ->whereIn('rni.role_id', $roleIds)
- ->get(1)->getFirstRow();
-
- if (!$allowed) {
- // You can show a nicer "Access Denied" view if you prefer
- throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
- }
-}
-
- public function index(){
- $this->ensureAdmin();
-
- helper(['url', 'form']);
-
- return view('nav_builder/index');
-}
-
-/** Case-insensitive, natural sort; ignores leading punctuation & articles (“a/an/the”) */
-private function labelKey(string $s): string
-{
- $s = trim(preg_replace('/\s+/', ' ', $s));
- $s = preg_replace('/^[^[:alnum:]]+/u', '', $s); // strip leading punctuation
- $s = preg_replace('/^(?i)(the|an|a)\s+/', '', $s); // drop leading articles
- return mb_strtolower($s, 'UTF-8');
-}
-
-private function sortTreeAlpha(array &$nodes): void
-{
- usort($nodes, fn($a,$b) => strnatcasecmp(
- $this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')
- ));
- foreach ($nodes as &$n) {
- if (!empty($n['children'])) {
- $this->sortTreeAlpha($n['children']);
- }
- }
- unset($n);
-}
-
-
-public function save()
-{
- $this->ensureAdmin();
-
- $id = (int) $this->request->getPost('id');
-
- // Accept either "menu_parent_id" or "parent_id" from the form
- $menuParentRaw = $this->request->getPost('menu_parent_id');
- if ($menuParentRaw === null) {
- $menuParentRaw = $this->request->getPost('parent_id');
- }
-
- $menuParentId = ($menuParentRaw === '' || $menuParentRaw === null) ? null : (int) $menuParentRaw;
- if ($id && $menuParentId === $id) {
- // item cannot be its own parent
- $menuParentId = null;
- }
-
- // Validate parent exists (optional but helpful)
- if ($menuParentId !== null) {
- $parent = $this->items->select('id')->where('id', $menuParentId)->first();
- if (!$parent) {
- return redirect()->back()->with('error', 'Selected parent does not exist.')->withInput();
- }
- }
-
- $data = [
- 'menu_parent_id' => $menuParentId,
- 'label' => trim((string) $this->request->getPost('label')),
- 'url' => trim((string) $this->request->getPost('url')) ?: null,
- 'icon_class' => trim((string) $this->request->getPost('icon_class')) ?: null,
- 'target' => trim((string) $this->request->getPost('target')) ?: null,
- 'sort_order' => (int) $this->request->getPost('sort_order'),
- 'is_enabled' => (int) ($this->request->getPost('is_enabled') ? 1 : 0),
- ];
-
- // Save (force insert to return ID)
- if ($id) {
- $this->items->update($id, $data);
- } else {
- $id = (int) $this->items->insert($data, true); // ensure insertID is returned
- }
-
- // Roles
- $roleIds = array_values(array_unique(array_filter(
- array_map('intval', (array) ($this->request->getPost('roles') ?? [])),
- fn ($v) => $v > 0
- )));
-
- $this->maps->where('nav_item_id', $id)->delete();
- foreach ($roleIds as $rid) {
- $this->maps->insert(['role_id' => $rid, 'nav_item_id' => $id]);
- }
-
- $this->service->clearCache();
- return redirect()->back()->with('success', 'Menu saved.');
-}
-
-
- public function delete($id)
- {
- $this->ensureAdmin();
- $id = (int) $id;
-
- $this->items->delete($id); // children handled by FK (SET NULL on parent)
- $this->service->clearCache();
-
- return redirect()->back()->with('success', 'Menu item deleted.');
- }
-
- public function reorder()
- {
- $this->ensureAdmin();
-
- $orders = $this->request->getPost('orders') ?? [];
- foreach ($orders as $id => $order) {
- $this->items->update((int) $id, ['sort_order' => (int) $order]);
- }
- $this->service->clearCache();
- return $this->response->setJSON(['ok' => true]);
- }
-
- protected function distinctRoles(): array
- {
- $db = \Config\Database::connect();
- return $db->table('roles')
- ->select('id, name')
- ->orderBy('name')
- ->get()
- ->getResultArray();
- }
-
- public function data()
- {
- $this->ensureAdmin();
- return $this->response->setJSON($this->buildNavPayload());
- }
-
- private function buildNavPayload(): array
- {
- $all = $this->items
- ->orderBy('menu_parent_id', 'ASC')
- ->orderBy('label', 'ASC')
- ->orderBy('id', 'ASC')
- ->findAll();
-
- $byId = [];
- foreach ($all as $a) {
- $a['children'] = [];
- $byId[$a['id']] = $a;
- }
-
- $tree = [];
- foreach ($byId as $id => &$node) {
- $pid = $node['menu_parent_id'] ?: null;
- if ($pid && isset($byId[$pid])) {
- $byId[$pid]['children'][] = &$node;
- } else {
- $tree[] = &$node;
- }
- }
- unset($node);
-
- $this->sortTreeAlpha($tree);
-
- $flatAlpha = $all;
- usort($flatAlpha, fn($a, $b) => strnatcasecmp(
- $this->labelKey($a['label'] ?? ''),
- $this->labelKey($b['label'] ?? '')
- ));
-
- $roleAssignments = [];
- $roleRows = $this->maps
- ->select('role_nav_items.nav_item_id, roles.id AS role_id, roles.name AS role_name')
- ->join('roles', 'roles.id = role_nav_items.role_id', 'left')
- ->findAll();
-
- foreach ($roleRows as $row) {
- $navId = (int) ($row['nav_item_id'] ?? 0);
- if ($navId <= 0) continue;
-
- $roleId = (int) ($row['role_id'] ?? 0);
- $roleName = $row['role_name'] ?? ($roleId ? ('#' . $roleId) : null);
-
- if ($roleId > 0) {
- $roleAssignments[$navId]['ids'][] = $roleId;
- }
- if ($roleName !== null) {
- $roleAssignments[$navId]['names'][] = $roleName;
- }
- }
-
- $flattened = $this->flattenTreeForResponse($tree, $roleAssignments);
-
- $parentOptions = array_map(static function ($row) {
- return [
- 'id' => (int) ($row['id'] ?? 0),
- 'label' => (string) ($row['label'] ?? ''),
- ];
- }, $flatAlpha);
-
- $roles = array_map(static function ($role) {
- return [
- 'id' => (int) ($role['id'] ?? 0),
- 'name' => (string) ($role['name'] ?? ''),
- ];
- }, $this->distinctRoles());
-
- return [
- 'items' => $flattened,
- 'roles' => $roles,
- 'parentOptions' => $parentOptions,
- ];
- }
-
- private function flattenTreeForResponse(array $nodes, array $roleAssignments, ?string $parentLabel = null, int $depth = 0, array &$rows = []): array
- {
- foreach ($nodes as $node) {
- $raw = $node;
- unset($raw['children']);
-
- $navId = (int) ($raw['id'] ?? 0);
- $roles = $roleAssignments[$navId] ?? ['ids' => [], 'names' => []];
-
- $rows[] = [
- 'id' => $navId,
- 'label' => (string) ($raw['label'] ?? ''),
- 'url' => $raw['url'] ?? null,
- 'parent_label' => $parentLabel ?? '—',
- 'parent_id' => isset($raw['menu_parent_id']) && (int) $raw['menu_parent_id'] !== 0
- ? (int) $raw['menu_parent_id']
- : null,
- 'order' => (int) ($raw['sort_order'] ?? 0),
- 'enabled' => (int) ($raw['is_enabled'] ?? 0) === 1,
- 'target' => $raw['target'] ?? null,
- 'depth' => $depth,
- 'roles' => [
- 'ids' => array_values(array_unique($roles['ids'] ?? [])),
- 'names' => array_values(array_unique($roles['names'] ?? [])),
- ],
- 'raw' => $raw,
- ];
-
- if (!empty($node['children'])) {
- $this->flattenTreeForResponse(
- $node['children'],
- $roleAssignments,
- (string) ($raw['label'] ?? ''),
- $depth + 1,
- $rows
- );
- }
- }
-
- return $rows;
- }
-}
diff --git a/app/old/NavbarService.php b/app/old/NavbarService.php
deleted file mode 100644
index 183184b1..00000000
--- a/app/old/NavbarService.php
+++ /dev/null
@@ -1,66 +0,0 @@
-cache ??= cache();
- }
-
- public function getMenuForRoles(array $roles): array
- {
- $roles = array_map(fn($r)=>strtolower(trim((string)$r)), $roles);
- $cacheKey = 'navbar_' . md5(json_encode($roles));
-
- if ($menu = $this->cache->get($cacheKey)) {
- return $menu;
- }
-
- // Which items this user can see
- $allowedIds = $this->mapModel->getNavItemIdsForRoles($roles);
- if (empty($allowedIds)) return [];
-
- // Load all enabled items, then filter
- $rows = $this->navModel->where('is_enabled', 1)
- ->orderBy('sort_order', 'ASC')
- ->findAll();
-
- // index by id
- $byId = [];
- foreach ($rows as $r) {
- if (in_array($r['id'], $allowedIds, true)) {
- $r['children'] = [];
- $byId[$r['id']] = $r;
- }
- }
-
- // Build tree
- $tree = [];
- foreach ($byId as $id => &$node) {
- $pid = $node['menu_parent_id'];
- if ($pid && isset($byId[$pid])) {
- $byId[$pid]['children'][] = &$node;
- } else {
- $tree[] = &$node;
- }
- }
-
- $this->cache->save($cacheKey, $tree, 300); // 5 minutes
- return $tree;
- }
-
- public function clearCache(): void
- {
- // simplest: flush; or if you have a tagged cache, clear only keys
- cache()->clean();
- }
-}
diff --git a/app/old/PageController.php b/app/old/PageController.php
deleted file mode 100644
index 4bc87db8..00000000
--- a/app/old/PageController.php
+++ /dev/null
@@ -1,64 +0,0 @@
-response->setBody(file_get_contents(ROOTPATH . 'public/html/privacy_policy.html'))
- ->setContentType('text/html');
- }
-
- public function termsOfService()
- {
- return $this->response->setBody(file_get_contents(ROOTPATH . 'public/html/terms_of_service.html'))
- ->setContentType('text/html');
- }
-
- public function helpCenter()
- {
- return $this->response->setBody(file_get_contents(ROOTPATH . 'public/html/help_center.html'))
- ->setContentType('text/html');
- }
-
- public function submitContactForm()
- {
- try {
- $email = strtolower($this->request->getPost('email'));
- $message = $this->request->getPost('message');
-
- // Save the message to the database
- $contactModel = new ContactUsModel();
- $contactData = [
- 'email' => $email,
- 'message' => $message,
- ];
-
- if (!$contactModel->save($contactData)) {
- log_message('error', 'Failed to save contact data: ' . implode(', ', $contactModel->errors()));
- return redirect()->back()->with('error', 'Failed to save message. Please try again.');
- }
-
- // Prepare the email content
- $recipient = 'alrahma.isgl@gmail.com'; // Change to the communication head's email
- $subject = 'Contact Us Form Submission';
- $emailMessage = "You have received a new message from $email:\n\n$message";
-
- // Use EmailController to send the email
- $emailController = new \App\Controllers\View\EmailController();
- if (!$emailController->sendEmail($recipient, $subject, $emailMessage)) {
- log_message('error', 'Failed to send email.');
- return redirect()->back()->with('error', 'Failed to send message. Please try again.');
- }
-
- return redirect()->to('/thank_you')->with('success', 'Message sent successfully.');
- } catch (\Exception $e) {
- log_message('error', 'Exception occurred: ' . $e->getMessage());
- return redirect()->back()->with('error', 'An unexpected error occurred. Please try again.');
- }
- }
-}
\ No newline at end of file
diff --git a/app/old/PhoneFormatterService.php b/app/old/PhoneFormatterService.php
deleted file mode 100644
index 9cd47f76..00000000
--- a/app/old/PhoneFormatterService.php
+++ /dev/null
@@ -1,31 +0,0 @@
-preferencesModel = new PreferencesModel();
- }
-
- /**
- * Display preferences page
- * GET /preferences/{userId}
- */
- public function index($userId = null)
- {
- // Get user ID from parameter or session
- $userId = $userId ?? (int) session()->get('user_id');
-
- if (!$userId) {
- return redirect()->to('/login')->with('error', 'Please log in to view preferences');
- }
-
- // Fetch preferences for the current user
- $preferences = $this->preferencesModel->where('user_id', $userId)->first();
-
- // If preferences do not exist, set default preferences
- if (!$preferences) {
- $preferences = [
- 'receive_email_notifications' => 1,
- 'receive_sms_notifications' => 1,
- 'theme' => 'light',
- 'language' => 'en',
- ];
- }
-
- // Append style/menu selections from session or defaults
- $styleConfig = config('Style');
- $preferences['style_color'] = $preferences['style_color'] ?? (session()->get('style_color') ?? ($styleConfig->defaultStyle ?? 'blue'));
- $preferences['menu_color'] = $preferences['menu_color'] ?? (session()->get('menu_color') ?? ($styleConfig->defaultMenu ?? 'white'));
-
- // Options for selectors
- $styleOptions = array_keys($styleConfig->stylePalettes ?? []);
- $menuOptions = array_keys($styleConfig->menuPalettes ?? []);
-
- // Load the view with the user's preferences
- return view('/preferences', [
- 'preferences' => $preferences,
- 'styleOptions' => $styleOptions,
- 'menuOptions' => $menuOptions,
- 'userId' => $userId,
- ]);
- }
-
- /**
- * Update preferences
- * POST /preferences/update/{userId}
- */
- public function updatePreferences($userId = null)
- {
- // Get user ID from parameter or session
- $userId = $userId ?? (int) session()->get('user_id');
-
- if (!$userId) {
- return redirect()->to('/login')->with('error', 'Please log in to update preferences');
- }
-
- // Validation rules
- $validation = \Config\Services::validation();
-
- $styleConfig = config('Style');
- $styleOptions = implode(',', array_keys($styleConfig->stylePalettes ?? []));
- $menuOptions = implode(',', array_keys($styleConfig->menuPalettes ?? []));
-
- $validation->setRules([
- 'receive_email_notifications' => 'permit_empty|in_list[0,1]',
- 'receive_sms_notifications' => 'permit_empty|in_list[0,1]',
- 'theme' => 'permit_empty|in_list[light,dark]',
- 'language' => 'permit_empty|in_list[en,fr,es]',
- 'style_color' => $styleOptions ? 'permit_empty|in_list[' . $styleOptions . ']' : 'permit_empty',
- 'menu_color' => $menuOptions ? 'permit_empty|in_list[' . $menuOptions . ']' : 'permit_empty',
- ]);
-
- if (!$validation->run($this->request->getPost())) {
- $preferences = $this->preferencesModel->where('user_id', $userId)->first();
- if (!$preferences) {
- $preferences = [
- 'receive_email_notifications' => 1,
- 'receive_sms_notifications' => 1,
- 'theme' => 'light',
- 'language' => 'en',
- ];
- }
- $preferences['style_color'] = $this->request->getPost('style_color') ?? ($preferences['style_color'] ?? (session()->get('style_color') ?? ($styleConfig->defaultStyle ?? 'blue')));
- $preferences['menu_color'] = $this->request->getPost('menu_color') ?? ($preferences['menu_color'] ?? (session()->get('menu_color') ?? ($styleConfig->defaultMenu ?? 'white')));
-
- return view('/preferences', [
- 'preferences' => $preferences,
- 'styleOptions' => array_keys($styleConfig->stylePalettes ?? []),
- 'menuOptions' => array_keys($styleConfig->menuPalettes ?? []),
- 'validation' => $validation,
- 'userId' => $userId,
- ]);
- }
-
- // Prepare updated data (map form fields to database fields)
- $updateData = [
- 'receive_email_notifications' => $this->request->getPost('receive_email_notifications') ?? $this->request->getPost('notification_email') ?? 1,
- 'receive_sms_notifications' => $this->request->getPost('receive_sms_notifications') ?? $this->request->getPost('notification_sms') ?? 1,
- 'theme' => $this->request->getPost('theme') ?? 'light',
- 'language' => $this->request->getPost('language') ?? 'en',
- ];
-
- // Store style/menu selections in session (no DB column dependency)
- $styleColor = $this->request->getPost('style_color');
- $menuColor = $this->request->getPost('menu_color');
- if ($styleColor && isset(($styleConfig->stylePalettes ?? [])[$styleColor])) {
- session()->set('style_color', $styleColor);
- $updateData['style_color'] = $styleColor;
- }
- if ($menuColor && isset(($styleConfig->menuPalettes ?? [])[$menuColor])) {
- session()->set('menu_color', $menuColor);
- $updateData['menu_color'] = $menuColor;
- }
-
- // Check if preferences already exist, update if they do, otherwise insert new preferences
- $existing = $this->preferencesModel->where('user_id', $userId)->first();
- if ($existing) {
- $this->preferencesModel->update($existing['id'], $updateData);
- } else {
- $updateData['user_id'] = $userId;
- $this->preferencesModel->insert($updateData);
- }
-
- // Redirect back to preferences page with success message
- return redirect()->to('/preferences/' . $userId)->with('success', 'Preferences updated successfully');
- }
-}
diff --git a/app/old/RFIDController.php b/app/old/RFIDController.php
deleted file mode 100644
index d4b50f0f..00000000
--- a/app/old/RFIDController.php
+++ /dev/null
@@ -1,71 +0,0 @@
-request->getPost('rfid');
-
- // Load the user model
- $userModel = new UserModel();
-
- // Find the user by RFID tag
- $user = $userModel->where('rfid_tag', $rfidTag)->first();
-
- if ($user) {
- // Log the scan (optional: store in a 'scan_log' table)
- $db = \Config\Database::connect();
- $db->table('scan_log')->insert([
- 'user_id' => $user->id,
- 'card_id' => $rfidTag,
- 'scan_time' => Time::now(),
- ]);
-
- // Redirect with success message
- return redirect()->to('/rfid/log')->with('message', 'RFID recognized: ' . $user->name);
- } else {
- // Redirect with error message if RFID not found
- return redirect()->to('/')->with('error', 'RFID tag not recognized.');
- }
- }
-
- public function log()
- {
- $db = \Config\Database::connect();
-
- // Retrieve scan logs and join with users table to display user names
- $query = $db->table('scan_log')
- ->select('users.firstname as user_firstname, users.lastname as user_lastname, students.firstname as student_firstname, students.lastname as student_lastname, scan_log.card_id, scan_log.scan_time')
- ->join('users', 'users.id = scan_log.user_id', 'left')
- ->join('students', 'students.rfid_tag = scan_log.card_id', 'left') // Join students table by RFID tag
- ->orderBy('scan_time', 'DESC')
- ->get();
-
- // Fetch user and student data for display purposes
- $userModel = new UserModel();
- $studentModel = new StudentModel();
-
- // Optional: Fetch all users and students (if needed elsewhere in the view)
- $users = $userModel->findAll();
- $students = $studentModel->findAll();
-
- // Pass scan logs, users, and students data to the view
- $data['logs'] = $query->getResult(); // Logs with user and student info
- $data['users'] = $users; // All users data
- $data['students'] = $students; // All students data
-
- return view('/rfid/rfid_coming_soon', $data);
- }
-
- public function rfidComingSoon()
- {
- return view('rfid/rfid_coming_soon');
- }
-}
\ No newline at end of file
diff --git a/app/old/SendSMSController.php b/app/old/SendSMSController.php
deleted file mode 100644
index 9dbef1a6..00000000
--- a/app/old/SendSMSController.php
+++ /dev/null
@@ -1,54 +0,0 @@
-Major U.S. Providers:
-
-AT&T
-SMS: <10-digit-number>@txt.att.net
-MMS: <10-digit-number>@mms.att.net
-
-Verizon
-SMS: <10-digit-number>@vtext.com
-MMS: <10-digit-number>@vzwpix.com
-
-T-Mobile
-SMS: <10-digit-number>@tmomail.net
-MMS: <10-digit-number>@tmomail.net
-
-Sprint (now merged with T-Mobile, but some legacy users may still use Sprint domains)
-SMS: <10-digit-number>@messaging.sprintpcs.com
-MMS: <10-digit-number>@pm.sprint.com
-
-Boost Mobile
-SMS: <10-digit-number>@sms.myboostmobile.com
-MMS: <10-digit-number>@myboostmobile.com
-
-U.S. Cellular
-SMS: <10-digit-number>@email.uscc.net
-MMS: <10-digit-number>@mms.uscc.net
-
-Metro by T-Mobile (formerly MetroPCS)
-SMS: <10-digit-number>@mymetropcs.com
-MMS: <10-digit-number>@mymetropcs.com
-
-Cricket Wireless
-SMS: <10-digit-number>@sms.cricketwireless.net
-MMS: <10-digit-number>@mms.cricketwireless.net
-
-Google Fi
-SMS/MMS: <10-digit-number>@msg.fi.google.com
-
-Virgin Mobile USA
-SMS: <10-digit-number>@vmobl.com
-MMS: <10-digit-number>@vmpix.com
-
-
-
-Carrier SMS Gateway MMS Gateway
-AT&T number@txt.att.net number@mms.att.net
-Verizon number@vtext.com number@vzwpix.com
-T-Mobile number@tmomail.net number@tmomail.net
-Sprint number@messaging.sprintpcs.com number@pm.sprint.com
-Boost Mobile number@sms.myboostmobile.com number@myboostmobile.com
-Cricket number@sms.cricketwireless.net number@mms.cricketwireless.net
-US Cellular number@email.uscc.net number@mms.uscc.net
-Google Fi number@msg.fi.google.com number@msg.fi.google.com
-Virgin Mobile number@vmobl.com number@vmpix.com
-Metro by T-Mobile number@mymetropcs.com number@mymetropcs.com
diff --git a/app/old/SettingsController.php b/app/old/SettingsController.php
deleted file mode 100644
index 47a33450..00000000
--- a/app/old/SettingsController.php
+++ /dev/null
@@ -1,38 +0,0 @@
-settingsModel = new SettingsModel();
- }
-
- public function index()
- {
- // Retrieve current settings from the database
- $settings = $this->settingsModel->getSettings();
- $timezones = timezone_identifiers_list();
-
- // Pass the settings and timezones to the view
- return view('administrator/settings', ['settings' => $settings, 'timezones' => $timezones]);
- }
-
- public function update()
- {
- // Handle the form submission to update settings
- $data = [
- 'site_name' => $this->request->getPost('site_name'),
- 'administrator_email' => strtolower($this->request->getPost('administrator_email')),
- 'timezone' => $this->request->getPost('timezone'),
- ];
- $this->settingsModel->updateSettings($data);
- return redirect()->to('/administrator/settings');
- }
-}
-?>
\ No newline at end of file
diff --git a/app/old/StaffController.php b/app/old/StaffController.php
deleted file mode 100644
index 3221ceb8..00000000
--- a/app/old/StaffController.php
+++ /dev/null
@@ -1,156 +0,0 @@
-configModel = new ConfigurationModel(); // Assuming ConfigModel is the model handling configurations
- $this->teacherClassModel = new TeacherClassModel();
- $this->userModel = new UserModel();
- $this->staffModel = new StaffModel();
-
- // Retrieve the configuration values
- $this->semester = $this->configModel->getConfig('semester');
- $this->schoolYear = $this->configModel->getConfig('school_year');
- }
-
- public function index()
- {
- // roles we never show
- $excludedRoles = ['student', 'parent', 'guest', 'inactive']; // ← add inactive here
-
- $staffList = $this->staffModel
- ->whereNotIn('LOWER(active_role)', array_map('strtolower', $excludedRoles))
- ->orderBy('created_at', 'DESC')
- ->findAll();
-
- // Preload assignments for the selected school year (across all semesters),
- // mirroring the logic used in teacher_class_assignment.
- $assignRows = $this->teacherClassModel
- ->select('teacher_class.teacher_id, teacher_class.position, classSection.class_section_name')
- ->join('classSection', 'classSection.class_section_id = teacher_class.class_section_id', 'left')
- ->where('teacher_class.school_year', (string)$this->schoolYear)
- ->findAll();
-
- $assignByTeacher = [];
- foreach ($assignRows as $r) {
- $tid = (int)($r['teacher_id'] ?? 0);
- if ($tid <= 0) { continue; }
- $name = trim((string)($r['class_section_name'] ?? ''));
- if ($name === '') { continue; }
- $pos = strtolower((string)($r['position'] ?? ''));
- if (!in_array($pos, ['main','ta'], true)) { continue; }
- $assignByTeacher[$tid][] = $name . ' (' . $pos . ')';
- }
-
- $issuesCount = 0;
- foreach ($staffList as &$staff) {
- // attach school_id
- $staff['school_id'] = $this->userModel->getSchoolIdByUserId($staff['user_id'] ?? null);
-
- // Verify and attach class assignments for teacher/TA roles for the selected school year
- $role = strtolower((string)($staff['active_role'] ?? ''));
- if (in_array($role, ['teacher', 'teacher_assistant'], true)) {
- $tid = (int)($staff['user_id'] ?? 0);
- $labels = $assignByTeacher[$tid] ?? [];
- if (!empty($labels)) {
- $staff['class_section'] = implode(', ', array_unique($labels));
- $staff['verification_issue'] = false;
- } else {
- $staff['class_section'] = 'No class assigned';
- $staff['verification_issue'] = true;
- $issuesCount++;
- }
- } else {
- $staff['class_section'] = '—';
- $staff['verification_issue'] = false;
- }
- }
- unset($staff); // break reference
-
- return view('staff/index', [
- 'staff' => $staffList,
- 'issues_count' => $issuesCount,
- 'semester' => $this->semester,
- 'school_year' => $this->schoolYear,
- ]);
- }
-
- public function create()
- {
- return view('staff/create');
- }
-
- public function store()
- {
- $now = utc_now();
- $payload = [
- 'firstname' => $this->request->getPost('firstname'),
- 'lastname' => $this->request->getPost('lastname'),
- 'email' => $this->request->getPost('email'),
- 'phone' => $this->request->getPost('phone'),
- 'role_name' => $this->request->getPost('role_name'),
- 'school_year' => $this->request->getPost('school_year') ?: $this->schoolYear,
- 'status' => $this->request->getPost('status') ?? 'Active',
- 'created_at' => $now,
- 'updated_at' => $now,
- ];
-
- if (!$this->staffModel->insert($payload)) {
- return redirect()->back()->withInput()->with('error', 'Failed to create staff.');
- }
-
- return redirect()->to('/staff')->with('success', 'Staff member added.');
- }
-
- public function edit($id)
- {
- $user = $this->staffModel->find($id);
-
- if (!$user) {
- return redirect()->to('staff')->with('error', 'Staff member not found.');
- }
-
- return view('staff/edit', ['user' => $user]);
- }
-
- public function update($id)
- {
- // Only update fields provided by the form to avoid wiping data
- $data = [];
- foreach (['firstname','lastname','email','phone','role_name','school_year','status'] as $field) {
- $val = $this->request->getPost($field);
- if ($val !== null && $val !== '') {
- $data[$field] = $val;
- }
- }
- // Always bump updated_at
- $data['updated_at'] = utc_now();
-
- if (empty($data)) {
- return redirect()->back()->with('error', 'No changes detected.');
- }
-
- if (!$this->staffModel->update($id, $data)) {
- return redirect()->back()->withInput()->with('error', 'Failed to update staff.');
- }
-
- return redirect()->to('/staff')->with('success', 'Staff member updated.');
- }
-}
diff --git a/app/old/StatsController.php b/app/old/StatsController.php
deleted file mode 100644
index 068baf92..00000000
--- a/app/old/StatsController.php
+++ /dev/null
@@ -1,32 +0,0 @@
-getStats();
-
- return view('stats_view', $data);
- }
-
- public function calendar()
- {
- return view('/calendars');
- }
-
- public function support()
- {
- return view('/support');
- }
-
- public function contactUs()
- {
- return view('/contact_us');
- }
-}
diff --git a/app/old/SupportController.php b/app/old/SupportController.php
deleted file mode 100644
index 6fd61c06..00000000
--- a/app/old/SupportController.php
+++ /dev/null
@@ -1,133 +0,0 @@
-get('user_id');
-
- // If no user_id is found in the session, redirect with an error
- if (!$user_id) {
- return redirect()->back()->with('error', 'You must be logged in to submit a support request.');
- }
-
- // Load the UserModel to retrieve user information
- $userModel = new UserModel();
- $user = $userModel->find($user_id);
-
- // If the user does not exist, redirect with an error
- if (!$user) {
- return redirect()->back()->with('error', 'User not found. Please contact support.');
- }
-
- // Retrieve form data
- $subject = $this->request->getPost('subject');
- $message = $this->request->getPost('message');
-
- // Get the user's full name using firstname and lastname
- $full_name = $user['firstname'] . ' ' . $user['lastname'];
-
- // Call the function to send an email using SMTP (configured via Email.php or .env)
- $sendStatus = $this->sendSupportEmail($user['email'], $full_name, $subject, $message);
-
- if ($sendStatus === true) {
- return redirect()->back()->with('success', 'Your support request has been submitted successfully.');
- } else {
- // If email fails, log the error and display a message
- log_message('error', $sendStatus);
- return redirect()->back()->with('error', 'Failed to send your support request. Please try again later.');
- }
- }
-
- /**
- * Function to send an email via SMTP from user to support@domain.org
- *
- * @param string $user_email - The email address of the user sending the request.
- * @param string $user_name - The name of the user sending the request.
- * @param string $subject - The subject of the support request.
- * @param string $message - The body of the support request.
- * @return bool|string - Returns true if email sent successfully, otherwise error message.
- */
- public function sendSupportEmail($user_email, $full_name, $subject, $message)
- {
- $mailer = new EmailController();
- $ok = $mailer->sendEmail(
- 'support@alrahmaisgl.org',
- $subject,
- nl2br($message),
- null,
- $user_email,
- $full_name
- );
-
- return $ok === true ? true : 'Failed to send support email.';
- }
-
-
-
- public function requests()
- {
- $supportModel = new SupportRequestModel();
- $userId = session()->get('user_id');
- $data['requests'] = $supportModel->where('user_id', $userId)->findAll();
-
- return view('/support_requests', $data);
- }
-
- public function supportTeacher()
- {
- return view('/teacher/teacher_support');
- }
-}
-
-
-/*
- public function submit()
- {
- $validation = \Config\Services::validation();
-
- // Define validation rules
- $validation->setRules([
- 'subject' => 'required|min_length[3]',
- 'message' => 'required|min_length[10]',
- ]);
-
- if (!$validation->withRequest($this->request)->run()) {
- return view('/support', [
- 'validation' => $validation
- ]);
- }
-
- $supportModel = new SupportRequestModel();
- $supportModel->save([
- 'user_id' => session()->get('user_id'),
- 'subject' => $this->request->getPost('subject'),
- 'message' => $this->request->getPost('message'),
- 'status' => 'open'
- ]);
-
- session()->setFlashdata('success', 'Your support request has been submitted.');
-
- return redirect()->to('/support');
- }
-*/
diff --git a/app/old/TestDBController.php b/app/old/TestDBController.php
deleted file mode 100644
index 70d30230..00000000
--- a/app/old/TestDBController.php
+++ /dev/null
@@ -1,27 +0,0 @@
-query('SELECT 1');
- $result = $query->getResult();
-
- if ($result) {
- echo "Database connection is successful.";
- } else {
- echo "Database connection failed.";
- }
- } catch (DatabaseException $e) {
- echo "Database connection failed: " . $e->getMessage();
- }
- }
-}
diff --git a/app/old/TimeService.php b/app/old/TimeService.php
deleted file mode 100644
index 15cd02c2..00000000
--- a/app/old/TimeService.php
+++ /dev/null
@@ -1,222 +0,0 @@
-cachedUserTz = $this->detectUserTimezone($request);
- }
-
- /**
- * Get the resolved user timezone (detect lazily if needed).
- */
- public function userTimezone(?RequestInterface $request = null): string
- {
- if ($this->cachedUserTz !== null) {
- return $this->cachedUserTz;
- }
-
- $this->cachedUserTz = $this->detectUserTimezone($request);
- return $this->cachedUserTz;
- }
-
- /**
- * Core detection logic (headers > user pref > settings > school config > default).
- */
- public function detectUserTimezone(?RequestInterface $request = null): string
- {
- $request = $request ?: service('request');
-
- // 1) Explicit client header
- try {
- $tzHeader = $request->getHeaderLine('X-Timezone')
- ?: $request->getHeaderLine('Timezone')
- ?: $request->getHeaderLine('Accept-Timezone')
- ?: null;
-
- if ($tzHeader && in_array($tzHeader, timezone_identifiers_list(), true)) {
- return $tzHeader;
- }
- } catch (\Throwable $e) {
- // ignore
- }
-
- // 2) User preference (if logged-in)
- try {
- $userId = (int) (session('user_id') ?: 0);
- if ($userId > 0) {
- // Do not select a specific column; some DBs may not have it yet
- $pref = (new PreferencesModel())
- ->where('user_id', $userId)
- ->first();
- $tz = $pref['timezone'] ?? null;
- if ($tz && in_array($tz, timezone_identifiers_list(), true)) {
- return $tz;
- }
- }
- } catch (\Throwable $e) {
- // ignore
- }
-
- // 3) Global settings timezone (if available)
- try {
- $settings = (new SettingsModel())->getSettings();
- $tz = $settings['timezone'] ?? null;
- if ($tz && in_array($tz, timezone_identifiers_list(), true)) {
- return $tz;
- }
- } catch (\Throwable $e) {
- // ignore
- }
-
- // 4) School attendance timezone (if defined)
- try {
- $tz = (string) (config('School')->attendance['timezone'] ?? '');
- if ($tz && in_array($tz, timezone_identifiers_list(), true)) {
- return $tz;
- }
- } catch (\Throwable $e) {
- // ignore
- }
-
- // 5) Default
- return $this->defaultUserTimezone;
- }
-
- /**
- * Server timezone (for storage/UTC operations).
- */
- public function serverTimezone(): string
- {
- return $this->serverTimezone;
- }
-
- /**
- * Current time in user timezone as CI Time.
- */
- public function nowLocal(?string $tz = null): Time
- {
- $tz = $tz ?: $this->userTimezone();
- return Time::now($tz);
- }
-
- /**
- * Current time in UTC as CI Time.
- */
- public function nowUTC(): Time
- {
- return Time::now($this->serverTimezone);
- }
-
- /**
- * Convert any supported input to UTC string (Y-m-d H:i:s by default).
- *
- * @param string|Time|\DateTimeInterface|null $value
- */
- public function toUTC($value, ?string $fromTz = null, string $format = 'Y-m-d H:i:s'): ?string
- {
- if ($value === null || $value === '') {
- return null;
- }
-
- $fromTz = $fromTz ?: $this->userTimezone();
-
- try {
- if ($value instanceof Time) {
- return $value->setTimezone($this->serverTimezone)->toDateTimeString();
- }
- if ($value instanceof \DateTimeInterface) {
- return Time::createFromInstance($value)
- ->setTimezone($this->serverTimezone)
- ->format($format);
- }
-
- // assume string
- return Time::parse((string)$value, $fromTz)
- ->setTimezone($this->serverTimezone)
- ->format($format);
- } catch (\Throwable $e) {
- return null;
- }
- }
-
- /**
- * Convert UTC (or provided timezone) to user-local string.
- *
- * @param string|Time|\DateTimeInterface|null $value
- */
- public function toLocal($value, ?string $sourceTz = null, ?string $targetTz = null, string $format = 'Y-m-d H:i:s'): ?string
- {
- if ($value === null || $value === '') {
- return null;
- }
-
- $targetTz = $targetTz ?: $this->userTimezone();
- if ($sourceTz === null && $this->isDateOnlyString($value)) {
- // Date-only strings should not shift across timezones.
- $sourceTz = $targetTz;
- } else {
- $sourceTz = $sourceTz ?: $this->serverTimezone;
- }
-
- try {
- if ($value instanceof Time) {
- return $value->setTimezone($targetTz)->format($format);
- }
- if ($value instanceof \DateTimeInterface) {
- return Time::createFromInstance($value)
- ->setTimezone($targetTz)
- ->format($format);
- }
-
- // assume string
- return Time::parse((string)$value, $sourceTz)
- ->setTimezone($targetTz)
- ->format($format);
- } catch (\Throwable $e) {
- return null;
- }
- }
-
- /**
- * Convenience formatter for user-local.
- */
- public function formatLocal($value, string $format = 'Y-m-d H:i', ?string $sourceTz = null): string
- {
- return (string) ($this->toLocal($value, $sourceTz, null, $format) ?? '');
- }
-
- /**
- * Convenience formatter for UTC.
- */
- public function formatUTC($value, string $format = 'Y-m-d H:i:s', ?string $fromTz = null): string
- {
- return (string) ($this->toUTC($value, $fromTz, $format) ?? '');
- }
-
- private function isDateOnlyString($value): bool
- {
- if (!is_string($value)) {
- return false;
- }
-
- $value = trim($value);
- return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $value);
- }
-}
diff --git a/app/old/UiController.php b/app/old/UiController.php
deleted file mode 100644
index 78619f8c..00000000
--- a/app/old/UiController.php
+++ /dev/null
@@ -1,102 +0,0 @@
-request->getGet('accent');
- $menu = (string) $this->request->getGet('menu');
- $menuBg = (string) $this->request->getGet('menu_bg');
- $menuTx = (string) $this->request->getGet('menu_text');
- $menuMd = (string) $this->request->getGet('menu_mode'); // optional: 'light'|'dark'|'auto'
-
- $updates = [];
- if ($accent && isset(($styleCfg->stylePalettes ?? [])[$accent])) {
- session()->set('style_color', $accent);
- $updates['style_color'] = $accent;
- }
- if ($menu && isset(($styleCfg->menuPalettes ?? [])[$menu])) {
- session()->set('menu_color', $menu);
- $updates['menu_color'] = $menu;
- $updates['menu_custom_bg'] = null;
- $updates['menu_custom_text'] = null;
- $updates['menu_custom_mode'] = null;
- } elseif (strtolower($menu) === 'custom' || ($menuBg !== '' || $menuTx !== '')) {
- // Allow custom menu colors via GET: menu=custom&menu_bg=#hex&menu_text=#hex&menu_mode=dark|light|auto
- $bg = $this->normalizeHex($menuBg);
- $tx = $this->normalizeHex($menuTx);
- if ($bg === '' && $tx === '') {
- // no valid values; ignore
- } else {
- if ($bg === '') $bg = '#0f172a';
- if ($tx === '') $tx = '#ffffff';
- $mode = in_array($menuMd, ['light','dark','auto'], true) ? $menuMd : 'auto';
- session()->set('menu_color', 'custom');
- session()->set('menu_custom_bg', $bg);
- session()->set('menu_custom_text', $tx);
- session()->set('menu_custom_mode', $mode);
- $updates['menu_color'] = 'custom';
- $updates['menu_custom_bg'] = $bg;
- $updates['menu_custom_text'] = $tx;
- $updates['menu_custom_mode'] = $mode;
- }
- }
-
- $userId = (int) session()->get('user_id');
- if ($userId && !empty($updates)) {
- $prefsModel = new PreferencesModel();
- $existing = $prefsModel->where('user_id', $userId)->first();
- if ($existing) {
- $prefsModel->update($existing['id'], $updates);
- } else {
- $updates['user_id'] = $userId;
- $prefsModel->insert($updates);
- }
- }
-
- // Redirect back to referrer or home
- $back = (string) $this->request->getGet('back');
- if ($back) return redirect()->to($back);
- $ref = $this->request->getServer('HTTP_REFERER');
- if ($ref) return redirect()->to($ref);
- return redirect()->to(site_url('/'));
- }
-
- /**
- * Normalize a CSS hex color (#RGB or #RRGGBB). Returns '' on failure.
- */
- private function normalizeHex(string $hex): string
- {
- $h = trim($hex);
- if ($h === '') return '';
- if ($h[0] !== '#') $h = '#' . $h;
- if (preg_match('/^#([0-9a-fA-F]{3})$/', $h, $m)) {
- // Expand #RGB to #RRGGBB
- $r = $m[1][0]; $g = $m[1][1]; $b = $m[1][2];
- return '#' . $r . $r . $g . $g . $b . $b;
- }
- if (preg_match('/^#([0-9a-fA-F]{6})$/', $h)) return strtoupper($h);
- return '';
- }
-
- /**
- * Choose navbar mode based on background luminance: 'dark' for dark BG, else 'light'.
- */
- private function autoModeForBg(string $hexBg): string
- {
- $hex = ltrim($hexBg, '#');
- if (strlen($hex) !== 6) return 'dark';
- $r = hexdec(substr($hex,0,2));
- $g = hexdec(substr($hex,2,2));
- $b = hexdec(substr($hex,4,2));
- // Relative luminance approximation
- $lum = (0.2126*$r + 0.7152*$g + 0.0722*$b) / 255.0;
- return ($lum < 0.5) ? 'dark' : 'light';
- }
-}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 85d31086..f609b86c 100755
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -16,6 +16,11 @@ return Application::configure(basePath: dirname(__DIR__))
'auth.multi' => \App\Http\Middleware\MultiAuth::class,
'jwt.auth' => \PHPOpenSourceSaver\JWTAuth\Http\Middleware\Authenticate::class,
'jwt.refresh' => \PHPOpenSourceSaver\JWTAuth\Http\Middleware\RefreshToken::class,
+ 'api.jwt' => \App\Http\Middleware\ApiJwtAuth::class,
+ 'perm' => \App\Http\Middleware\RequirePermission::class,
+ 'timezone' => \App\Http\Middleware\PrimeTimezone::class,
+ 'cleanup.scheduler' => \App\Http\Middleware\CleanupScheduler::class,
+ 'auth.docs' => \App\Http\Middleware\ApiDocsAuth::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
diff --git a/database/migrations/2026_02_23_204001_create_users_table.php b/database/migrations/2026_02_23_204001_create_users_table.php
index 453d57fd..c9f50842 100644
--- a/database/migrations/2026_02_23_204001_create_users_table.php
+++ b/database/migrations/2026_02_23_204001_create_users_table.php
@@ -7,6 +7,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('users')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `users` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204002_create_students_table.php b/database/migrations/2026_02_23_204002_create_students_table.php
index 3f7c83ef..c078e396 100644
--- a/database/migrations/2026_02_23_204002_create_students_table.php
+++ b/database/migrations/2026_02_23_204002_create_students_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('students')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `students` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204030_create_expenses_table.php b/database/migrations/2026_02_23_204030_create_expenses_table.php
index 11abfeee..00f8c9fb 100644
--- a/database/migrations/2026_02_23_204030_create_expenses_table.php
+++ b/database/migrations/2026_02_23_204030_create_expenses_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('expenses')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `expenses` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204035_create_current_incident_table.php b/database/migrations/2026_02_23_204035_create_current_incident_table.php
index f039ff4c..7ef64a45 100644
--- a/database/migrations/2026_02_23_204035_create_current_incident_table.php
+++ b/database/migrations/2026_02_23_204035_create_current_incident_table.php
@@ -7,6 +7,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('current_incident')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `current_incident` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204036_create_incident_table.php b/database/migrations/2026_02_23_204036_create_incident_table.php
index c9272069..20e4e2d7 100644
--- a/database/migrations/2026_02_23_204036_create_incident_table.php
+++ b/database/migrations/2026_02_23_204036_create_incident_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('incident')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `incident` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204037_create_grading_locks_table.php b/database/migrations/2026_02_23_204037_create_grading_locks_table.php
index df4c89a5..08d1e80d 100644
--- a/database/migrations/2026_02_23_204037_create_grading_locks_table.php
+++ b/database/migrations/2026_02_23_204037_create_grading_locks_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('grading_locks')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `grading_locks` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204038_create_homework_table.php b/database/migrations/2026_02_23_204038_create_homework_table.php
index 801033d4..c07c13e3 100644
--- a/database/migrations/2026_02_23_204038_create_homework_table.php
+++ b/database/migrations/2026_02_23_204038_create_homework_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('homework')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `homework` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204039_create_inventory_categories_table.php b/database/migrations/2026_02_23_204039_create_inventory_categories_table.php
index b19e7e2d..0d9bc047 100644
--- a/database/migrations/2026_02_23_204039_create_inventory_categories_table.php
+++ b/database/migrations/2026_02_23_204039_create_inventory_categories_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('inventory_categories')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `inventory_categories` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204040_create_inventory_items_table.php b/database/migrations/2026_02_23_204040_create_inventory_items_table.php
index 8b6e547d..457d32ce 100644
--- a/database/migrations/2026_02_23_204040_create_inventory_items_table.php
+++ b/database/migrations/2026_02_23_204040_create_inventory_items_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('inventory_items')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `inventory_items` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204041_create_inventory_movements_table.php b/database/migrations/2026_02_23_204041_create_inventory_movements_table.php
index db27254c..d114fea5 100644
--- a/database/migrations/2026_02_23_204041_create_inventory_movements_table.php
+++ b/database/migrations/2026_02_23_204041_create_inventory_movements_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('inventory_movements')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `inventory_movements` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204042_create_invoice_event_table.php b/database/migrations/2026_02_23_204042_create_invoice_event_table.php
index f681882f..7fcceed9 100644
--- a/database/migrations/2026_02_23_204042_create_invoice_event_table.php
+++ b/database/migrations/2026_02_23_204042_create_invoice_event_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('invoice_event')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `invoice_event` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204043_create_invoices_table.php b/database/migrations/2026_02_23_204043_create_invoices_table.php
index 6020b08f..39d5a618 100644
--- a/database/migrations/2026_02_23_204043_create_invoices_table.php
+++ b/database/migrations/2026_02_23_204043_create_invoices_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('invoices')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `invoices` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204044_create_invoice_students_list_table.php b/database/migrations/2026_02_23_204044_create_invoice_students_list_table.php
index 43afd2f9..0d7e6f69 100644
--- a/database/migrations/2026_02_23_204044_create_invoice_students_list_table.php
+++ b/database/migrations/2026_02_23_204044_create_invoice_students_list_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('invoice_students_list')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `invoice_students_list` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204045_create_ip_attempts_table.php b/database/migrations/2026_02_23_204045_create_ip_attempts_table.php
index 4bb8ff04..18e9d66c 100644
--- a/database/migrations/2026_02_23_204045_create_ip_attempts_table.php
+++ b/database/migrations/2026_02_23_204045_create_ip_attempts_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('ip_attempts')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `ip_attempts` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204046_create_late_slip_logs_table.php b/database/migrations/2026_02_23_204046_create_late_slip_logs_table.php
index 1eb9dc19..3d344e16 100644
--- a/database/migrations/2026_02_23_204046_create_late_slip_logs_table.php
+++ b/database/migrations/2026_02_23_204046_create_late_slip_logs_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('late_slip_logs')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `late_slip_logs` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204047_create_login_activity_table.php b/database/migrations/2026_02_23_204047_create_login_activity_table.php
index 56abe823..5d9f4355 100644
--- a/database/migrations/2026_02_23_204047_create_login_activity_table.php
+++ b/database/migrations/2026_02_23_204047_create_login_activity_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('login_activity')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `login_activity` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204048_create_manual_payments_table.php b/database/migrations/2026_02_23_204048_create_manual_payments_table.php
index 4a91dbda..fa543a5b 100644
--- a/database/migrations/2026_02_23_204048_create_manual_payments_table.php
+++ b/database/migrations/2026_02_23_204048_create_manual_payments_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('manual_payments')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `manual_payments` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204049_create_messages_table.php b/database/migrations/2026_02_23_204049_create_messages_table.php
index 19f95964..65c899a6 100644
--- a/database/migrations/2026_02_23_204049_create_messages_table.php
+++ b/database/migrations/2026_02_23_204049_create_messages_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('messages')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `messages` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204050_create_midterm_exam_table.php b/database/migrations/2026_02_23_204050_create_midterm_exam_table.php
index 05e0a3e2..c0c66ed4 100644
--- a/database/migrations/2026_02_23_204050_create_midterm_exam_table.php
+++ b/database/migrations/2026_02_23_204050_create_midterm_exam_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('midterm_exam')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `midterm_exam` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204051_create_missing_score_overrides_table.php b/database/migrations/2026_02_23_204051_create_missing_score_overrides_table.php
index bcd34de3..7d0d5022 100644
--- a/database/migrations/2026_02_23_204051_create_missing_score_overrides_table.php
+++ b/database/migrations/2026_02_23_204051_create_missing_score_overrides_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('missing_score_overrides')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `missing_score_overrides` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204052_create_nav_items_table.php b/database/migrations/2026_02_23_204052_create_nav_items_table.php
index 430ea69b..e5170668 100644
--- a/database/migrations/2026_02_23_204052_create_nav_items_table.php
+++ b/database/migrations/2026_02_23_204052_create_nav_items_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('nav_items')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `nav_items` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204053_create_notifications_table.php b/database/migrations/2026_02_23_204053_create_notifications_table.php
index f4baf204..6edca331 100644
--- a/database/migrations/2026_02_23_204053_create_notifications_table.php
+++ b/database/migrations/2026_02_23_204053_create_notifications_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('notifications')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `notifications` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204054_create_parent_attendance_reports_table.php b/database/migrations/2026_02_23_204054_create_parent_attendance_reports_table.php
index 547edf4a..40439b55 100644
--- a/database/migrations/2026_02_23_204054_create_parent_attendance_reports_table.php
+++ b/database/migrations/2026_02_23_204054_create_parent_attendance_reports_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('parent_attendance_reports')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `parent_attendance_reports` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204055_create_parent_meeting_schedules_table.php b/database/migrations/2026_02_23_204055_create_parent_meeting_schedules_table.php
index 0fa278ae..4f00b430 100644
--- a/database/migrations/2026_02_23_204055_create_parent_meeting_schedules_table.php
+++ b/database/migrations/2026_02_23_204055_create_parent_meeting_schedules_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('parent_meeting_schedules')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `parent_meeting_schedules` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204056_create_parents_table.php b/database/migrations/2026_02_23_204056_create_parents_table.php
index 02b41ff5..50db8e70 100644
--- a/database/migrations/2026_02_23_204056_create_parents_table.php
+++ b/database/migrations/2026_02_23_204056_create_parents_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('parents')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `parents` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_204057_create_parent_notifications_table.php b/database/migrations/2026_02_23_204057_create_parent_notifications_table.php
index 90ece232..36157698 100644
--- a/database/migrations/2026_02_23_204057_create_parent_notifications_table.php
+++ b/database/migrations/2026_02_23_204057_create_parent_notifications_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('parent_notifications')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `parent_notifications` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204058_create_participation_table.php b/database/migrations/2026_02_23_204058_create_participation_table.php
index 2855cb38..91f54c7a 100644
--- a/database/migrations/2026_02_23_204058_create_participation_table.php
+++ b/database/migrations/2026_02_23_204058_create_participation_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('participation')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `participation` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204059_create_password_resets_table.php b/database/migrations/2026_02_23_204059_create_password_resets_table.php
index 7e7f4595..daf2c27a 100644
--- a/database/migrations/2026_02_23_204059_create_password_resets_table.php
+++ b/database/migrations/2026_02_23_204059_create_password_resets_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('password_resets')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `password_resets` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204100_create_password_reset_requests_table.php b/database/migrations/2026_02_23_204100_create_password_reset_requests_table.php
index f5a5ccbd..2d3d7de1 100644
--- a/database/migrations/2026_02_23_204100_create_password_reset_requests_table.php
+++ b/database/migrations/2026_02_23_204100_create_password_reset_requests_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('password_reset_requests')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `password_reset_requests` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204100_create_reimbursement_batches_table.php b/database/migrations/2026_02_23_204100_create_reimbursement_batches_table.php
index bcfad3d3..098aed8d 100644
--- a/database/migrations/2026_02_23_204100_create_reimbursement_batches_table.php
+++ b/database/migrations/2026_02_23_204100_create_reimbursement_batches_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('reimbursement_batches')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `reimbursement_batches` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204101_create_paypal_payments_table.php b/database/migrations/2026_02_23_204101_create_paypal_payments_table.php
index 00644235..aed7c3af 100644
--- a/database/migrations/2026_02_23_204101_create_paypal_payments_table.php
+++ b/database/migrations/2026_02_23_204101_create_paypal_payments_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('paypal_payments')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `paypal_payments` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204101_create_reimbursements_table.php b/database/migrations/2026_02_23_204101_create_reimbursements_table.php
index df1658e2..19fde635 100644
--- a/database/migrations/2026_02_23_204101_create_reimbursements_table.php
+++ b/database/migrations/2026_02_23_204101_create_reimbursements_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('reimbursements')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `reimbursements` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204102_create_payment_error_table.php b/database/migrations/2026_02_23_204102_create_payment_error_table.php
index 838fcfac..a502cf01 100644
--- a/database/migrations/2026_02_23_204102_create_payment_error_table.php
+++ b/database/migrations/2026_02_23_204102_create_payment_error_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('payment_error')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `payment_error` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204103_create_payments_table.php b/database/migrations/2026_02_23_204103_create_payments_table.php
index 3a230d8c..32b142d4 100644
--- a/database/migrations/2026_02_23_204103_create_payments_table.php
+++ b/database/migrations/2026_02_23_204103_create_payments_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('payments')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `payments` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204104_create_payment_notification_logs_table.php b/database/migrations/2026_02_23_204104_create_payment_notification_logs_table.php
index 5b786b5d..8d9b84ae 100644
--- a/database/migrations/2026_02_23_204104_create_payment_notification_logs_table.php
+++ b/database/migrations/2026_02_23_204104_create_payment_notification_logs_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('payment_notification_logs')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `payment_notification_logs` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204105_create_payment_transactions_table.php b/database/migrations/2026_02_23_204105_create_payment_transactions_table.php
index da834c40..c444fc21 100644
--- a/database/migrations/2026_02_23_204105_create_payment_transactions_table.php
+++ b/database/migrations/2026_02_23_204105_create_payment_transactions_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('payment_transactions')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `payment_transactions` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204106_create_paypal_transactions_table.php b/database/migrations/2026_02_23_204106_create_paypal_transactions_table.php
index 0bfbcace..431d6721 100644
--- a/database/migrations/2026_02_23_204106_create_paypal_transactions_table.php
+++ b/database/migrations/2026_02_23_204106_create_paypal_transactions_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('paypal_transactions')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `paypal_transactions` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_204107_create_permissions_table.php b/database/migrations/2026_02_23_204107_create_permissions_table.php
index e4c1fb46..65b8cb5c 100644
--- a/database/migrations/2026_02_23_204107_create_permissions_table.php
+++ b/database/migrations/2026_02_23_204107_create_permissions_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('permissions')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `permissions` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204108_create_placement_batches_table.php b/database/migrations/2026_02_23_204108_create_placement_batches_table.php
index 5bb65f41..74d72f93 100644
--- a/database/migrations/2026_02_23_204108_create_placement_batches_table.php
+++ b/database/migrations/2026_02_23_204108_create_placement_batches_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('placement_batches')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `placement_batches` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204109_create_placement_levels_table.php b/database/migrations/2026_02_23_204109_create_placement_levels_table.php
index 248beba5..b50db61e 100644
--- a/database/migrations/2026_02_23_204109_create_placement_levels_table.php
+++ b/database/migrations/2026_02_23_204109_create_placement_levels_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('placement_levels')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `placement_levels` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204110_create_placement_scores_table.php b/database/migrations/2026_02_23_204110_create_placement_scores_table.php
index d2c8a316..fc739390 100644
--- a/database/migrations/2026_02_23_204110_create_placement_scores_table.php
+++ b/database/migrations/2026_02_23_204110_create_placement_scores_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('placement_scores')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `placement_scores` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204111_create_user_preferences_table.php b/database/migrations/2026_02_23_204111_create_user_preferences_table.php
index 26b31108..f348fae1 100644
--- a/database/migrations/2026_02_23_204111_create_user_preferences_table.php
+++ b/database/migrations/2026_02_23_204111_create_user_preferences_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('user_preferences')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `user_preferences` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204112_create_print_requests_table.php b/database/migrations/2026_02_23_204112_create_print_requests_table.php
index b4964da5..3d229cde 100644
--- a/database/migrations/2026_02_23_204112_create_print_requests_table.php
+++ b/database/migrations/2026_02_23_204112_create_print_requests_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('print_requests')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `print_requests` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204113_create_project_table.php b/database/migrations/2026_02_23_204113_create_project_table.php
index 0ad94b8c..6678c2f1 100644
--- a/database/migrations/2026_02_23_204113_create_project_table.php
+++ b/database/migrations/2026_02_23_204113_create_project_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('project')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `project` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204114_create_promotion_queue_table.php b/database/migrations/2026_02_23_204114_create_promotion_queue_table.php
index 9e561000..d619a54f 100644
--- a/database/migrations/2026_02_23_204114_create_promotion_queue_table.php
+++ b/database/migrations/2026_02_23_204114_create_promotion_queue_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('promotion_queue')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `promotion_queue` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204117_create_quiz_table.php b/database/migrations/2026_02_23_204117_create_quiz_table.php
index 95d1ee5d..a845894c 100644
--- a/database/migrations/2026_02_23_204117_create_quiz_table.php
+++ b/database/migrations/2026_02_23_204117_create_quiz_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('quiz')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `quiz` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204118_create_refunds_table.php b/database/migrations/2026_02_23_204118_create_refunds_table.php
index 9afac796..bf7cccd3 100644
--- a/database/migrations/2026_02_23_204118_create_refunds_table.php
+++ b/database/migrations/2026_02_23_204118_create_refunds_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('refunds')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `refunds` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204119_create_reimbursement_batch_admin_files_table.php b/database/migrations/2026_02_23_204119_create_reimbursement_batch_admin_files_table.php
index 27254a2e..645e53c1 100644
--- a/database/migrations/2026_02_23_204119_create_reimbursement_batch_admin_files_table.php
+++ b/database/migrations/2026_02_23_204119_create_reimbursement_batch_admin_files_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('reimbursement_batch_admin_files')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `reimbursement_batch_admin_files` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204123_create_roles_table.php b/database/migrations/2026_02_23_204123_create_roles_table.php
index 68faec99..2650dd9f 100644
--- a/database/migrations/2026_02_23_204123_create_roles_table.php
+++ b/database/migrations/2026_02_23_204123_create_roles_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('roles')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `roles` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204124_create_role_nav_items_table.php b/database/migrations/2026_02_23_204124_create_role_nav_items_table.php
index 2d5d1c1d..6239e352 100644
--- a/database/migrations/2026_02_23_204124_create_role_nav_items_table.php
+++ b/database/migrations/2026_02_23_204124_create_role_nav_items_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('role_nav_items')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `role_nav_items` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204125_create_role_permissions_table.php b/database/migrations/2026_02_23_204125_create_role_permissions_table.php
index fcc32884..01e068e5 100644
--- a/database/migrations/2026_02_23_204125_create_role_permissions_table.php
+++ b/database/migrations/2026_02_23_204125_create_role_permissions_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('role_permissions')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `role_permissions` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204126_create_score_comments_table.php b/database/migrations/2026_02_23_204126_create_score_comments_table.php
index 65be59cf..09f37fb8 100644
--- a/database/migrations/2026_02_23_204126_create_score_comments_table.php
+++ b/database/migrations/2026_02_23_204126_create_score_comments_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('score_comments')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `score_comments` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204127_create_sections_table.php b/database/migrations/2026_02_23_204127_create_sections_table.php
index d9f0142e..57bf4e14 100644
--- a/database/migrations/2026_02_23_204127_create_sections_table.php
+++ b/database/migrations/2026_02_23_204127_create_sections_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('sections')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `sections` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204128_create_semester_scores_table.php b/database/migrations/2026_02_23_204128_create_semester_scores_table.php
index 683df47f..c974803e 100644
--- a/database/migrations/2026_02_23_204128_create_semester_scores_table.php
+++ b/database/migrations/2026_02_23_204128_create_semester_scores_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('semester_scores')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `semester_scores` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204129_create_settings_table.php b/database/migrations/2026_02_23_204129_create_settings_table.php
index ec0cf2fc..78712afc 100644
--- a/database/migrations/2026_02_23_204129_create_settings_table.php
+++ b/database/migrations/2026_02_23_204129_create_settings_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('settings')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `settings` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204130_create_staff_attendance_table.php b/database/migrations/2026_02_23_204130_create_staff_attendance_table.php
index 57847773..3c797cee 100644
--- a/database/migrations/2026_02_23_204130_create_staff_attendance_table.php
+++ b/database/migrations/2026_02_23_204130_create_staff_attendance_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('staff_attendance')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `staff_attendance` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204131_create_staff_table.php b/database/migrations/2026_02_23_204131_create_staff_table.php
index bc4be890..7e9539cf 100644
--- a/database/migrations/2026_02_23_204131_create_staff_table.php
+++ b/database/migrations/2026_02_23_204131_create_staff_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('staff')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `staff` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204133_create_student_allergies_table.php b/database/migrations/2026_02_23_204133_create_student_allergies_table.php
index bec61517..7d89fadd 100644
--- a/database/migrations/2026_02_23_204133_create_student_allergies_table.php
+++ b/database/migrations/2026_02_23_204133_create_student_allergies_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('student_allergies')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `student_allergies` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_204134_create_student_class_table.php b/database/migrations/2026_02_23_204134_create_student_class_table.php
index 9891996b..0a94d28d 100644
--- a/database/migrations/2026_02_23_204134_create_student_class_table.php
+++ b/database/migrations/2026_02_23_204134_create_student_class_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('student_class')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `student_class` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204135_create_student_medical_conditions_table.php b/database/migrations/2026_02_23_204135_create_student_medical_conditions_table.php
index f4d5a5a3..0bf8f1b1 100644
--- a/database/migrations/2026_02_23_204135_create_student_medical_conditions_table.php
+++ b/database/migrations/2026_02_23_204135_create_student_medical_conditions_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('student_medical_conditions')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `student_medical_conditions` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_204137_create_subject_curriculum_items_table.php b/database/migrations/2026_02_23_204137_create_subject_curriculum_items_table.php
index 84c7c7da..b8500c64 100644
--- a/database/migrations/2026_02_23_204137_create_subject_curriculum_items_table.php
+++ b/database/migrations/2026_02_23_204137_create_subject_curriculum_items_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('subject_curriculum_items')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `subject_curriculum_items` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204138_create_teacher_class_table.php b/database/migrations/2026_02_23_204138_create_teacher_class_table.php
index eb81e955..a7cbd2dc 100644
--- a/database/migrations/2026_02_23_204138_create_teacher_class_table.php
+++ b/database/migrations/2026_02_23_204138_create_teacher_class_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('teacher_class')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `teacher_class` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204140_create_teacher_submission_notification_history_table.php b/database/migrations/2026_02_23_204140_create_teacher_submission_notification_history_table.php
index 7729ec80..efc2977f 100644
--- a/database/migrations/2026_02_23_204140_create_teacher_submission_notification_history_table.php
+++ b/database/migrations/2026_02_23_204140_create_teacher_submission_notification_history_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('teacher_submission_notification_history')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `teacher_submission_notification_history` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204142_create_user_notifications_table.php b/database/migrations/2026_02_23_204142_create_user_notifications_table.php
index d943fd35..e12ff70a 100644
--- a/database/migrations/2026_02_23_204142_create_user_notifications_table.php
+++ b/database/migrations/2026_02_23_204142_create_user_notifications_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('user_notifications')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `user_notifications` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204143_create_user_roles_table.php b/database/migrations/2026_02_23_204143_create_user_roles_table.php
index 26c109f5..f1d6384a 100644
--- a/database/migrations/2026_02_23_204143_create_user_roles_table.php
+++ b/database/migrations/2026_02_23_204143_create_user_roles_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('user_roles')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `user_roles` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204144_create_whatsapp_group_links_table.php b/database/migrations/2026_02_23_204144_create_whatsapp_group_links_table.php
index 105b150a..fcea6509 100644
--- a/database/migrations/2026_02_23_204144_create_whatsapp_group_links_table.php
+++ b/database/migrations/2026_02_23_204144_create_whatsapp_group_links_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('whatsapp_group_links')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `whatsapp_group_links` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_204145_create_whatsapp_group_memberships_table.php b/database/migrations/2026_02_23_204145_create_whatsapp_group_memberships_table.php
index 78cbf6f4..f087535a 100644
--- a/database/migrations/2026_02_23_204145_create_whatsapp_group_memberships_table.php
+++ b/database/migrations/2026_02_23_204145_create_whatsapp_group_memberships_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('whatsapp_group_memberships')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `whatsapp_group_memberships` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204146_create_whatsapp_invites_log_table.php b/database/migrations/2026_02_23_204146_create_whatsapp_invites_log_table.php
index f08b92f6..3899f034 100644
--- a/database/migrations/2026_02_23_204146_create_whatsapp_invites_log_table.php
+++ b/database/migrations/2026_02_23_204146_create_whatsapp_invites_log_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('whatsapp_invites_log')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `whatsapp_invites_log` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_204150_create_class_progress_reports_table.php b/database/migrations/2026_02_23_204150_create_class_progress_reports_table.php
index bdf1380b..0867cf2c 100644
--- a/database/migrations/2026_02_23_204150_create_class_progress_reports_table.php
+++ b/database/migrations/2026_02_23_204150_create_class_progress_reports_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('class_progress_reports')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `class_progress_reports` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204151_create_report_card_acknowledgements_table.php b/database/migrations/2026_02_23_204151_create_report_card_acknowledgements_table.php
index 293735a7..6660f0f4 100644
--- a/database/migrations/2026_02_23_204151_create_report_card_acknowledgements_table.php
+++ b/database/migrations/2026_02_23_204151_create_report_card_acknowledgements_table.php
@@ -7,6 +7,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('report_card_acknowledgements')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `report_card_acknowledgements` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204160_create_class_progress_attachments_table.php b/database/migrations/2026_02_23_204160_create_class_progress_attachments_table.php
index 76cc6967..8859fbda 100644
--- a/database/migrations/2026_02_23_204160_create_class_progress_attachments_table.php
+++ b/database/migrations/2026_02_23_204160_create_class_progress_attachments_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('class_progress_attachments')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `class_progress_attachments` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_204200_create_reimbursement_batch_items_table.php b/database/migrations/2026_02_23_204200_create_reimbursement_batch_items_table.php
index e5f85ab7..c42c0357 100644
--- a/database/migrations/2026_02_23_204200_create_reimbursement_batch_items_table.php
+++ b/database/migrations/2026_02_23_204200_create_reimbursement_batch_items_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('reimbursement_batch_items')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `reimbursement_batch_items` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900001_create_additional_charges_table.php b/database/migrations/2026_02_23_900001_create_additional_charges_table.php
index e051c12c..04752129 100644
--- a/database/migrations/2026_02_23_900001_create_additional_charges_table.php
+++ b/database/migrations/2026_02_23_900001_create_additional_charges_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('additional_charges')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `additional_charges` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900002_create_admin_notification_subjects_table.php b/database/migrations/2026_02_23_900002_create_admin_notification_subjects_table.php
index ab84f922..b5f10fef 100644
--- a/database/migrations/2026_02_23_900002_create_admin_notification_subjects_table.php
+++ b/database/migrations/2026_02_23_900002_create_admin_notification_subjects_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('admin_notification_subjects')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `admin_notification_subjects` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900003_create_attendance_comment_template_table.php b/database/migrations/2026_02_23_900003_create_attendance_comment_template_table.php
index 1401ce7a..d5d638cd 100644
--- a/database/migrations/2026_02_23_900003_create_attendance_comment_template_table.php
+++ b/database/migrations/2026_02_23_900003_create_attendance_comment_template_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('attendance_comment_template')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `attendance_comment_template` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900004_create_attendance_data_table.php b/database/migrations/2026_02_23_900004_create_attendance_data_table.php
index 54e2748a..e31809d3 100644
--- a/database/migrations/2026_02_23_900004_create_attendance_data_table.php
+++ b/database/migrations/2026_02_23_900004_create_attendance_data_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('attendance_data')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `attendance_data` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900005_create_attendance_day_table.php b/database/migrations/2026_02_23_900005_create_attendance_day_table.php
index a5ca690b..15b9a08a 100644
--- a/database/migrations/2026_02_23_900005_create_attendance_day_table.php
+++ b/database/migrations/2026_02_23_900005_create_attendance_day_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('attendance_day')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `attendance_day` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900006_create_attendance_record_table.php b/database/migrations/2026_02_23_900006_create_attendance_record_table.php
index 1af8354a..810fd4bf 100644
--- a/database/migrations/2026_02_23_900006_create_attendance_record_table.php
+++ b/database/migrations/2026_02_23_900006_create_attendance_record_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('attendance_record')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `attendance_record` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900007_create_attendance_tracking_table.php b/database/migrations/2026_02_23_900007_create_attendance_tracking_table.php
index efdb1f0f..0ebb1537 100644
--- a/database/migrations/2026_02_23_900007_create_attendance_tracking_table.php
+++ b/database/migrations/2026_02_23_900007_create_attendance_tracking_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('attendance_tracking')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `attendance_tracking` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900008_create_authorized_users_table.php b/database/migrations/2026_02_23_900008_create_authorized_users_table.php
index 10a7403b..5baa0d5c 100644
--- a/database/migrations/2026_02_23_900008_create_authorized_users_table.php
+++ b/database/migrations/2026_02_23_900008_create_authorized_users_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('authorized_users')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `authorized_users` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900009_create_badge_print_logs_table.php b/database/migrations/2026_02_23_900009_create_badge_print_logs_table.php
index 2c2fc113..abd6a9ef 100644
--- a/database/migrations/2026_02_23_900009_create_badge_print_logs_table.php
+++ b/database/migrations/2026_02_23_900009_create_badge_print_logs_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('badge_print_logs')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `badge_print_logs` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900010_create_calendar_events_table.php b/database/migrations/2026_02_23_900010_create_calendar_events_table.php
index a1fec875..f4768259 100644
--- a/database/migrations/2026_02_23_900010_create_calendar_events_table.php
+++ b/database/migrations/2026_02_23_900010_create_calendar_events_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('calendar_events')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `calendar_events` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900011_create_chapters_table.php b/database/migrations/2026_02_23_900011_create_chapters_table.php
index 413b724e..c944522f 100644
--- a/database/migrations/2026_02_23_900011_create_chapters_table.php
+++ b/database/migrations/2026_02_23_900011_create_chapters_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('chapters')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `chapters` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900012_create_classSection_table.php b/database/migrations/2026_02_23_900012_create_classSection_table.php
index a70247b0..f3767c68 100644
--- a/database/migrations/2026_02_23_900012_create_classSection_table.php
+++ b/database/migrations/2026_02_23_900012_create_classSection_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('classSection')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `classSection` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900013_create_class_prep_adjustments_table.php b/database/migrations/2026_02_23_900013_create_class_prep_adjustments_table.php
index e3845a25..b1c21219 100644
--- a/database/migrations/2026_02_23_900013_create_class_prep_adjustments_table.php
+++ b/database/migrations/2026_02_23_900013_create_class_prep_adjustments_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('class_prep_adjustments')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `class_prep_adjustments` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900014_create_class_preparation_log_table.php b/database/migrations/2026_02_23_900014_create_class_preparation_log_table.php
index 9b0729f1..2f7cf398 100644
--- a/database/migrations/2026_02_23_900014_create_class_preparation_log_table.php
+++ b/database/migrations/2026_02_23_900014_create_class_preparation_log_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('class_preparation_log')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `class_preparation_log` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900017_create_classes_table.php b/database/migrations/2026_02_23_900017_create_classes_table.php
index 6c1069ec..535adb04 100644
--- a/database/migrations/2026_02_23_900017_create_classes_table.php
+++ b/database/migrations/2026_02_23_900017_create_classes_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('classes')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `classes` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900018_create_competition_class_winners_table.php b/database/migrations/2026_02_23_900018_create_competition_class_winners_table.php
index 44ab7a4f..f79f8d80 100644
--- a/database/migrations/2026_02_23_900018_create_competition_class_winners_table.php
+++ b/database/migrations/2026_02_23_900018_create_competition_class_winners_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('competition_class_winners')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `competition_class_winners` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900019_create_competition_scores_table.php b/database/migrations/2026_02_23_900019_create_competition_scores_table.php
index c7cece93..6d64fe9b 100644
--- a/database/migrations/2026_02_23_900019_create_competition_scores_table.php
+++ b/database/migrations/2026_02_23_900019_create_competition_scores_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('competition_scores')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `competition_scores` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900020_create_competition_winners_table.php b/database/migrations/2026_02_23_900020_create_competition_winners_table.php
index d995d59a..8a5f3beb 100644
--- a/database/migrations/2026_02_23_900020_create_competition_winners_table.php
+++ b/database/migrations/2026_02_23_900020_create_competition_winners_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('competition_winners')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `competition_winners` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900021_create_competitions_table.php b/database/migrations/2026_02_23_900021_create_competitions_table.php
index b92a634d..74c6a595 100644
--- a/database/migrations/2026_02_23_900021_create_competitions_table.php
+++ b/database/migrations/2026_02_23_900021_create_competitions_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('competitions')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `competitions` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900022_create_configuration_table.php b/database/migrations/2026_02_23_900022_create_configuration_table.php
index c812d0f1..841df351 100644
--- a/database/migrations/2026_02_23_900022_create_configuration_table.php
+++ b/database/migrations/2026_02_23_900022_create_configuration_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('configuration')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `configuration` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900023_create_contactus_table.php b/database/migrations/2026_02_23_900023_create_contactus_table.php
index d409a261..82714bc9 100644
--- a/database/migrations/2026_02_23_900023_create_contactus_table.php
+++ b/database/migrations/2026_02_23_900023_create_contactus_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('contactus')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `contactus` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900024_create_current_flag_table.php b/database/migrations/2026_02_23_900024_create_current_flag_table.php
index c99961dc..4ff21969 100644
--- a/database/migrations/2026_02_23_900024_create_current_flag_table.php
+++ b/database/migrations/2026_02_23_900024_create_current_flag_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('current_flag')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `current_flag` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900025_create_discount_usages_table.php b/database/migrations/2026_02_23_900025_create_discount_usages_table.php
index a76f37bf..f205e993 100644
--- a/database/migrations/2026_02_23_900025_create_discount_usages_table.php
+++ b/database/migrations/2026_02_23_900025_create_discount_usages_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('discount_usages')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `discount_usages` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900026_create_discount_vouchers_table.php b/database/migrations/2026_02_23_900026_create_discount_vouchers_table.php
index 85be5b45..046a1905 100644
--- a/database/migrations/2026_02_23_900026_create_discount_vouchers_table.php
+++ b/database/migrations/2026_02_23_900026_create_discount_vouchers_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('discount_vouchers')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `discount_vouchers` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900027_create_early_dismissal_signatures_table.php b/database/migrations/2026_02_23_900027_create_early_dismissal_signatures_table.php
index 706a89a3..5f590f18 100644
--- a/database/migrations/2026_02_23_900027_create_early_dismissal_signatures_table.php
+++ b/database/migrations/2026_02_23_900027_create_early_dismissal_signatures_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('early_dismissal_signatures')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `early_dismissal_signatures` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900028_create_email_templates_table.php b/database/migrations/2026_02_23_900028_create_email_templates_table.php
index a2dbbe08..9c02c7d5 100644
--- a/database/migrations/2026_02_23_900028_create_email_templates_table.php
+++ b/database/migrations/2026_02_23_900028_create_email_templates_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('email_templates')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `email_templates` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900029_create_emergency_contacts_table.php b/database/migrations/2026_02_23_900029_create_emergency_contacts_table.php
index 449c8754..08d5c262 100644
--- a/database/migrations/2026_02_23_900029_create_emergency_contacts_table.php
+++ b/database/migrations/2026_02_23_900029_create_emergency_contacts_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('emergency_contacts')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `emergency_contacts` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900030_create_enrollments_table.php b/database/migrations/2026_02_23_900030_create_enrollments_table.php
index 457ac14e..06b39b99 100644
--- a/database/migrations/2026_02_23_900030_create_enrollments_table.php
+++ b/database/migrations/2026_02_23_900030_create_enrollments_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('enrollments')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `enrollments` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900031_create_event_charges_table.php b/database/migrations/2026_02_23_900031_create_event_charges_table.php
index 4d90cf2f..3af9ec10 100644
--- a/database/migrations/2026_02_23_900031_create_event_charges_table.php
+++ b/database/migrations/2026_02_23_900031_create_event_charges_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('event_charges')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `event_charges` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900032_create_events_table.php b/database/migrations/2026_02_23_900032_create_events_table.php
index b57b6cc8..d8927016 100644
--- a/database/migrations/2026_02_23_900032_create_events_table.php
+++ b/database/migrations/2026_02_23_900032_create_events_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('events')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `events` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900033_create_exam_drafts_table.php b/database/migrations/2026_02_23_900033_create_exam_drafts_table.php
index d5909aed..2ed71c79 100644
--- a/database/migrations/2026_02_23_900033_create_exam_drafts_table.php
+++ b/database/migrations/2026_02_23_900033_create_exam_drafts_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('exam_drafts')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `exam_drafts` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900034_create_examquestiondistribution_table.php b/database/migrations/2026_02_23_900034_create_examquestiondistribution_table.php
index b1daa6e2..1f305727 100644
--- a/database/migrations/2026_02_23_900034_create_examquestiondistribution_table.php
+++ b/database/migrations/2026_02_23_900034_create_examquestiondistribution_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('examquestiondistribution')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `examquestiondistribution` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_900035_create_examquestions_table.php b/database/migrations/2026_02_23_900035_create_examquestions_table.php
index 02f5dbd2..d0f9a480 100644
--- a/database/migrations/2026_02_23_900035_create_examquestions_table.php
+++ b/database/migrations/2026_02_23_900035_create_examquestions_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('examquestions')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `examquestions` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_900036_create_exams_table.php b/database/migrations/2026_02_23_900036_create_exams_table.php
index 2b593567..24d1d598 100644
--- a/database/migrations/2026_02_23_900036_create_exams_table.php
+++ b/database/migrations/2026_02_23_900036_create_exams_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('exams')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `exams` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900038_create_families_table.php b/database/migrations/2026_02_23_900038_create_families_table.php
index 8334326f..da256006 100644
--- a/database/migrations/2026_02_23_900038_create_families_table.php
+++ b/database/migrations/2026_02_23_900038_create_families_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('families')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `families` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900039_create_family_comm_prefs_table.php b/database/migrations/2026_02_23_900039_create_family_comm_prefs_table.php
index 2767b891..3e4a3d6e 100644
--- a/database/migrations/2026_02_23_900039_create_family_comm_prefs_table.php
+++ b/database/migrations/2026_02_23_900039_create_family_comm_prefs_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('family_comm_prefs')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `family_comm_prefs` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900040_create_family_guardians_table.php b/database/migrations/2026_02_23_900040_create_family_guardians_table.php
index b5da65d9..d5420daf 100644
--- a/database/migrations/2026_02_23_900040_create_family_guardians_table.php
+++ b/database/migrations/2026_02_23_900040_create_family_guardians_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('family_guardians')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `family_guardians` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900041_create_family_students_table.php b/database/migrations/2026_02_23_900041_create_family_students_table.php
index 9e1316ed..c885c6e7 100644
--- a/database/migrations/2026_02_23_900041_create_family_students_table.php
+++ b/database/migrations/2026_02_23_900041_create_family_students_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('family_students')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `family_students` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900042_create_final_exam_table.php b/database/migrations/2026_02_23_900042_create_final_exam_table.php
index a1440120..9d62308a 100644
--- a/database/migrations/2026_02_23_900042_create_final_exam_table.php
+++ b/database/migrations/2026_02_23_900042_create_final_exam_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('final_exam')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `final_exam` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900043_create_final_score_table.php b/database/migrations/2026_02_23_900043_create_final_score_table.php
index 323a955f..517bb6ce 100644
--- a/database/migrations/2026_02_23_900043_create_final_score_table.php
+++ b/database/migrations/2026_02_23_900043_create_final_score_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('final_score')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `final_score` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900044_create_longanswerquestions_table.php b/database/migrations/2026_02_23_900044_create_longanswerquestions_table.php
index f26e246c..b0747618 100644
--- a/database/migrations/2026_02_23_900044_create_longanswerquestions_table.php
+++ b/database/migrations/2026_02_23_900044_create_longanswerquestions_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('longanswerquestions')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `longanswerquestions` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_900046_create_notification_recipients_table.php b/database/migrations/2026_02_23_900046_create_notification_recipients_table.php
index 42ad9103..6fbdc8e9 100644
--- a/database/migrations/2026_02_23_900046_create_notification_recipients_table.php
+++ b/database/migrations/2026_02_23_900046_create_notification_recipients_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('notification_recipients')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `notification_recipients` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900047_create_preferences_table.php b/database/migrations/2026_02_23_900047_create_preferences_table.php
index 89221b17..8c0e8001 100644
--- a/database/migrations/2026_02_23_900047_create_preferences_table.php
+++ b/database/migrations/2026_02_23_900047_create_preferences_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('preferences')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `preferences` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_900048_create_qcmquestions_table.php b/database/migrations/2026_02_23_900048_create_qcmquestions_table.php
index ba1e29de..ad1996ea 100644
--- a/database/migrations/2026_02_23_900048_create_qcmquestions_table.php
+++ b/database/migrations/2026_02_23_900048_create_qcmquestions_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('qcmquestions')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `qcmquestions` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_900049_create_questions_table.php b/database/migrations/2026_02_23_900049_create_questions_table.php
index 30d83fe2..37bc6e83 100644
--- a/database/migrations/2026_02_23_900049_create_questions_table.php
+++ b/database/migrations/2026_02_23_900049_create_questions_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('questions')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `questions` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_900050_create_questionstruefalse_table.php b/database/migrations/2026_02_23_900050_create_questionstruefalse_table.php
index a9887a39..3db1d088 100644
--- a/database/migrations/2026_02_23_900050_create_questionstruefalse_table.php
+++ b/database/migrations/2026_02_23_900050_create_questionstruefalse_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('questionstruefalse')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `questionstruefalse` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_900051_create_questiontypes_table.php b/database/migrations/2026_02_23_900051_create_questiontypes_table.php
index 27adce36..49d517fb 100644
--- a/database/migrations/2026_02_23_900051_create_questiontypes_table.php
+++ b/database/migrations/2026_02_23_900051_create_questiontypes_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('questiontypes')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `questiontypes` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_900052_create_scan_log_table.php b/database/migrations/2026_02_23_900052_create_scan_log_table.php
index 81add41f..74b74e86 100644
--- a/database/migrations/2026_02_23_900052_create_scan_log_table.php
+++ b/database/migrations/2026_02_23_900052_create_scan_log_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('scan_log')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `scan_log` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900053_create_shortanswerquestions_table.php b/database/migrations/2026_02_23_900053_create_shortanswerquestions_table.php
index 4f960550..7d56ca28 100644
--- a/database/migrations/2026_02_23_900053_create_shortanswerquestions_table.php
+++ b/database/migrations/2026_02_23_900053_create_shortanswerquestions_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('shortanswerquestions')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `shortanswerquestions` (
`id` int NOT NULL,
diff --git a/database/migrations/2026_02_23_900054_create_support_requests_table.php b/database/migrations/2026_02_23_900054_create_support_requests_table.php
index d7c0a65e..650da0d0 100644
--- a/database/migrations/2026_02_23_900054_create_support_requests_table.php
+++ b/database/migrations/2026_02_23_900054_create_support_requests_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('support_requests')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `support_requests` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900055_create_teacher_attendance_data_table.php b/database/migrations/2026_02_23_900055_create_teacher_attendance_data_table.php
index 21334af6..a75883a4 100644
--- a/database/migrations/2026_02_23_900055_create_teacher_attendance_data_table.php
+++ b/database/migrations/2026_02_23_900055_create_teacher_attendance_data_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('teacher_attendance_data')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `teacher_attendance_data` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_02_23_900056_create_communication_logs_table.php b/database/migrations/2026_02_23_900056_create_communication_logs_table.php
index d4083ca4..5aa429b7 100644
--- a/database/migrations/2026_02_23_900056_create_communication_logs_table.php
+++ b/database/migrations/2026_02_23_900056_create_communication_logs_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('communication_logs')) {
+ return;
+ }
+
App\Support\SqliteCompat::statement(<<<'SQL'
CREATE TABLE `communication_logs` (
`id` int UNSIGNED NOT NULL,
diff --git a/database/migrations/2026_03_09_050312_create_personal_access_tokens_table.php b/database/migrations/2026_03_09_050312_create_personal_access_tokens_table.php
index 40ff706e..6ac41a5e 100644
--- a/database/migrations/2026_03_09_050312_create_personal_access_tokens_table.php
+++ b/database/migrations/2026_03_09_050312_create_personal_access_tokens_table.php
@@ -11,6 +11,10 @@ return new class extends Migration
*/
public function up(): void
{
+ if (Schema::hasTable('personal_access_tokens')) {
+ return;
+ }
+
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
diff --git a/database/migrations/2026_03_09_064908_create_sessions_table.php b/database/migrations/2026_03_09_064908_create_sessions_table.php
index f60625bb..9210f600 100644
--- a/database/migrations/2026_03_09_064908_create_sessions_table.php
+++ b/database/migrations/2026_03_09_064908_create_sessions_table.php
@@ -11,6 +11,10 @@ return new class extends Migration
*/
public function up(): void
{
+ if (Schema::hasTable('sessions')) {
+ return;
+ }
+
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
diff --git a/database/migrations/2026_03_09_070000_create_suppliers_table.php b/database/migrations/2026_03_09_070000_create_suppliers_table.php
index 34990c06..187de84e 100644
--- a/database/migrations/2026_03_09_070000_create_suppliers_table.php
+++ b/database/migrations/2026_03_09_070000_create_suppliers_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('suppliers')) {
+ return;
+ }
+
Schema::create('suppliers', function (Blueprint $table) {
$table->id();
$table->string('name');
diff --git a/database/migrations/2026_03_09_070010_create_supplies_table.php b/database/migrations/2026_03_09_070010_create_supplies_table.php
index 01741b29..96e409ef 100644
--- a/database/migrations/2026_03_09_070010_create_supplies_table.php
+++ b/database/migrations/2026_03_09_070010_create_supplies_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('supplies')) {
+ return;
+ }
+
Schema::create('supplies', function (Blueprint $table) {
$table->id();
$table->string('name');
diff --git a/database/migrations/2026_03_09_070020_create_supply_transactions_table.php b/database/migrations/2026_03_09_070020_create_supply_transactions_table.php
index e292f37e..661c0fd4 100644
--- a/database/migrations/2026_03_09_070020_create_supply_transactions_table.php
+++ b/database/migrations/2026_03_09_070020_create_supply_transactions_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('supply_transactions')) {
+ return;
+ }
+
Schema::create('supply_transactions', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('supply_id');
diff --git a/database/migrations/2026_03_09_070030_create_purchase_orders_table.php b/database/migrations/2026_03_09_070030_create_purchase_orders_table.php
index a676498d..ea9d53c6 100644
--- a/database/migrations/2026_03_09_070030_create_purchase_orders_table.php
+++ b/database/migrations/2026_03_09_070030_create_purchase_orders_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('purchase_orders')) {
+ return;
+ }
+
Schema::create('purchase_orders', function (Blueprint $table) {
$table->id();
$table->string('po_number')->nullable();
diff --git a/database/migrations/2026_03_09_070040_create_purchase_order_items_table.php b/database/migrations/2026_03_09_070040_create_purchase_order_items_table.php
index a589cfbe..24328653 100644
--- a/database/migrations/2026_03_09_070040_create_purchase_order_items_table.php
+++ b/database/migrations/2026_03_09_070040_create_purchase_order_items_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('purchase_order_items')) {
+ return;
+ }
+
Schema::create('purchase_order_items', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('purchase_order_id');
diff --git a/database/migrations/2026_03_09_070050_create_supply_categories_table.php b/database/migrations/2026_03_09_070050_create_supply_categories_table.php
index aa8d44cd..4a68e8dd 100644
--- a/database/migrations/2026_03_09_070050_create_supply_categories_table.php
+++ b/database/migrations/2026_03_09_070050_create_supply_categories_table.php
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
+ if (Schema::hasTable('supply_categories')) {
+ return;
+ }
+
Schema::create('supply_categories', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
diff --git a/database/migrations/2026_03_11_045324_create_cache_table.php b/database/migrations/2026_03_11_045324_create_cache_table.php
index ed758bdf..5799efc1 100644
--- a/database/migrations/2026_03_11_045324_create_cache_table.php
+++ b/database/migrations/2026_03_11_045324_create_cache_table.php
@@ -11,6 +11,10 @@ return new class extends Migration
*/
public function up(): void
{
+ if (Schema::hasTable('cache')) {
+ return;
+ }
+
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
diff --git a/database/migrations/2026_03_11_205736_create_students_table.php b/database/migrations/2026_03_11_205736_create_students_table.php
new file mode 100644
index 00000000..ad7729a6
--- /dev/null
+++ b/database/migrations/2026_03_11_205736_create_students_table.php
@@ -0,0 +1,31 @@
+id();
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('students');
+ }
+};
diff --git a/database/migrations/2026_03_11_205736_create_teachers_table.php b/database/migrations/2026_03_11_205736_create_teachers_table.php
new file mode 100644
index 00000000..c59fef41
--- /dev/null
+++ b/database/migrations/2026_03_11_205736_create_teachers_table.php
@@ -0,0 +1,31 @@
+id();
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('teachers');
+ }
+};
diff --git a/database/migrations/2026_03_11_205737_create_attendance_data_table.php b/database/migrations/2026_03_11_205737_create_attendance_data_table.php
new file mode 100644
index 00000000..cc540779
--- /dev/null
+++ b/database/migrations/2026_03_11_205737_create_attendance_data_table.php
@@ -0,0 +1,31 @@
+id();
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('attendance_data');
+ }
+};
diff --git a/public/html/privacy_policy.html b/public/html/privacy_policy.html
new file mode 100644
index 00000000..ff29a22b
--- /dev/null
+++ b/public/html/privacy_policy.html
@@ -0,0 +1 @@
+Privacy
\ No newline at end of file
diff --git a/public/html/terms_of_service.html b/public/html/terms_of_service.html
new file mode 100644
index 00000000..e8035da8
--- /dev/null
+++ b/public/html/terms_of_service.html
@@ -0,0 +1 @@
+Terms
\ No newline at end of file
diff --git a/resources/policies/picture_policy.html b/resources/policies/picture_policy.html
new file mode 100644
index 00000000..60906f78
--- /dev/null
+++ b/resources/policies/picture_policy.html
@@ -0,0 +1,2 @@
+Picture Policy
+Picture policy content is provided by the administration. Please review the latest version for updates.
diff --git a/resources/policies/school_policy.html b/resources/policies/school_policy.html
new file mode 100644
index 00000000..abb6c8e6
--- /dev/null
+++ b/resources/policies/school_policy.html
@@ -0,0 +1,2 @@
+School Policy
+School policy content is provided by the administration. Please review the latest version for updates.
diff --git a/routes/api.php b/routes/api.php
index f3cb36a6..d878b1bf 100755
--- a/routes/api.php
+++ b/routes/api.php
@@ -24,7 +24,7 @@ use App\Http\Controllers\Api\Parents\ParentAttendanceReportController as ParentA
use App\Http\Controllers\Api\AttendanceTracking\AttendanceTrackingController;
use App\Http\Controllers\Api\Email\BroadcastEmailController;
use App\Http\Controllers\Api\Settings\ConfigurationController;
-use App\Http\Controllers\Api\Staff\ContactController;
+use App\Http\Controllers\Api\Staff\StaffController;
use App\Http\Controllers\Api\ClassPreparation\ClassPreparationController;
use App\Http\Controllers\Api\ClassPrep\ClassPrepController;
use App\Http\Controllers\Api\Email\EmailController;
@@ -36,6 +36,7 @@ use App\Http\Controllers\Api\Expenses\ExpenseController;
use App\Http\Controllers\Api\ExtraCharges\ExtraChargesController;
use App\Http\Controllers\Api\Parents\EmergencyContactController;
use App\Http\Controllers\Api\Settings\EventController;
+use App\Http\Controllers\Api\Settings\PolicyController;
use App\Http\Controllers\Api\Family\FamilyAdminController;
use App\Http\Controllers\Api\Family\FamilyController;
use App\Http\Controllers\Api\Reports\FilesController;
@@ -44,12 +45,16 @@ use App\Http\Controllers\Api\Reports\StickersController;
use App\Http\Controllers\Api\Scores\FinalController;
use App\Http\Controllers\Api\Finance\FinancialController;
use App\Http\Controllers\Api\System\FlagController;
+use App\Http\Controllers\Api\System\DashboardController;
+use App\Http\Controllers\Api\System\DatabaseHealthController;
+use App\Http\Controllers\Api\System\NavBuilderController;
use App\Http\Controllers\Api\Scores\GradingController;
use App\Http\Controllers\Api\System\HealthController;
use App\Http\Controllers\Api\System\SchoolIdController;
use App\Http\Controllers\Api\System\SemesterRangeController;
use App\Http\Controllers\Api\Scores\HomeworkController;
use App\Http\Controllers\Api\Frontend\InfoIconController;
+use App\Http\Controllers\Api\Frontend\FrontendController;
use App\Http\Controllers\Api\Inventory\InventoryController;
use App\Http\Controllers\Api\Inventory\InventoryCategoryController;
use App\Http\Controllers\Api\Inventory\InventoryMovementController;
@@ -58,15 +63,8 @@ use App\Http\Controllers\Api\Frontend\LandingPageController;
use App\Http\Controllers\Api\Attendance\LateSlipLogsController;
use App\Http\Controllers\Api\Messaging\MessagesController;
use App\Http\Controllers\Api\Incidents\IncidentController as IncidentApiController;
-use App\Http\Controllers\Api\Scores\MidtermController;
-use App\Http\Controllers\Api\System\NavBuilderController;
use App\Http\Controllers\Api\Notifications\NotificationManagementController;
use App\Http\Controllers\Api\Frontend\PageController;
-use App\Http\Controllers\Api\Scores\ParticipationController;
-use App\Http\Controllers\Api\Settings\PolicyController;
-use App\Http\Controllers\Api\Reports\PrintablesReportsController;
-use App\Http\Controllers\Api\Scores\ProjectController;
-use App\Http\Controllers\Api\Scores\QuizController;
use App\Http\Controllers\Api\Finance\PaymentController;
use App\Http\Controllers\Api\Finance\RefundController;
use App\Http\Controllers\Api\Finance\PaymentNotificationController;
@@ -82,17 +80,15 @@ use App\Http\Controllers\Api\Auth\RegisterController;
use App\Http\Controllers\Api\RolePermissionController;
use App\Http\Controllers\Api\RoleSwitcherController;
use App\Http\Controllers\Api\Settings\SchoolCalendarController;
-use App\Http\Controllers\Api\Scores\ScoreCommentController;
-use App\Http\Controllers\Api\Scores\ScoreController;
use App\Http\Controllers\Api\Scores\ScorePredictorController;
-use App\Http\Controllers\Api\Auth\SessionTimeoutController;
use App\Http\Controllers\Api\Reports\SlipPrinterController;
-use App\Http\Controllers\Api\Staff\StaffController;
use App\Http\Controllers\Api\Inventory\SupplierController;
use App\Http\Controllers\Api\Inventory\SupplyCategoryController;
use App\Http\Controllers\Api\Staff\TeacherController;
use App\Http\Controllers\Api\StatsController;
-use App\Http\Controllers\Api\Frontend\UiController;
+use App\Http\Controllers\Api\Ui\UiController;
+use App\Http\Controllers\Api\Support\ContactController as SupportContactController;
+use App\Http\Controllers\Api\Support\SupportController as ApiSupportController;
use App\Http\Controllers\Api\Scores\ProjectController as ProjectScoreController;
use App\Http\Controllers\Api\Scores\MidtermController as MidtermScoreController;
use App\Http\Controllers\Api\Scores\FinalController as FinalScoreController;
@@ -108,6 +104,10 @@ use App\Http\Controllers\Api\Subjects\SubjectCurriculumController as SubjectCurr
use App\Http\Controllers\Api\Exams\ExamDraftController as ExamDraftApiController;
use App\Http\Controllers\Api\Settings\ConfigurationAdminController;
use App\Http\Controllers\Api\Notifications\NotificationController as ApiNotificationController;
+use App\Http\Controllers\Api\Utilities\PhoneFormatterController;
+use App\Http\Controllers\Api\Settings\PreferencesController;
+use App\Http\Controllers\Api\Settings\SettingsController;
+use App\Http\Controllers\Api\Classes\ClassController as ClassSectionController;
use App\Http\Controllers\Api\Attendance\AdminAttendanceApiController;
use App\Http\Controllers\Api\Attendance\AttendanceCommentTemplateController;
@@ -124,6 +124,64 @@ Route::prefix('v1')->group(function () {
Route::get('me', [AuthController::class, 'me'])->middleware('auth.multi');
});
+ Route::prefix('policies')->group(function () {
+ Route::get('school', [PolicyController::class, 'school']);
+ Route::get('picture', [PolicyController::class, 'picture']);
+ });
+
+ Route::prefix('pages')->group(function () {
+ Route::get('privacy', [PageController::class, 'privacyPolicy']);
+ Route::get('terms', [PageController::class, 'termsOfService']);
+ Route::get('help', [PageController::class, 'helpCenter']);
+ Route::post('contact', [PageController::class, 'submitContact']);
+ });
+
+ Route::post('contact', [SupportContactController::class, 'send']);
+
+ Route::prefix('frontend')->group(function () {
+ Route::get('/', [FrontendController::class, 'index']);
+ Route::get('facility', [FrontendController::class, 'facility']);
+ Route::get('team', [FrontendController::class, 'team']);
+ Route::get('call-to-action', [FrontendController::class, 'callToAction']);
+ Route::get('testimonial', [FrontendController::class, 'testimonial']);
+ Route::get('not-found', [FrontendController::class, 'notFound']);
+ Route::get('me', [FrontendController::class, 'fetchUser'])->middleware('auth.multi');
+ });
+
+ Route::get('health', [HealthController::class, 'index']);
+ Route::get('system/db-check', [DatabaseHealthController::class, 'index']);
+
+ Route::middleware('auth.multi')->prefix('preferences')->group(function () {
+ Route::get('/', [PreferencesController::class, 'show']);
+ Route::post('/', [PreferencesController::class, 'store']);
+ Route::get('list', [PreferencesController::class, 'index']);
+ Route::get('{userId}', [PreferencesController::class, 'showForUser']);
+ Route::put('{userId}', [PreferencesController::class, 'updateForUser']);
+ Route::delete('{userId}', [PreferencesController::class, 'destroy']);
+ });
+
+ Route::middleware('auth.multi')->prefix('nav-builder')->group(function () {
+ Route::get('menu', [NavBuilderController::class, 'menu']);
+ Route::get('data', [NavBuilderController::class, 'data']);
+ Route::post('/', [NavBuilderController::class, 'store']);
+ Route::delete('{id}', [NavBuilderController::class, 'destroy']);
+ Route::post('reorder', [NavBuilderController::class, 'reorder']);
+ });
+
+ Route::middleware('auth.multi')->prefix('landing')->group(function () {
+ Route::get('/', [LandingPageController::class, 'index']);
+ Route::get('teacher', [LandingPageController::class, 'teacher']);
+ Route::get('parent', [LandingPageController::class, 'parent']);
+ Route::get('administrator', [LandingPageController::class, 'administrator']);
+ Route::get('admin', [LandingPageController::class, 'admin']);
+ Route::get('student', [LandingPageController::class, 'student']);
+ Route::get('guest', [LandingPageController::class, 'guest']);
+ });
+
+ Route::middleware('auth.multi')->prefix('info-icon')->group(function () {
+ Route::get('profile', [InfoIconController::class, 'profileIcon']);
+ });
+
Route::middleware('auth.multi')->prefix('administrator')->group(function () {
Route::get('absence', [AdministratorAbsenceController::class, 'index']);
Route::post('absence', [AdministratorAbsenceController::class, 'store']);
@@ -170,6 +228,12 @@ Route::prefix('v1')->group(function () {
Route::post('/staff/admins/save', [StaffAttendanceApiController::class, 'saveAdmins']);
Route::post('/staff/cell', [StaffAttendanceApiController::class, 'saveCell']);
Route::get('/staff/month-csv', [StaffAttendanceApiController::class, 'monthCsv']);
+
+ Route::prefix('late-slip-logs')->group(function () {
+ Route::get('/', [LateSlipLogsController::class, 'index']);
+ Route::get('{id}', [LateSlipLogsController::class, 'show']);
+ Route::delete('{id}', [LateSlipLogsController::class, 'destroy']);
+ });
});
Route::prefix('attendance-tracking')->group(function () {
@@ -191,6 +255,66 @@ Route::prefix('v1')->group(function () {
});
Route::middleware('auth.multi')->group(function () {
+ Route::get('dashboard/route', [DashboardController::class, 'route']);
+
+ Route::prefix('utilities/phone')->group(function () {
+ Route::post('format', [PhoneFormatterController::class, 'format']);
+ });
+
+ Route::prefix('ui')->group(function () {
+ Route::post('style', [UiController::class, 'style']);
+ });
+
+ Route::prefix('support')->group(function () {
+ Route::get('/', [ApiSupportController::class, 'index']);
+ Route::post('/', [ApiSupportController::class, 'store']);
+ Route::get('teacher', [ApiSupportController::class, 'teacher']);
+ });
+
+ Route::prefix('staff')->group(function () {
+ Route::get('/', [StaffController::class, 'index']);
+ Route::get('{id}', [StaffController::class, 'show']);
+ Route::post('/', [StaffController::class, 'store']);
+ Route::patch('{id}', [StaffController::class, 'update']);
+ Route::delete('{id}', [StaffController::class, 'destroy']);
+ });
+
+ Route::prefix('messages')->group(function () {
+ Route::get('/', [MessagesController::class, 'index']);
+ Route::get('inbox', [MessagesController::class, 'inbox']);
+ Route::get('sent', [MessagesController::class, 'sent']);
+ Route::get('drafts', [MessagesController::class, 'drafts']);
+ Route::get('trash', [MessagesController::class, 'trash']);
+ Route::post('/', [MessagesController::class, 'store']);
+ Route::post('receive', [MessagesController::class, 'receive']);
+ Route::get('recipients/{type}', [MessagesController::class, 'recipients']);
+ Route::get('{id}', [MessagesController::class, 'show']);
+ Route::patch('{id}', [MessagesController::class, 'update']);
+ Route::delete('{id}', [MessagesController::class, 'destroy']);
+ });
+
+ Route::prefix('settings')->group(function () {
+ Route::get('/', [SettingsController::class, 'index']);
+ Route::patch('/', [SettingsController::class, 'update']);
+ });
+
+ Route::prefix('ip-bans')->group(function () {
+ Route::get('/', [IpBanController::class, 'index']);
+ Route::get('{id}', [IpBanController::class, 'show']);
+ Route::post('ban', [IpBanController::class, 'ban']);
+ Route::post('unban', [IpBanController::class, 'unban']);
+ });
+
+ Route::prefix('class-sections')->group(function () {
+ Route::get('/', [ClassSectionController::class, 'index']);
+ Route::post('/', [ClassSectionController::class, 'store']);
+ Route::get('{classSection}', [ClassSectionController::class, 'show']);
+ Route::put('{classSection}', [ClassSectionController::class, 'update']);
+ Route::delete('{classSection}', [ClassSectionController::class, 'destroy']);
+ Route::get('{classSectionId}/attendance', [ClassSectionController::class, 'attendance']);
+ Route::post('seed-defaults', [ClassSectionController::class, 'seedDefaults']);
+ });
+
Route::get('stats', [StatsController::class, 'index']);
Route::prefix('notifications')->group(function () {
Route::get('/', [ApiNotificationController::class, 'index']);
@@ -203,11 +327,6 @@ Route::prefix('v1')->group(function () {
Route::post('{notificationId}/restore', [ApiNotificationController::class, 'restore']);
Route::post('{notificationId}/read', [ApiNotificationController::class, 'markRead']);
});
- Route::prefix('session')->group(function () {
- Route::get('timeout-config', [SessionTimeoutController::class, 'config'])->name('session.timeout.config');
- Route::get('check-timeout', [SessionTimeoutController::class, 'check'])->name('session.timeout.check');
- Route::post('ping', [SessionTimeoutController::class, 'ping'])->name('session.timeout.ping');
- });
Route::prefix('role-permissions')->group(function () {
Route::get('roles', [RolePermissionController::class, 'roles']);
Route::post('roles', [RolePermissionController::class, 'storeRole']);
@@ -681,6 +800,8 @@ Route::prefix('v1')->group(function () {
Route::prefix('class-prep')->group(function () {
Route::get('/', [ClassPreparationController::class, 'index']);
Route::post('mark-printed', [ClassPreparationController::class, 'markPrinted']);
+ Route::post('adjustments', [ClassPreparationController::class, 'saveAdjustments']);
+ Route::post('print/{classSectionId}/{schoolYear}', [ClassPreparationController::class, 'print']);
Route::get('sticker-counts', [ClassPrepController::class, 'stickerCounts']);
Route::get('classes/{classSectionId}/students', [ClassPrepController::class, 'studentsByClass']);
});
@@ -732,6 +853,8 @@ Route::middleware('auth.multi')->group(function () {
Route::prefix('class-prep')->group(function () {
Route::get('/', [ClassPreparationController::class, 'index']);
Route::post('mark-printed', [ClassPreparationController::class, 'markPrinted']);
+ Route::post('adjustments', [ClassPreparationController::class, 'saveAdjustments']);
+ Route::post('print/{classSectionId}/{schoolYear}', [ClassPreparationController::class, 'print']);
Route::get('sticker-counts', [ClassPrepController::class, 'stickerCounts']);
Route::get('classes/{classSectionId}/students', [ClassPrepController::class, 'studentsByClass']);
});
diff --git a/tests/Feature/Api/V1/Attendance/LateSlipLogsControllerTest.php b/tests/Feature/Api/V1/Attendance/LateSlipLogsControllerTest.php
new file mode 100644
index 00000000..42b16cd4
--- /dev/null
+++ b/tests/Feature/Api/V1/Attendance/LateSlipLogsControllerTest.php
@@ -0,0 +1,136 @@
+createUser('teacher');
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/attendance/late-slip-logs');
+
+ $response->assertForbidden();
+ }
+
+ public function test_index_returns_logs(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ DB::table('late_slip_logs')->insert([
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'student_name' => 'Student One',
+ 'slip_date' => '2025-09-01',
+ 'time_in' => '08:10:00',
+ 'grade' => '3',
+ 'reason' => 'Traffic',
+ 'admin_name' => 'Admin User',
+ ]);
+
+ $response = $this->getJson('/api/v1/attendance/late-slip-logs?school_year=2025-2026');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.logs.0.student_name', 'Student One');
+ $response->assertJsonStructure(['data' => ['meta']]);
+ }
+
+ public function test_show_returns_log(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ DB::table('late_slip_logs')->insert([
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'student_name' => 'Student Two',
+ 'slip_date' => '2025-09-02',
+ 'time_in' => '08:15:00',
+ 'grade' => '2',
+ 'reason' => 'Bus delay',
+ 'admin_name' => 'Admin User',
+ ]);
+
+ $response = $this->getJson('/api/v1/attendance/late-slip-logs/1');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.log.student_name', 'Student Two');
+ }
+
+ public function test_destroy_deletes_log(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ DB::table('late_slip_logs')->insert([
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'student_name' => 'Student Three',
+ 'slip_date' => '2025-09-03',
+ 'time_in' => '08:20:00',
+ 'grade' => '1',
+ 'reason' => 'Weather',
+ 'admin_name' => 'Admin User',
+ ]);
+
+ $response = $this->deleteJson('/api/v1/attendance/late-slip-logs/1');
+
+ $response->assertOk();
+ $this->assertDatabaseMissing('late_slip_logs', [
+ 'id' => 1,
+ ]);
+ }
+
+ public function test_index_validation_rejects_bad_dates(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $response = $this->getJson('/api/v1/attendance/late-slip-logs?date_from=not-a-date');
+
+ $response->assertStatus(422);
+ $response->assertJsonStructure(['message', 'errors']);
+ }
+
+ private function createUser(string $roleName): User
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => $roleName,
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => $roleName . '@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ return $user;
+ }
+}
diff --git a/tests/Feature/Api/V1/Auth/IpBanControllerTest.php b/tests/Feature/Api/V1/Auth/IpBanControllerTest.php
new file mode 100644
index 00000000..d12badd0
--- /dev/null
+++ b/tests/Feature/Api/V1/Auth/IpBanControllerTest.php
@@ -0,0 +1,182 @@
+createUser('teacher');
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/ip-bans');
+
+ $response->assertForbidden();
+ }
+
+ public function test_index_returns_bans(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ DB::table('ip_attempts')->insert([
+ 'ip_address' => '10.0.0.1',
+ 'attempts' => 5,
+ 'blocked_until' => now('UTC')->addHours(2),
+ ]);
+
+ $response = $this->getJson('/api/v1/ip-bans?status=active');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.bans.0.ip_address', '10.0.0.1');
+ $response->assertJsonStructure(['data' => ['meta']]);
+ }
+
+ public function test_show_returns_ban(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ DB::table('ip_attempts')->insert([
+ 'ip_address' => '10.0.0.2',
+ 'attempts' => 3,
+ 'blocked_until' => now('UTC')->addHours(1),
+ ]);
+
+ $response = $this->getJson('/api/v1/ip-bans/1');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.ban.ip_address', '10.0.0.2');
+ }
+
+ public function test_ban_creates_block(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ DB::table('ip_attempts')->insert([
+ 'ip_address' => '10.0.0.3',
+ 'attempts' => 1,
+ ]);
+
+ $response = $this->postJson('/api/v1/ip-bans/ban', [
+ 'ip' => '10.0.0.3',
+ 'hours' => 12,
+ ]);
+
+ $response->assertOk();
+ $response->assertJsonPath('data.ban.ip_address', '10.0.0.3');
+ $this->assertDatabaseHas('ip_attempts', [
+ 'ip_address' => '10.0.0.3',
+ ]);
+ }
+
+ public function test_unban_single(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ DB::table('ip_attempts')->insert([
+ 'ip_address' => '10.0.0.4',
+ 'attempts' => 5,
+ 'blocked_until' => now('UTC')->addHours(2),
+ ]);
+
+ $response = $this->postJson('/api/v1/ip-bans/unban', [
+ 'ip' => '10.0.0.4',
+ ]);
+
+ $response->assertOk();
+ $this->assertDatabaseHas('ip_attempts', [
+ 'ip_address' => '10.0.0.4',
+ 'blocked_until' => null,
+ 'attempts' => 0,
+ ]);
+ }
+
+ public function test_unban_all(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ DB::table('ip_attempts')->insert([
+ [
+ 'ip_address' => '10.0.0.5',
+ 'attempts' => 5,
+ 'blocked_until' => now('UTC')->addHours(2),
+ ],
+ [
+ 'ip_address' => '10.0.0.6',
+ 'attempts' => 2,
+ 'blocked_until' => now('UTC')->addHours(2),
+ ],
+ ]);
+
+ $response = $this->postJson('/api/v1/ip-bans/unban', [
+ 'all' => true,
+ ]);
+
+ $response->assertOk();
+ $this->assertDatabaseHas('ip_attempts', [
+ 'ip_address' => '10.0.0.5',
+ 'blocked_until' => null,
+ ]);
+ $this->assertDatabaseHas('ip_attempts', [
+ 'ip_address' => '10.0.0.6',
+ 'blocked_until' => null,
+ ]);
+ }
+
+ public function test_ban_validation_requires_ip_or_id(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $response = $this->postJson('/api/v1/ip-bans/ban', [
+ 'hours' => 12,
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonStructure(['message', 'errors']);
+ }
+
+ private function createUser(string $roleName): User
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => $roleName,
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => $roleName . '@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ return $user;
+ }
+}
diff --git a/tests/Feature/Api/V1/Auth/RegisterControllerTest.php b/tests/Feature/Api/V1/Auth/RegisterControllerTest.php
index 7d14bffc..f48727b8 100644
--- a/tests/Feature/Api/V1/Auth/RegisterControllerTest.php
+++ b/tests/Feature/Api/V1/Auth/RegisterControllerTest.php
@@ -39,6 +39,8 @@ class RegisterControllerTest extends TestCase
'is_active' => 1,
]);
+ $captcha = $this->getJson('/api/v1/auth/register/captcha')->json('captcha');
+
$payload = [
'firstname' => 'Parent',
'lastname' => 'User',
@@ -52,13 +54,12 @@ class RegisterControllerTest extends TestCase
'state' => 'CT',
'zip' => '12345',
'accept_school_policy' => 1,
- 'captcha' => 'ABCD',
+ 'captcha' => $captcha,
'is_parent' => 1,
'no_second_parent_info' => 1,
];
- $response = $this->withSession(['captcha_answer' => 'ABCD'])
- ->postJson('/api/v1/auth/register', $payload);
+ $response = $this->postJson('/api/v1/auth/register', $payload);
$response->assertCreated();
$response->assertJsonPath('ok', true);
diff --git a/tests/Feature/Api/V1/Auth/SessionTimeoutControllerTest.php b/tests/Feature/Api/V1/Auth/SessionTimeoutControllerTest.php
deleted file mode 100644
index 26f576c4..00000000
--- a/tests/Feature/Api/V1/Auth/SessionTimeoutControllerTest.php
+++ /dev/null
@@ -1,123 +0,0 @@
-seedUser();
- Sanctum::actingAs($user);
-
- $response = $this->getJson('/api/v1/session/timeout-config');
-
- $response->assertOk();
- $response->assertJsonPath('status', true);
- $this->assertArrayHasKey('timeout', $response->json('data.config'));
- $this->assertArrayHasKey('keep_alive_url', $response->json('data.config'));
- }
-
- public function test_check_timeout_returns_active_status(): void
- {
- $user = $this->seedUser();
- Sanctum::actingAs($user);
-
- $response = $this
- ->withSession(['last_activity' => time() - 10])
- ->getJson('/api/v1/session/check-timeout');
-
- $response->assertOk();
- $response->assertJsonPath('status', true);
- $response->assertJsonPath('data.session.status', 'active');
- }
-
- public function test_check_timeout_returns_warning_status(): void
- {
- $user = $this->seedUser();
- Sanctum::actingAs($user);
-
- $response = $this
- ->withSession(['last_activity' => time() - 1000])
- ->getJson('/api/v1/session/check-timeout');
-
- $response->assertOk();
- $response->assertJsonPath('status', true);
- $response->assertJsonPath('data.session.status', 'warning');
- }
-
- public function test_check_timeout_returns_expired_status(): void
- {
- $user = $this->seedUser();
- Sanctum::actingAs($user);
-
- $response = $this
- ->withSession(['last_activity' => time() - 5000])
- ->getJson('/api/v1/session/check-timeout');
-
- $response->assertStatus(401);
- $response->assertJsonPath('status', false);
- $response->assertJsonPath('errors.status', 'expired');
- }
-
- public function test_ping_updates_activity(): void
- {
- $user = $this->seedUser();
- Sanctum::actingAs($user);
-
- $response = $this
- ->withSession(['last_activity' => time() - 10])
- ->postJson('/api/v1/session/ping');
-
- $response->assertOk();
- $response->assertJsonPath('status', true);
- $response->assertJsonPath('data.session.status', 'active');
- }
-
- public function test_ping_returns_expired_when_missing_activity(): void
- {
- $user = $this->seedUser();
- Sanctum::actingAs($user);
-
- $response = $this->postJson('/api/v1/session/ping');
-
- $response->assertStatus(401);
- $response->assertJsonPath('status', false);
- }
-
- public function test_requires_authentication(): void
- {
- $response = $this->getJson('/api/v1/session/timeout-config');
-
- $response->assertStatus(401);
- }
-
- private function seedUser(): User
- {
- $userId = DB::table('users')->insertGetId([
- 'firstname' => 'Admin',
- 'lastname' => 'User',
- 'cellphone' => '9999999999',
- 'email' => 'session@example.com',
- 'address_street' => '123 Street',
- 'city' => 'City',
- 'state' => 'ST',
- 'zip' => '12345',
- 'accept_school_policy' => 1,
- 'password' => bcrypt('password'),
- 'user_type' => 'primary',
- 'semester' => 'Fall',
- 'school_year' => '2025-2026',
- 'status' => 'Active',
- ]);
-
- return User::query()->findOrFail($userId);
- }
-}
diff --git a/tests/Feature/Api/V1/ClassPreparation/ClassPreparationControllerTest.php b/tests/Feature/Api/V1/ClassPreparation/ClassPreparationControllerTest.php
index f7b639f4..d4b66594 100644
--- a/tests/Feature/Api/V1/ClassPreparation/ClassPreparationControllerTest.php
+++ b/tests/Feature/Api/V1/ClassPreparation/ClassPreparationControllerTest.php
@@ -23,11 +23,14 @@ class ClassPreparationControllerTest extends TestCase
$response->assertOk();
$response->assertJson([
- 'ok' => true,
- 'schoolYear' => '2025-2026',
- 'semester' => 'Fall',
+ 'status' => true,
+ 'message' => 'Success',
+ 'data' => [
+ 'schoolYear' => '2025-2026',
+ 'semester' => 'Fall',
+ ],
]);
- $this->assertNotEmpty($response->json('results'));
+ $this->assertNotEmpty($response->json('data.results'));
}
public function test_mark_printed_creates_snapshots(): void
@@ -40,12 +43,12 @@ class ClassPreparationControllerTest extends TestCase
$response = $this->postJson('/api/v1/class-prep/mark-printed', [
'school_year' => '2025-2026',
'semester' => 'Fall',
- 'class_section_ids' => [101],
+ 'class_section_ids' => ['101'],
]);
$response->assertOk();
$response->assertJson([
- 'ok' => true,
+ 'status' => true,
]);
$this->assertDatabaseHas('class_preparation_log', [
@@ -54,6 +57,66 @@ class ClassPreparationControllerTest extends TestCase
]);
}
+ public function test_mark_printed_rejects_invalid_payload(): void
+ {
+ $user = $this->createUser();
+ Sanctum::actingAs($user);
+
+ $response = $this->postJson('/api/v1/class-prep/mark-printed', [
+ 'class_section_ids' => [],
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonStructure(['message', 'errors']);
+ }
+
+ public function test_save_adjustments_updates_rows(): void
+ {
+ $this->seedPrepData();
+
+ $user = $this->createUser();
+ Sanctum::actingAs($user);
+
+ $response = $this->postJson('/api/v1/class-prep/adjustments', [
+ 'class_section_id' => '101',
+ 'school_year' => '2025-2026',
+ 'adjustments' => [
+ 'Small Table' => 2,
+ ],
+ ]);
+
+ $response->assertOk();
+ $response->assertJson([
+ 'status' => true,
+ ]);
+
+ $this->assertDatabaseHas('class_prep_adjustments', [
+ 'class_section_id' => '101',
+ 'school_year' => '2025-2026',
+ 'item_name' => 'Small Table',
+ 'adjustment' => 2,
+ ]);
+ }
+
+ public function test_print_logs_prep_items(): void
+ {
+ $this->seedPrepData();
+
+ $user = $this->createUser();
+ Sanctum::actingAs($user);
+
+ $response = $this->postJson('/api/v1/class-prep/print/101/2025-2026');
+
+ $response->assertOk();
+ $response->assertJsonPath('status', true);
+ $response->assertJsonPath('data.print.class_section_id', '101');
+
+ $this->assertDatabaseHas('class_preparation_log', [
+ 'class_section_id' => 101,
+ 'school_year' => '2025-2026',
+ ]);
+ }
+
private function seedPrepData(): void
{
DB::table('configuration')->insert([
diff --git a/tests/Feature/Api/V1/Classes/ClassSectionControllerTest.php b/tests/Feature/Api/V1/Classes/ClassSectionControllerTest.php
new file mode 100644
index 00000000..3deb5d96
--- /dev/null
+++ b/tests/Feature/Api/V1/Classes/ClassSectionControllerTest.php
@@ -0,0 +1,282 @@
+createUser();
+ Sanctum::actingAs($user);
+
+ DB::table('classes')->insert([
+ 'class_name' => 'Class 1',
+ 'schedule' => 'Monday',
+ 'capacity' => 30,
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('classSection')->insert([
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $response = $this->getJson('/api/v1/class-sections');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.sections.0.class_section_name', '1-A');
+ }
+
+ public function test_store_requires_admin(): void
+ {
+ $user = $this->createUser();
+ Sanctum::actingAs($user);
+
+ $response = $this->postJson('/api/v1/class-sections', [
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $response->assertForbidden();
+ }
+
+ public function test_store_creates_class_section(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ DB::table('classes')->insert([
+ 'class_name' => 'Class 1',
+ 'schedule' => 'Monday',
+ 'capacity' => 30,
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $response = $this->postJson('/api/v1/class-sections', [
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $response->assertCreated();
+ $this->assertDatabaseHas('classSection', [
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ ]);
+ }
+
+ public function test_update_requires_admin(): void
+ {
+ DB::table('classSection')->insert([
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $user = $this->createUser();
+ Sanctum::actingAs($user);
+
+ $response = $this->putJson('/api/v1/class-sections/1', [
+ 'class_section_name' => '1-B',
+ ]);
+
+ $response->assertForbidden();
+ }
+
+ public function test_show_returns_section(): void
+ {
+ DB::table('classSection')->insert([
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $user = $this->createUser();
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/class-sections/1');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.section.class_section_name', '1-A');
+ }
+
+ public function test_update_modifies_class_section(): void
+ {
+ DB::table('classSection')->insert([
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $response = $this->putJson('/api/v1/class-sections/1', [
+ 'class_section_name' => '1-B',
+ ]);
+
+ $response->assertOk();
+ $this->assertDatabaseHas('classSection', [
+ 'id' => 1,
+ 'class_section_name' => '1-B',
+ ]);
+ }
+
+ public function test_destroy_deletes_class_section(): void
+ {
+ DB::table('classSection')->insert([
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $response = $this->deleteJson('/api/v1/class-sections/1');
+
+ $response->assertOk();
+ $this->assertDatabaseMissing('classSection', [
+ 'id' => 1,
+ ]);
+ }
+
+ public function test_attendance_returns_students(): void
+ {
+ DB::table('configuration')->insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ]);
+
+ DB::table('classSection')->insert([
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('students')->insert([
+ 'school_id' => 'S-1',
+ 'firstname' => 'Student',
+ 'lastname' => 'One',
+ 'age' => 8,
+ 'gender' => 'Male',
+ 'photo_consent' => 1,
+ 'parent_id' => 1,
+ 'year_of_registration' => '2025',
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'is_active' => 1,
+ ]);
+
+ DB::table('student_class')->insert([
+ 'student_id' => 1,
+ 'class_section_id' => 101,
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $teacher = $this->createUser();
+ Sanctum::actingAs($teacher);
+
+ $response = $this->getJson('/api/v1/class-sections/101/attendance');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.attendance.class_section_id', 101);
+ $response->assertJsonCount(1, 'data.attendance.students');
+ }
+
+ public function test_seed_defaults_creates_classes(): void
+ {
+ DB::table('configuration')->insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ]);
+
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $response = $this->postJson('/api/v1/class-sections/seed-defaults');
+
+ $response->assertOk();
+ $this->assertDatabaseHas('classes', [
+ 'class_name' => 'Class 1',
+ ]);
+ }
+
+ public function test_validation_rejects_invalid_payload(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $response = $this->postJson('/api/v1/class-sections', [
+ 'class_id' => 'bad',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonStructure(['message', 'errors']);
+ }
+
+ private function createUser(string $roleName = 'teacher'): User
+ {
+ DB::table('roles')->insert([
+ 'id' => 1,
+ 'name' => $roleName,
+ 'priority' => 1,
+ ]);
+
+ DB::table('users')->insert([
+ 'school_id' => 1,
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'cellphone' => '5555555555',
+ 'email' => $roleName . '@example.com',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'is_verified' => 1,
+ 'status' => 'Active',
+ 'is_suspended' => 0,
+ 'failed_attempts' => 0,
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => 1,
+ 'role_id' => 1,
+ ]);
+
+ return User::query()->findOrFail(1);
+ }
+}
diff --git a/tests/Feature/Api/V1/Frontend/FrontendControllerTest.php b/tests/Feature/Api/V1/Frontend/FrontendControllerTest.php
new file mode 100644
index 00000000..fb68a7b2
--- /dev/null
+++ b/tests/Feature/Api/V1/Frontend/FrontendControllerTest.php
@@ -0,0 +1,72 @@
+getJson('/api/v1/frontend');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.page.page', 'index');
+ }
+
+ public function test_fetch_user_requires_auth(): void
+ {
+ $response = $this->getJson('/api/v1/frontend/me');
+
+ $response->assertStatus(401);
+ }
+
+ public function test_fetch_user_returns_profile(): void
+ {
+ $user = $this->createUser('parent');
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/frontend/me');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.user.firstname', 'Test');
+ }
+
+ private function createUser(string $roleName): User
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => $roleName,
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => $roleName . '@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ return $user;
+ }
+}
diff --git a/tests/Feature/Api/V1/Frontend/InfoIconControllerTest.php b/tests/Feature/Api/V1/Frontend/InfoIconControllerTest.php
new file mode 100644
index 00000000..ebd0a5e2
--- /dev/null
+++ b/tests/Feature/Api/V1/Frontend/InfoIconControllerTest.php
@@ -0,0 +1,40 @@
+create([
+ 'firstname' => 'John',
+ 'lastname' => 'Doe',
+ 'email' => 'john@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/info-icon/profile');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.profile.user_initials', 'JD');
+ }
+}
diff --git a/tests/Feature/Api/V1/Frontend/LandingPageControllerTest.php b/tests/Feature/Api/V1/Frontend/LandingPageControllerTest.php
new file mode 100644
index 00000000..d60af1b5
--- /dev/null
+++ b/tests/Feature/Api/V1/Frontend/LandingPageControllerTest.php
@@ -0,0 +1,81 @@
+createUser('admin');
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/landing');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.landing.role', 'admin');
+ }
+
+ public function test_teacher_dashboard_returns_summary(): void
+ {
+ $user = $this->createUser('teacher');
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/landing/teacher');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.landing.dashboard', 'teacher');
+ $response->assertJsonStructure(['data' => ['landing' => ['summary']]]);
+ }
+
+ public function test_parent_dashboard_returns_summary(): void
+ {
+ $user = $this->createUser('parent');
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/landing/parent');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.landing.dashboard', 'parent');
+ }
+
+ private function createUser(string $roleName): User
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => $roleName,
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => $roleName . '@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ 'user_type' => 'primary',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ return $user;
+ }
+}
diff --git a/tests/Feature/Api/V1/Frontend/PageControllerTest.php b/tests/Feature/Api/V1/Frontend/PageControllerTest.php
new file mode 100644
index 00000000..538d3cea
--- /dev/null
+++ b/tests/Feature/Api/V1/Frontend/PageControllerTest.php
@@ -0,0 +1,59 @@
+Privacy');
+
+ $response = $this->getJson('/api/v1/pages/privacy');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.page.type', 'privacy_policy');
+ $this->assertStringContainsString('Privacy', $response->json('data.page.content'));
+ }
+
+ public function test_help_missing_returns_error(): void
+ {
+ $dir = public_path('html');
+ File::ensureDirectoryExists($dir);
+ File::delete($dir . '/help_center.html');
+
+ $response = $this->getJson('/api/v1/pages/help');
+
+ $response->assertStatus(404);
+ }
+
+ public function test_submit_contact_persists(): void
+ {
+ DB::table('configuration')->insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ]);
+
+ $payload = [
+ 'email' => 'contact@example.com',
+ 'message' => 'Hello there, this is a test message.',
+ ];
+
+ $response = $this->postJson('/api/v1/pages/contact', $payload);
+
+ $response->assertStatus(201);
+ $this->assertDatabaseHas('contactus', [
+ 'subject' => 'Contact Us Form Submission',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+ }
+}
diff --git a/tests/Feature/Api/V1/Messaging/MessagesControllerTest.php b/tests/Feature/Api/V1/Messaging/MessagesControllerTest.php
new file mode 100644
index 00000000..53c7065d
--- /dev/null
+++ b/tests/Feature/Api/V1/Messaging/MessagesControllerTest.php
@@ -0,0 +1,236 @@
+getJson('/api/v1/messages/inbox');
+
+ $response->assertStatus(401);
+ }
+
+ public function test_store_creates_message(): void
+ {
+ DB::table('configuration')->insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ]);
+
+ $sender = $this->createUser('parent', 'sender@example.com');
+ $recipient = $this->createUser('teacher', 'recipient@example.com');
+ Sanctum::actingAs($sender);
+
+ $payload = [
+ 'recipient_id' => $recipient->id,
+ 'subject' => 'Hello',
+ 'message' => 'Test message',
+ ];
+
+ $response = $this->postJson('/api/v1/messages', $payload);
+
+ $response->assertStatus(201);
+ $this->assertDatabaseHas('messages', [
+ 'sender_id' => $sender->id,
+ 'recipient_id' => $recipient->id,
+ 'subject' => 'Hello',
+ ]);
+ }
+
+ public function test_show_marks_read(): void
+ {
+ $sender = $this->createUser('parent', 'sender2@example.com');
+ $recipient = $this->createUser('teacher', 'recipient2@example.com');
+
+ $message = Message::query()->create([
+ 'sender_id' => $sender->id,
+ 'recipient_id' => $recipient->id,
+ 'subject' => 'Subject',
+ 'message' => 'Body',
+ 'sent_datetime' => now(),
+ 'read_status' => 0,
+ 'message_number' => 'MSG-123',
+ 'priority' => 'normal',
+ 'status' => 'sent',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ Sanctum::actingAs($recipient);
+
+ $response = $this->getJson('/api/v1/messages/' . $message->id);
+
+ $response->assertOk();
+ $this->assertDatabaseHas('messages', [
+ 'id' => $message->id,
+ 'read_status' => 1,
+ ]);
+ }
+
+ public function test_update_changes_status(): void
+ {
+ $sender = $this->createUser('parent', 'sender3@example.com');
+ $recipient = $this->createUser('teacher', 'recipient3@example.com');
+
+ $message = Message::query()->create([
+ 'sender_id' => $sender->id,
+ 'recipient_id' => $recipient->id,
+ 'subject' => 'Subject',
+ 'message' => 'Body',
+ 'sent_datetime' => now(),
+ 'read_status' => 0,
+ 'message_number' => 'MSG-124',
+ 'priority' => 'normal',
+ 'status' => 'sent',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ Sanctum::actingAs($sender);
+
+ $response = $this->patchJson('/api/v1/messages/' . $message->id, [
+ 'status' => 'trashed',
+ ]);
+
+ $response->assertOk();
+ $this->assertDatabaseHas('messages', [
+ 'id' => $message->id,
+ 'status' => 'trashed',
+ ]);
+ }
+
+ public function test_destroy_marks_trashed(): void
+ {
+ $sender = $this->createUser('parent', 'sender4@example.com');
+ $recipient = $this->createUser('teacher', 'recipient4@example.com');
+
+ $message = Message::query()->create([
+ 'sender_id' => $sender->id,
+ 'recipient_id' => $recipient->id,
+ 'subject' => 'Subject',
+ 'message' => 'Body',
+ 'sent_datetime' => now(),
+ 'read_status' => 0,
+ 'message_number' => 'MSG-125',
+ 'priority' => 'normal',
+ 'status' => 'sent',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ Sanctum::actingAs($sender);
+
+ $response = $this->deleteJson('/api/v1/messages/' . $message->id);
+
+ $response->assertOk();
+ $this->assertDatabaseHas('messages', [
+ 'id' => $message->id,
+ 'status' => 'trashed',
+ ]);
+ }
+
+ public function test_recipients_teacher_returns_list(): void
+ {
+ $teacher = $this->createUser('teacher', 'teacher@example.com');
+ Sanctum::actingAs($teacher);
+
+ DB::table('teacher_class')->insert([
+ 'teacher_id' => $teacher->id,
+ 'class_section_id' => 1,
+ 'position' => 'main',
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $response = $this->getJson('/api/v1/messages/recipients/teacher');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.recipients.0.id', $teacher->id);
+ }
+
+ public function test_validation_rejects_missing_subject(): void
+ {
+ $sender = $this->createUser('parent', 'sender5@example.com');
+ $recipient = $this->createUser('teacher', 'recipient5@example.com');
+ Sanctum::actingAs($sender);
+
+ $response = $this->postJson('/api/v1/messages', [
+ 'recipient_id' => $recipient->id,
+ 'message' => 'Body',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonStructure(['message', 'errors']);
+ }
+
+ public function test_show_forbidden_for_other_users(): void
+ {
+ $sender = $this->createUser('parent', 'sender6@example.com');
+ $recipient = $this->createUser('teacher', 'recipient6@example.com');
+ $other = $this->createUser('student', 'other@example.com');
+
+ $message = Message::query()->create([
+ 'sender_id' => $sender->id,
+ 'recipient_id' => $recipient->id,
+ 'subject' => 'Subject',
+ 'message' => 'Body',
+ 'sent_datetime' => now(),
+ 'read_status' => 0,
+ 'message_number' => 'MSG-126',
+ 'priority' => 'normal',
+ 'status' => 'sent',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ Sanctum::actingAs($other);
+
+ $response = $this->getJson('/api/v1/messages/' . $message->id);
+
+ $response->assertForbidden();
+ }
+
+ private function createUser(string $roleName, string $email): User
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => $roleName,
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => $email,
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ return $user;
+ }
+}
diff --git a/tests/Feature/Api/V1/Settings/PolicyControllerTest.php b/tests/Feature/Api/V1/Settings/PolicyControllerTest.php
new file mode 100644
index 00000000..2ca3c7fa
--- /dev/null
+++ b/tests/Feature/Api/V1/Settings/PolicyControllerTest.php
@@ -0,0 +1,27 @@
+getJson('/api/v1/policies/school');
+
+ $response->assertOk();
+ $response->assertJsonStructure(['data' => ['policy' => ['type', 'title', 'format', 'content', 'updated_at']]]);
+ }
+
+ public function test_picture_policy_endpoint_returns_payload(): void
+ {
+ $response = $this->getJson('/api/v1/policies/picture');
+
+ $response->assertOk();
+ $response->assertJsonStructure(['data' => ['policy' => ['type', 'title', 'format', 'content', 'updated_at']]]);
+ }
+}
diff --git a/tests/Feature/Api/V1/Settings/PreferencesControllerTest.php b/tests/Feature/Api/V1/Settings/PreferencesControllerTest.php
new file mode 100644
index 00000000..7c51b8d2
--- /dev/null
+++ b/tests/Feature/Api/V1/Settings/PreferencesControllerTest.php
@@ -0,0 +1,169 @@
+createUser('teacher');
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/preferences');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.preferences.receive_email_notifications', true);
+ $response->assertJsonStructure(['data' => ['options' => ['style_options', 'menu_options']]]);
+ }
+
+ public function test_store_creates_preferences(): void
+ {
+ $user = $this->createUser('teacher');
+ Sanctum::actingAs($user);
+
+ $response = $this->postJson('/api/v1/preferences', [
+ 'receive_email_notifications' => true,
+ 'receive_sms_notifications' => false,
+ 'theme' => 'dark',
+ 'language' => 'en',
+ 'style_color' => 'blue',
+ 'menu_color' => 'white',
+ ]);
+
+ $response->assertOk();
+ $this->assertDatabaseHas('user_preferences', [
+ 'user_id' => $user->id,
+ 'notification_email' => 1,
+ 'notification_sms' => 0,
+ ]);
+ }
+
+ public function test_list_requires_admin(): void
+ {
+ $user = $this->createUser('teacher');
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/preferences/list');
+
+ $response->assertForbidden();
+ }
+
+ public function test_list_returns_rows_for_admin(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ DB::table('user_preferences')->insert([
+ 'user_id' => $admin->id,
+ 'notification_email' => 1,
+ 'notification_sms' => 1,
+ 'theme' => 'light',
+ 'language' => 'en',
+ ]);
+
+ $response = $this->getJson('/api/v1/preferences/list');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.preferences.0.user_id', $admin->id);
+ }
+
+ public function test_update_for_user_by_admin(): void
+ {
+ $admin = $this->createUser('admin');
+ $user = $this->createUser('teacher', 2);
+ Sanctum::actingAs($admin);
+
+ $response = $this->putJson('/api/v1/preferences/2', [
+ 'receive_sms_notifications' => false,
+ ]);
+
+ $response->assertOk();
+ $this->assertDatabaseHas('user_preferences', [
+ 'user_id' => $user->id,
+ 'notification_sms' => 0,
+ ]);
+ }
+
+ public function test_destroy_requires_admin(): void
+ {
+ $user = $this->createUser('teacher');
+ Sanctum::actingAs($user);
+
+ $response = $this->deleteJson('/api/v1/preferences/' . $user->id);
+
+ $response->assertForbidden();
+ }
+
+ public function test_destroy_deletes_preferences(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ DB::table('user_preferences')->insert([
+ 'user_id' => $admin->id,
+ 'notification_email' => 1,
+ 'notification_sms' => 1,
+ ]);
+
+ $response = $this->deleteJson('/api/v1/preferences/' . $admin->id);
+
+ $response->assertOk();
+ $this->assertDatabaseMissing('user_preferences', [
+ 'user_id' => $admin->id,
+ ]);
+ }
+
+ public function test_store_validates_payload(): void
+ {
+ $user = $this->createUser('teacher');
+ Sanctum::actingAs($user);
+
+ $response = $this->postJson('/api/v1/preferences', [
+ 'theme' => 'invalid',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonStructure(['message', 'errors']);
+ }
+
+ private function createUser(string $roleName, int $id = 1): User
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => $roleName,
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ DB::table('users')->insert([
+ 'id' => $id,
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => $roleName . $id . '@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $id,
+ 'role_id' => $roleId,
+ ]);
+
+ return User::query()->findOrFail($id);
+ }
+}
diff --git a/tests/Feature/Api/V1/Settings/SettingsControllerTest.php b/tests/Feature/Api/V1/Settings/SettingsControllerTest.php
new file mode 100644
index 00000000..fc49c78a
--- /dev/null
+++ b/tests/Feature/Api/V1/Settings/SettingsControllerTest.php
@@ -0,0 +1,103 @@
+createUser('teacher');
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/settings');
+
+ $response->assertForbidden();
+ }
+
+ public function test_index_returns_settings(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $response = $this->getJson('/api/v1/settings');
+
+ $response->assertOk();
+ $response->assertJsonStructure(['data' => ['settings' => ['site_name', 'administrator_email', 'timezone']]]);
+ }
+
+ public function test_update_validation_rejects_bad_email(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $response = $this->patchJson('/api/v1/settings', [
+ 'administrator_email' => 'not-an-email',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonStructure(['message', 'errors']);
+ }
+
+ public function test_update_persists_settings(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $response = $this->patchJson('/api/v1/settings', [
+ 'site_name' => 'Alrahma',
+ 'timezone' => 'UTC',
+ 'administrator_email' => 'admin@example.com',
+ ]);
+
+ $response->assertOk();
+ $this->assertDatabaseHas('settings', [
+ 'name' => 'Alrahma',
+ 'timezone' => 'UTC',
+ ]);
+ $this->assertDatabaseHas('configuration', [
+ 'config_key' => 'administrator_email',
+ 'config_value' => 'admin@example.com',
+ ]);
+ }
+
+ private function createUser(string $roleName): User
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => $roleName,
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => $roleName . '@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ return $user;
+ }
+}
diff --git a/tests/Feature/Api/V1/Staff/StaffControllerTest.php b/tests/Feature/Api/V1/Staff/StaffControllerTest.php
new file mode 100644
index 00000000..e55f40c8
--- /dev/null
+++ b/tests/Feature/Api/V1/Staff/StaffControllerTest.php
@@ -0,0 +1,169 @@
+createUser('teacher');
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/staff');
+
+ $response->assertForbidden();
+ }
+
+ public function test_index_returns_staff(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $staffUser = $this->createUser('teacher', 'staff@example.com');
+ Staff::query()->create([
+ 'user_id' => $staffUser->id,
+ 'firstname' => 'Staff',
+ 'lastname' => 'User',
+ 'email' => 'staff@example.com',
+ 'phone' => '5555555555',
+ 'role_name' => 'teacher',
+ 'active_role' => 'teacher',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $response = $this->getJson('/api/v1/staff');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.staff.0.email', 'staff@example.com');
+ }
+
+ public function test_store_creates_staff(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $user = $this->createUser('teacher', 'newstaff@example.com');
+
+ $response = $this->postJson('/api/v1/staff', [
+ 'user_id' => $user->id,
+ 'firstname' => 'New',
+ 'lastname' => 'Staff',
+ 'email' => 'newstaff@example.com',
+ 'phone' => '5555555555',
+ 'role_name' => 'teacher',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $response->assertStatus(201);
+ $this->assertDatabaseHas('staff', [
+ 'user_id' => $user->id,
+ 'email' => 'newstaff@example.com',
+ ]);
+ }
+
+ public function test_store_validation(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $response = $this->postJson('/api/v1/staff', [
+ 'firstname' => 'New',
+ ]);
+
+ $response->assertStatus(422);
+ }
+
+ public function test_update_modifies_staff(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $user = $this->createUser('teacher', 'updatestaff@example.com');
+ $staff = Staff::query()->create([
+ 'user_id' => $user->id,
+ 'firstname' => 'Old',
+ 'lastname' => 'Name',
+ 'email' => 'updatestaff@example.com',
+ 'phone' => '5555555555',
+ 'role_name' => 'teacher',
+ 'active_role' => 'teacher',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $response = $this->patchJson('/api/v1/staff/' . $staff->id, [
+ 'firstname' => 'Updated',
+ ]);
+
+ $response->assertOk();
+ $this->assertDatabaseHas('staff', [
+ 'id' => $staff->id,
+ 'firstname' => 'Updated',
+ ]);
+ }
+
+ public function test_destroy_deletes_staff(): void
+ {
+ $admin = $this->createUser('admin');
+ Sanctum::actingAs($admin);
+
+ $user = $this->createUser('teacher', 'deletestaff@example.com');
+ $staff = Staff::query()->create([
+ 'user_id' => $user->id,
+ 'firstname' => 'Delete',
+ 'lastname' => 'Staff',
+ 'email' => 'deletestaff@example.com',
+ 'phone' => '5555555555',
+ 'role_name' => 'teacher',
+ 'active_role' => 'teacher',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $response = $this->deleteJson('/api/v1/staff/' . $staff->id);
+
+ $response->assertOk();
+ $this->assertDatabaseMissing('staff', [
+ 'id' => $staff->id,
+ ]);
+ }
+
+ private function createUser(string $roleName, ?string $email = null): User
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => $roleName,
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => $email ?? ($roleName . '@example.com'),
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ return $user;
+ }
+}
diff --git a/tests/Feature/Api/V1/Support/ContactControllerTest.php b/tests/Feature/Api/V1/Support/ContactControllerTest.php
new file mode 100644
index 00000000..47172e02
--- /dev/null
+++ b/tests/Feature/Api/V1/Support/ContactControllerTest.php
@@ -0,0 +1,33 @@
+postJson('/api/v1/contact', [
+ 'email' => 'not-an-email',
+ ]);
+
+ $response->assertStatus(422);
+ }
+
+ public function test_send_returns_success(): void
+ {
+ $response = $this->postJson('/api/v1/contact', [
+ 'name' => 'Test User',
+ 'email' => 'test@example.com',
+ 'subject' => 'Hello',
+ 'message' => 'This is a test message.',
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonStructure(['data' => ['contact' => ['email_sent', 'recipient']]]);
+ }
+}
diff --git a/tests/Feature/Api/V1/Support/SupportControllerTest.php b/tests/Feature/Api/V1/Support/SupportControllerTest.php
new file mode 100644
index 00000000..53a84e87
--- /dev/null
+++ b/tests/Feature/Api/V1/Support/SupportControllerTest.php
@@ -0,0 +1,108 @@
+getJson('/api/v1/support');
+
+ $response->assertStatus(401);
+ }
+
+ public function test_index_returns_requests(): void
+ {
+ $user = $this->createUser('parent');
+ Sanctum::actingAs($user);
+
+ SupportRequest::query()->create([
+ 'user_id' => $user->id,
+ 'subject' => 'Help',
+ 'message' => 'Need assistance',
+ 'status' => 'open',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $response = $this->getJson('/api/v1/support');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.requests.0.subject', 'Help');
+ }
+
+ public function test_store_validation(): void
+ {
+ $user = $this->createUser('parent');
+ Sanctum::actingAs($user);
+
+ $response = $this->postJson('/api/v1/support', [
+ 'subject' => 'Hi',
+ ]);
+
+ $response->assertStatus(422);
+ }
+
+ public function test_store_creates_request(): void
+ {
+ DB::table('configuration')->insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ]);
+
+ $user = $this->createUser('parent');
+ Sanctum::actingAs($user);
+
+ $response = $this->postJson('/api/v1/support', [
+ 'subject' => 'Help',
+ 'message' => 'Need assistance please',
+ ]);
+
+ $response->assertStatus(201);
+ $this->assertDatabaseHas('support_requests', [
+ 'user_id' => $user->id,
+ 'subject' => 'Help',
+ ]);
+ }
+
+ private function createUser(string $roleName): User
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => $roleName,
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => $roleName . '@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ return $user;
+ }
+}
diff --git a/tests/Feature/Api/V1/System/DashboardControllerTest.php b/tests/Feature/Api/V1/System/DashboardControllerTest.php
new file mode 100644
index 00000000..15a33652
--- /dev/null
+++ b/tests/Feature/Api/V1/System/DashboardControllerTest.php
@@ -0,0 +1,59 @@
+getJson('/api/v1/dashboard/route');
+
+ $response->assertStatus(401);
+ }
+
+ public function test_route_returns_dashboard_for_role(): void
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => 'administrator',
+ 'dashboard_route' => '/administrator/dashboard',
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Admin',
+ 'lastname' => 'User',
+ 'email' => 'admin@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ Sanctum::actingAs($user);
+
+ $response = $this->getJson('/api/v1/dashboard/route');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.dashboard.route', '/administrator/dashboard');
+ }
+}
diff --git a/tests/Feature/Api/V1/System/DatabaseHealthControllerTest.php b/tests/Feature/Api/V1/System/DatabaseHealthControllerTest.php
new file mode 100644
index 00000000..9306d12e
--- /dev/null
+++ b/tests/Feature/Api/V1/System/DatabaseHealthControllerTest.php
@@ -0,0 +1,19 @@
+getJson('/api/v1/system/db-check');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.ok', true);
+ }
+}
diff --git a/tests/Feature/Api/V1/System/HealthControllerTest.php b/tests/Feature/Api/V1/System/HealthControllerTest.php
new file mode 100644
index 00000000..634c3a1c
--- /dev/null
+++ b/tests/Feature/Api/V1/System/HealthControllerTest.php
@@ -0,0 +1,31 @@
+getJson('/api/v1/health');
+
+ $this->assertContains($response->status(), [200, 503]);
+ $response->assertJsonStructure([
+ 'status',
+ 'message',
+ 'data' => [
+ 'health' => [
+ 'ok',
+ 'paths',
+ 'database',
+ 'write_path',
+ 'timestamp',
+ ],
+ ],
+ ]);
+ }
+}
diff --git a/tests/Feature/Api/V1/System/NavBuilderControllerTest.php b/tests/Feature/Api/V1/System/NavBuilderControllerTest.php
new file mode 100644
index 00000000..7a9e4546
--- /dev/null
+++ b/tests/Feature/Api/V1/System/NavBuilderControllerTest.php
@@ -0,0 +1,150 @@
+insertGetId([
+ 'name' => 'admin',
+ 'priority' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Admin',
+ 'lastname' => 'User',
+ 'email' => 'admin@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'CT',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => 'hashed',
+ 'semester' => 'Fall',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ return $user;
+ }
+
+ public function test_menu_returns_items_for_roles(): void
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => 'parent',
+ 'priority' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Parent',
+ 'lastname' => 'User',
+ 'email' => 'parent@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'CT',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => 'hashed',
+ 'semester' => 'Fall',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ $navId = DB::table('nav_items')->insertGetId([
+ 'label' => 'Home',
+ 'url' => 'home',
+ 'sort_order' => 1,
+ 'is_enabled' => 1,
+ ]);
+
+ DB::table('role_nav_items')->insert([
+ 'role_id' => $roleId,
+ 'nav_item_id' => $navId,
+ ]);
+
+ $response = $this->actingAs($user, 'sanctum')->getJson('/api/v1/nav-builder/menu');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.items.0.label', 'Home');
+ }
+
+ public function test_data_requires_admin(): void
+ {
+ $user = $this->makeAdminUser();
+
+ $response = $this->actingAs($user, 'sanctum')->getJson('/api/v1/nav-builder/data');
+
+ $response->assertOk();
+ $response->assertJsonStructure(['data' => ['data' => ['items', 'roles', 'parentOptions']]]);
+ }
+
+ public function test_store_creates_nav_item(): void
+ {
+ $user = $this->makeAdminUser();
+
+ $response = $this->actingAs($user, 'sanctum')->postJson('/api/v1/nav-builder', [
+ 'label' => 'Dashboard',
+ 'url' => 'dashboard',
+ 'sort_order' => 1,
+ 'is_enabled' => true,
+ 'roles' => [],
+ ]);
+
+ $response->assertCreated();
+ $this->assertDatabaseHas('nav_items', ['label' => 'Dashboard']);
+ }
+
+ public function test_delete_removes_nav_item(): void
+ {
+ $user = $this->makeAdminUser();
+
+ $navId = DB::table('nav_items')->insertGetId([
+ 'label' => 'Delete',
+ 'url' => 'delete',
+ 'sort_order' => 1,
+ 'is_enabled' => 1,
+ ]);
+
+ $response = $this->actingAs($user, 'sanctum')->deleteJson("/api/v1/nav-builder/{$navId}");
+
+ $response->assertOk();
+ $this->assertDatabaseMissing('nav_items', ['id' => $navId]);
+ }
+
+ public function test_reorder_updates_items(): void
+ {
+ $user = $this->makeAdminUser();
+
+ $navId = DB::table('nav_items')->insertGetId([
+ 'label' => 'Sort',
+ 'url' => 'sort',
+ 'sort_order' => 1,
+ 'is_enabled' => 1,
+ ]);
+
+ $response = $this->actingAs($user, 'sanctum')->postJson('/api/v1/nav-builder/reorder', [
+ 'orders' => [$navId => 9],
+ ]);
+
+ $response->assertOk();
+ $this->assertDatabaseHas('nav_items', ['id' => $navId, 'sort_order' => 9]);
+ }
+}
diff --git a/tests/Feature/Api/V1/Ui/UiControllerTest.php b/tests/Feature/Api/V1/Ui/UiControllerTest.php
new file mode 100644
index 00000000..9631367b
--- /dev/null
+++ b/tests/Feature/Api/V1/Ui/UiControllerTest.php
@@ -0,0 +1,76 @@
+postJson('/api/v1/ui/style', [
+ 'accent' => 'blue',
+ ]);
+
+ $response->assertStatus(401);
+ }
+
+ public function test_style_updates_preferences(): void
+ {
+ $user = $this->createUser('parent');
+ Sanctum::actingAs($user);
+
+ $response = $this->postJson('/api/v1/ui/style', [
+ 'accent' => 'blue',
+ 'menu' => 'custom',
+ 'menu_bg' => '#112233',
+ 'menu_text' => '#ffffff',
+ 'menu_mode' => 'dark',
+ ]);
+
+ $response->assertOk();
+ $this->assertDatabaseHas('user_preferences', [
+ 'user_id' => $user->id,
+ 'style_color' => 'blue',
+ 'menu_color' => 'custom',
+ ]);
+ }
+
+ private function createUser(string $roleName): User
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => $roleName,
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => $roleName . '@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ return $user;
+ }
+}
diff --git a/tests/Feature/Api/V1/Utilities/PhoneFormatterControllerTest.php b/tests/Feature/Api/V1/Utilities/PhoneFormatterControllerTest.php
new file mode 100644
index 00000000..49f04af3
--- /dev/null
+++ b/tests/Feature/Api/V1/Utilities/PhoneFormatterControllerTest.php
@@ -0,0 +1,83 @@
+createUser();
+ Sanctum::actingAs($user);
+
+ $response = $this->postJson('/api/v1/utilities/phone/format', [
+ 'number' => '(555) 123-4567',
+ ]);
+
+ $response->assertOk();
+ $response->assertJsonPath('data.phone.formatted', '555-123-4567');
+ $response->assertJsonPath('data.phone.is_valid', true);
+ }
+
+ public function test_format_rejects_invalid_number(): void
+ {
+ $user = $this->createUser();
+ Sanctum::actingAs($user);
+
+ $response = $this->postJson('/api/v1/utilities/phone/format', [
+ 'number' => '123',
+ ]);
+
+ $response->assertStatus(422);
+ }
+
+ public function test_format_validates_payload(): void
+ {
+ $user = $this->createUser();
+ Sanctum::actingAs($user);
+
+ $response = $this->postJson('/api/v1/utilities/phone/format', []);
+
+ $response->assertStatus(422);
+ $response->assertJsonStructure(['message', 'errors']);
+ }
+
+ private function createUser(): User
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => 'admin',
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => 'admin@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ return $user;
+ }
+}
diff --git a/tests/Feature/Middleware/RequirePermissionMiddlewareTest.php b/tests/Feature/Middleware/RequirePermissionMiddlewareTest.php
new file mode 100644
index 00000000..a6467f88
--- /dev/null
+++ b/tests/Feature/Middleware/RequirePermissionMiddlewareTest.php
@@ -0,0 +1,84 @@
+get('/test-perm', fn () => 'ok');
+
+ $user = User::query()->create([
+ 'firstname' => 'No',
+ 'lastname' => 'Perm',
+ 'email' => 'noperm@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'CT',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => 'hashed',
+ 'semester' => 'Fall',
+ ]);
+
+ $response = $this->actingAs($user)->get('/test-perm', ['Accept' => 'application/json']);
+
+ $response->assertStatus(403);
+ }
+
+ public function test_allows_when_permission_assigned(): void
+ {
+ $permissionId = DB::table('permissions')->insertGetId([
+ 'name' => 'edit_students',
+ 'description' => 'Edit students',
+ ]);
+
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => 'admin',
+ 'priority' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Has',
+ 'lastname' => 'Perm',
+ 'email' => 'hasperm@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'CT',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => 'hashed',
+ 'semester' => 'Fall',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ DB::table('role_permissions')->insert([
+ 'role_id' => $roleId,
+ 'permission_id' => $permissionId,
+ 'can_read' => 1,
+ ]);
+
+ Route::middleware('perm:edit_students')->get('/test-perm-ok', fn () => 'ok');
+
+ $response = $this->actingAs($user)->get('/test-perm-ok');
+
+ $response->assertOk();
+ $response->assertSee('ok');
+ }
+}
diff --git a/tests/Unit/Resources/Frontend/FrontendPageResourceTest.php b/tests/Unit/Resources/Frontend/FrontendPageResourceTest.php
new file mode 100644
index 00000000..1f35d246
--- /dev/null
+++ b/tests/Unit/Resources/Frontend/FrontendPageResourceTest.php
@@ -0,0 +1,17 @@
+ 'index']);
+ $payload = $resource->toArray(request());
+
+ $this->assertSame('index', $payload['page']);
+ }
+}
diff --git a/tests/Unit/Resources/Frontend/PageContentResourceTest.php b/tests/Unit/Resources/Frontend/PageContentResourceTest.php
new file mode 100644
index 00000000..d7418afb
--- /dev/null
+++ b/tests/Unit/Resources/Frontend/PageContentResourceTest.php
@@ -0,0 +1,22 @@
+ 'privacy_policy',
+ 'content' => 'Privacy
',
+ ]);
+
+ $payload = $resource->toArray(request());
+
+ $this->assertSame('privacy_policy', $payload['type']);
+ $this->assertSame('Privacy
', $payload['content']);
+ }
+}
diff --git a/tests/Unit/Resources/Messaging/MessageResourceTest.php b/tests/Unit/Resources/Messaging/MessageResourceTest.php
new file mode 100644
index 00000000..2b311456
--- /dev/null
+++ b/tests/Unit/Resources/Messaging/MessageResourceTest.php
@@ -0,0 +1,34 @@
+ 1,
+ 'sender_id' => 2,
+ 'recipient_id' => 3,
+ 'subject' => 'Hello',
+ 'message' => 'Body',
+ 'sent_datetime' => now(),
+ 'read_status' => 0,
+ 'message_number' => 'MSG-1',
+ 'priority' => 'normal',
+ 'status' => 'sent',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $payload = (new MessageResource($message))->toArray(request());
+
+ $this->assertArrayHasKey('id', $payload);
+ $this->assertArrayHasKey('subject', $payload);
+ $this->assertArrayHasKey('message_number', $payload);
+ }
+}
diff --git a/tests/Unit/Resources/Messaging/RecipientResourceTest.php b/tests/Unit/Resources/Messaging/RecipientResourceTest.php
new file mode 100644
index 00000000..9b6ddae2
--- /dev/null
+++ b/tests/Unit/Resources/Messaging/RecipientResourceTest.php
@@ -0,0 +1,18 @@
+ 5, 'name' => 'Test User']);
+ $payload = $resource->toArray(request());
+
+ $this->assertSame(5, $payload['id']);
+ $this->assertSame('Test User', $payload['name']);
+ }
+}
diff --git a/tests/Unit/Resources/Settings/SettingsResourceTest.php b/tests/Unit/Resources/Settings/SettingsResourceTest.php
new file mode 100644
index 00000000..1ee6d82f
--- /dev/null
+++ b/tests/Unit/Resources/Settings/SettingsResourceTest.php
@@ -0,0 +1,24 @@
+ 'App',
+ 'administrator_email' => 'admin@example.com',
+ 'timezone' => 'UTC',
+ ]);
+
+ $payload = $resource->toArray(request());
+
+ $this->assertSame('App', $payload['site_name']);
+ $this->assertSame('admin@example.com', $payload['administrator_email']);
+ $this->assertSame('UTC', $payload['timezone']);
+ }
+}
diff --git a/tests/Unit/Resources/Staff/StaffResourceTest.php b/tests/Unit/Resources/Staff/StaffResourceTest.php
new file mode 100644
index 00000000..9f474ce9
--- /dev/null
+++ b/tests/Unit/Resources/Staff/StaffResourceTest.php
@@ -0,0 +1,29 @@
+ 1,
+ 'user_id' => 2,
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => 'test@example.com',
+ 'role_name' => 'teacher',
+ 'active_role' => 'teacher',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $payload = (new StaffResource($staff))->toArray(request());
+
+ $this->assertSame(1, $payload['id']);
+ $this->assertSame('teacher', $payload['role_name']);
+ }
+}
diff --git a/tests/Unit/Resources/Support/ContactMessageResourceTest.php b/tests/Unit/Resources/Support/ContactMessageResourceTest.php
new file mode 100644
index 00000000..350b3007
--- /dev/null
+++ b/tests/Unit/Resources/Support/ContactMessageResourceTest.php
@@ -0,0 +1,22 @@
+ true,
+ 'recipient' => 'support@example.com',
+ ]);
+
+ $payload = $resource->toArray(request());
+
+ $this->assertTrue($payload['email_sent']);
+ $this->assertSame('support@example.com', $payload['recipient']);
+ }
+}
diff --git a/tests/Unit/Resources/Support/SupportRequestResourceTest.php b/tests/Unit/Resources/Support/SupportRequestResourceTest.php
new file mode 100644
index 00000000..ef36e2bb
--- /dev/null
+++ b/tests/Unit/Resources/Support/SupportRequestResourceTest.php
@@ -0,0 +1,28 @@
+ 1,
+ 'user_id' => 2,
+ 'subject' => 'Help',
+ 'message' => 'Need help',
+ 'status' => 'open',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $payload = (new SupportRequestResource($model))->toArray(request());
+
+ $this->assertSame(1, $payload['id']);
+ $this->assertSame('Help', $payload['subject']);
+ }
+}
diff --git a/tests/Unit/Resources/Ui/UiStyleResourceTest.php b/tests/Unit/Resources/Ui/UiStyleResourceTest.php
new file mode 100644
index 00000000..b8c43d4a
--- /dev/null
+++ b/tests/Unit/Resources/Ui/UiStyleResourceTest.php
@@ -0,0 +1,26 @@
+ 'blue',
+ 'menu_color' => 'custom',
+ 'menu_custom_bg' => '#112233',
+ 'menu_custom_text' => '#FFFFFF',
+ 'menu_custom_mode' => 'dark',
+ ]);
+
+ $payload = (new UiStyleResource($prefs))->toArray(request());
+
+ $this->assertSame('blue', $payload['style_color']);
+ $this->assertSame('custom', $payload['menu_color']);
+ }
+}
diff --git a/tests/Unit/Services/Attendance/LateSlipLogCommandServiceTest.php b/tests/Unit/Services/Attendance/LateSlipLogCommandServiceTest.php
new file mode 100644
index 00000000..e7b9974f
--- /dev/null
+++ b/tests/Unit/Services/Attendance/LateSlipLogCommandServiceTest.php
@@ -0,0 +1,33 @@
+create([
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'student_name' => 'Student X',
+ 'slip_date' => '2025-09-01',
+ 'time_in' => '08:05:00',
+ 'grade' => '4',
+ 'reason' => 'Traffic',
+ 'admin_name' => 'Admin User',
+ ]);
+
+ $service = app(LateSlipLogCommandService::class);
+ $deleted = $service->delete($log);
+
+ $this->assertTrue($deleted);
+ $this->assertDatabaseMissing('late_slip_logs', ['id' => $log->id]);
+ }
+}
diff --git a/tests/Unit/Services/Attendance/LateSlipLogQueryServiceTest.php b/tests/Unit/Services/Attendance/LateSlipLogQueryServiceTest.php
new file mode 100644
index 00000000..7b1e283c
--- /dev/null
+++ b/tests/Unit/Services/Attendance/LateSlipLogQueryServiceTest.php
@@ -0,0 +1,37 @@
+insert([
+ [
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'student_name' => 'Student A',
+ 'slip_date' => '2025-09-01',
+ ],
+ [
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'student_name' => 'Student B',
+ 'slip_date' => '2025-10-01',
+ ],
+ ]);
+
+ $service = app(LateSlipLogQueryService::class);
+ $pager = $service->paginate(['date_from' => '2025-10-01'], 1, 10);
+
+ $this->assertSame(1, $pager->total());
+ $this->assertSame('Student B', $pager->items()[0]->student_name);
+ }
+}
diff --git a/tests/Unit/Services/Auth/RegistrationCaptchaServiceTest.php b/tests/Unit/Services/Auth/RegistrationCaptchaServiceTest.php
index 7580bc4e..6292655a 100644
--- a/tests/Unit/Services/Auth/RegistrationCaptchaServiceTest.php
+++ b/tests/Unit/Services/Auth/RegistrationCaptchaServiceTest.php
@@ -10,24 +10,24 @@ class RegistrationCaptchaServiceTest extends TestCase
{
use RefreshDatabase;
- public function test_generate_sets_session_value(): void
+ public function test_generate_sets_cache_value(): void
{
$service = new RegistrationCaptchaService();
$value = $service->generate(6);
$this->assertNotEmpty($value);
- $this->assertSame($value, session()->get('captcha_answer'));
+ $this->assertTrue($service->verify($value));
}
public function test_verify_and_clear(): void
{
$service = new RegistrationCaptchaService();
- session()->put('captcha_answer', 'ABC123');
+ $value = $service->generate(6);
- $this->assertTrue($service->verify('ABC123'));
+ $this->assertTrue($service->verify($value));
$this->assertFalse($service->verify('WRONG'));
$service->clear();
- $this->assertNull(session()->get('captcha_answer'));
+ $this->assertFalse($service->verify($value));
}
}
diff --git a/tests/Unit/Services/Auth/RegistrationServiceTest.php b/tests/Unit/Services/Auth/RegistrationServiceTest.php
index 798ac59e..c36ea3b6 100644
--- a/tests/Unit/Services/Auth/RegistrationServiceTest.php
+++ b/tests/Unit/Services/Auth/RegistrationServiceTest.php
@@ -35,7 +35,8 @@ class RegistrationServiceTest extends TestCase
'is_active' => 1,
]);
- session()->put('captcha_answer', 'ABCD');
+ $captchaService = new RegistrationCaptchaService();
+ $captcha = $captchaService->generate(4);
$emailService = Mockery::mock(EmailService::class);
$emailService->shouldReceive('send')->once()->andReturn(true);
@@ -44,7 +45,7 @@ class RegistrationServiceTest extends TestCase
$emailService,
new SchoolIdService(),
new RegistrationFormatterService(new PhoneFormatterService()),
- new RegistrationCaptchaService()
+ $captchaService
);
$result = $service->register([
@@ -60,7 +61,7 @@ class RegistrationServiceTest extends TestCase
'state' => 'CT',
'zip' => '12345',
'accept_school_policy' => 1,
- 'captcha' => 'ABCD',
+ 'captcha' => $captcha,
'is_parent' => 1,
'no_second_parent_info' => 1,
]);
@@ -114,7 +115,8 @@ class RegistrationServiceTest extends TestCase
'token' => 'token',
]);
- session()->put('captcha_answer', 'ABCD');
+ $captchaService = new RegistrationCaptchaService();
+ $captcha = $captchaService->generate(4);
$emailService = Mockery::mock(EmailService::class);
$emailService->shouldNotReceive('send');
@@ -123,7 +125,7 @@ class RegistrationServiceTest extends TestCase
$emailService,
new SchoolIdService(),
new RegistrationFormatterService(new PhoneFormatterService()),
- new RegistrationCaptchaService()
+ $captchaService
);
$result = $service->register([
@@ -139,7 +141,7 @@ class RegistrationServiceTest extends TestCase
'state' => 'CT',
'zip' => '12345',
'accept_school_policy' => 1,
- 'captcha' => 'ABCD',
+ 'captcha' => $captcha,
'is_parent' => 1,
'no_second_parent_info' => 1,
]);
diff --git a/tests/Unit/Services/Auth/SessionActivityServiceTest.php b/tests/Unit/Services/Auth/SessionActivityServiceTest.php
deleted file mode 100644
index 50a02aeb..00000000
--- a/tests/Unit/Services/Auth/SessionActivityServiceTest.php
+++ /dev/null
@@ -1,42 +0,0 @@
-makeSessionStore();
- $service = new SessionActivityService($store);
-
- $service->setLastActivity(12345);
-
- $this->assertTrue($service->hasLastActivity());
- $this->assertSame(12345, $service->getLastActivity());
- }
-
- public function test_clear_last_activity(): void
- {
- $store = $this->makeSessionStore();
- $service = new SessionActivityService($store);
-
- $service->setLastActivity(12345);
- $service->clearLastActivity();
-
- $this->assertFalse($service->hasLastActivity());
- }
-
- private function makeSessionStore(): Store
- {
- $handler = new ArraySessionHandler(10);
- $store = new Store('test', $handler);
- $store->start();
-
- return $store;
- }
-}
diff --git a/tests/Unit/Services/Auth/SessionTimeoutConfigServiceTest.php b/tests/Unit/Services/Auth/SessionTimeoutConfigServiceTest.php
deleted file mode 100644
index 86d9c4c1..00000000
--- a/tests/Unit/Services/Auth/SessionTimeoutConfigServiceTest.php
+++ /dev/null
@@ -1,22 +0,0 @@
-config();
-
- $this->assertArrayHasKey('timeout', $config);
- $this->assertArrayHasKey('warning_time', $config);
- $this->assertArrayHasKey('check_interval', $config);
- $this->assertArrayHasKey('logout_url', $config);
- $this->assertArrayHasKey('keep_alive_url', $config);
- $this->assertArrayHasKey('check_url', $config);
- }
-}
diff --git a/tests/Unit/Services/Auth/SessionTimeoutServiceTest.php b/tests/Unit/Services/Auth/SessionTimeoutServiceTest.php
deleted file mode 100644
index 8d799e16..00000000
--- a/tests/Unit/Services/Auth/SessionTimeoutServiceTest.php
+++ /dev/null
@@ -1,51 +0,0 @@
-shouldReceive('hasLastActivity')->andReturn(false);
- $activity->shouldReceive('clearLastActivity')->once();
- $activity->shouldReceive('invalidate')->once();
-
- $service = new SessionTimeoutService($activity);
- $result = $service->checkTimeout();
-
- $this->assertSame('expired', $result['status']);
- }
-
- public function test_check_timeout_returns_warning(): void
- {
- $activity = Mockery::mock(SessionActivityService::class);
- $activity->shouldReceive('hasLastActivity')->andReturn(true);
- $activity->shouldReceive('getLastActivity')->andReturn(time() - (SessionTimeout::WARNING_THRESHOLD + 10));
-
- $service = new SessionTimeoutService($activity);
- $result = $service->checkTimeout();
-
- $this->assertSame('warning', $result['status']);
- }
-
- public function test_ping_returns_expired_when_timed_out(): void
- {
- $activity = Mockery::mock(SessionActivityService::class);
- $activity->shouldReceive('hasLastActivity')->andReturn(true);
- $activity->shouldReceive('getLastActivity')->andReturn(time() - (SessionTimeout::TIMEOUT_DURATION + 10));
- $activity->shouldReceive('clearLastActivity')->once();
- $activity->shouldReceive('invalidate')->once();
-
- $service = new SessionTimeoutService($activity);
- $result = $service->pingActivity();
-
- $this->assertSame('expired', $result['status']);
- }
-}
diff --git a/tests/Unit/Services/Auth/UserRoleServiceTest.php b/tests/Unit/Services/Auth/UserRoleServiceTest.php
new file mode 100644
index 00000000..2b756fbd
--- /dev/null
+++ b/tests/Unit/Services/Auth/UserRoleServiceTest.php
@@ -0,0 +1,44 @@
+insertGetId([
+ 'name' => 'manage_students',
+ 'description' => 'Manage students',
+ ]);
+
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => 'admin',
+ 'priority' => 1,
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => 5,
+ 'role_id' => $roleId,
+ ]);
+
+ DB::table('role_permissions')->insert([
+ 'role_id' => $roleId,
+ 'permission_id' => $permissionId,
+ 'can_read' => 1,
+ 'can_manage' => 0,
+ ]);
+
+ $service = new UserRoleService();
+ $roleIds = $service->getRoleIds(5);
+
+ $this->assertTrue($service->hasPermissionForCrud($roleIds, 'manage_students', 'read'));
+ $this->assertFalse($service->hasPermissionForCrud($roleIds, 'manage_students', 'delete'));
+ }
+}
diff --git a/tests/Unit/Services/ClassPreparation/ClassPreparationAdjustmentWriterServiceTest.php b/tests/Unit/Services/ClassPreparation/ClassPreparationAdjustmentWriterServiceTest.php
new file mode 100644
index 00000000..19069490
--- /dev/null
+++ b/tests/Unit/Services/ClassPreparation/ClassPreparationAdjustmentWriterServiceTest.php
@@ -0,0 +1,33 @@
+saveAdjustments('101', '2025-2026', [
+ 'Small Table' => 2,
+ 'Unknown Item' => 5,
+ ], ['Small Table']);
+
+ $this->assertSame(1, $count);
+ $this->assertDatabaseHas('class_prep_adjustments', [
+ 'class_section_id' => '101',
+ 'school_year' => '2025-2026',
+ 'item_name' => 'Small Table',
+ 'adjustment' => 2,
+ ]);
+ $this->assertDatabaseMissing('class_prep_adjustments', [
+ 'item_name' => 'Unknown Item',
+ ]);
+ }
+}
diff --git a/tests/Unit/Services/ClassPreparation/ClassPreparationPrintServiceTest.php b/tests/Unit/Services/ClassPreparation/ClassPreparationPrintServiceTest.php
new file mode 100644
index 00000000..693e3924
--- /dev/null
+++ b/tests/Unit/Services/ClassPreparation/ClassPreparationPrintServiceTest.php
@@ -0,0 +1,105 @@
+seedPrepData();
+
+ $service = app(ClassPreparationPrintService::class);
+ $payload = $service->printPrep('101', '2025-2026', 'Fall');
+
+ $this->assertTrue($payload['ok']);
+ $this->assertDatabaseHas('class_preparation_log', [
+ 'class_section_id' => 101,
+ 'school_year' => '2025-2026',
+ ]);
+ }
+
+ public function test_print_prep_handles_log_failure(): void
+ {
+ $context = Mockery::mock(ClassPreparationContextService::class);
+ $roster = Mockery::mock(ClassPreparationRosterService::class);
+ $calculator = Mockery::mock(ClassPreparationCalculatorService::class);
+ $adjustments = Mockery::mock(ClassPreparationAdjustmentService::class);
+ $logs = Mockery::mock(ClassPreparationLogService::class);
+
+ $context->shouldReceive('getSemester')->andReturn('Fall');
+ $context->shouldReceive('hasRosterForSemester')->andReturn(true);
+ $roster->shouldReceive('getStudentCountForSection')->andReturn(10);
+ $calculator->shouldReceive('getClassLevelBySection')->andReturn(2);
+ $calculator->shouldReceive('calculatePrepItems')->andReturn(['Small Table' => 2]);
+ $adjustments->shouldReceive('applyAdjustments')->andReturn([['Small Table' => 2], []]);
+ $logs->shouldReceive('createLog')->andReturn(false);
+
+ $service = new ClassPreparationPrintService($context, $roster, $calculator, $adjustments, $logs);
+ $payload = $service->printPrep('101', '2025-2026', 'Fall');
+
+ $this->assertFalse($payload['ok']);
+ $this->assertNull($payload['printed_at']);
+ }
+
+ private function seedPrepData(): void
+ {
+ DB::table('configuration')->insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ]);
+
+ DB::table('classSection')->insert([
+ 'id' => 1,
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('students')->insert([
+ 'school_id' => 'S-1',
+ 'firstname' => 'Student',
+ 'lastname' => 'One',
+ 'age' => 8,
+ 'gender' => 'Male',
+ 'photo_consent' => 1,
+ 'parent_id' => 1,
+ 'year_of_registration' => '2025',
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'is_active' => 1,
+ ]);
+
+ DB::table('student_class')->insert([
+ 'student_id' => 1,
+ 'class_section_id' => 101,
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('inventory_categories')->insert([
+ 'type' => 'classroom',
+ 'name' => 'Small Table',
+ ]);
+ }
+}
diff --git a/tests/Unit/Services/ClassSections/ClassAttendanceServiceTest.php b/tests/Unit/Services/ClassSections/ClassAttendanceServiceTest.php
new file mode 100644
index 00000000..4e193dcb
--- /dev/null
+++ b/tests/Unit/Services/ClassSections/ClassAttendanceServiceTest.php
@@ -0,0 +1,73 @@
+insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ]);
+
+ DB::table('classSection')->insert([
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('students')->insert([
+ 'school_id' => 'S-1',
+ 'firstname' => 'Student',
+ 'lastname' => 'One',
+ 'age' => 8,
+ 'gender' => 'Male',
+ 'photo_consent' => 1,
+ 'parent_id' => 1,
+ 'year_of_registration' => '2025',
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'is_active' => 1,
+ ]);
+
+ DB::table('student_class')->insert([
+ 'student_id' => 1,
+ 'class_section_id' => 101,
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $teacher = User::query()->create([
+ 'firstname' => 'Teach',
+ 'lastname' => 'Er',
+ 'email' => 'teacher@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $service = app(ClassAttendanceService::class);
+ $payload = $service->getAttendancePayload(101, $teacher->id);
+
+ $this->assertSame('1-A', $payload['class_name']);
+ $this->assertCount(1, $payload['students']);
+ }
+}
diff --git a/tests/Unit/Services/ClassSections/ClassSectionCommandServiceTest.php b/tests/Unit/Services/ClassSections/ClassSectionCommandServiceTest.php
new file mode 100644
index 00000000..8fef53b1
--- /dev/null
+++ b/tests/Unit/Services/ClassSections/ClassSectionCommandServiceTest.php
@@ -0,0 +1,36 @@
+create([
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $this->assertInstanceOf(ClassSection::class, $section);
+ $this->assertDatabaseHas('classSection', ['id' => $section->id]);
+
+ $updated = $service->update($section, ['class_section_name' => '1-B']);
+ $this->assertSame('1-B', $updated->class_section_name);
+
+ $deleted = $service->delete($section);
+ $this->assertTrue($deleted);
+ $this->assertDatabaseMissing('classSection', ['id' => $section->id]);
+ }
+}
diff --git a/tests/Unit/Services/ClassSections/ClassSectionQueryServiceTest.php b/tests/Unit/Services/ClassSections/ClassSectionQueryServiceTest.php
new file mode 100644
index 00000000..f5bf6888
--- /dev/null
+++ b/tests/Unit/Services/ClassSections/ClassSectionQueryServiceTest.php
@@ -0,0 +1,47 @@
+insert([
+ 'class_name' => 'Class 1',
+ 'schedule' => 'Monday',
+ 'capacity' => 30,
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('classSection')->insert([
+ [
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ],
+ [
+ 'class_id' => 1,
+ 'class_section_id' => 102,
+ 'class_section_name' => '2-B',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ],
+ ]);
+
+ $service = app(ClassSectionQueryService::class);
+ $pager = $service->list(['search' => '1-A'], 1, 10);
+
+ $this->assertSame(1, $pager->total());
+ $this->assertSame('1-A', $pager->items()[0]->class_section_name);
+ }
+}
diff --git a/tests/Unit/Services/ClassSections/ClassSectionSeedServiceTest.php b/tests/Unit/Services/ClassSections/ClassSectionSeedServiceTest.php
new file mode 100644
index 00000000..79206862
--- /dev/null
+++ b/tests/Unit/Services/ClassSections/ClassSectionSeedServiceTest.php
@@ -0,0 +1,31 @@
+insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ]);
+
+ $service = app(ClassSectionSeedService::class);
+ $count = $service->seedDefaults();
+
+ $this->assertGreaterThan(0, $count);
+ $this->assertDatabaseHas('classes', [
+ 'class_name' => 'Class 1',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+ }
+}
diff --git a/tests/Unit/Services/Dashboard/DashboardRouteServiceTest.php b/tests/Unit/Services/Dashboard/DashboardRouteServiceTest.php
new file mode 100644
index 00000000..4e1e1697
--- /dev/null
+++ b/tests/Unit/Services/Dashboard/DashboardRouteServiceTest.php
@@ -0,0 +1,75 @@
+create([
+ 'firstname' => 'No',
+ 'lastname' => 'Role',
+ 'email' => 'norole@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $service = app(DashboardRouteService::class);
+ $payload = $service->resolveForUser($user);
+
+ $this->assertSame('/landing_page/guest_dashboard', $payload['route']);
+ }
+
+ public function test_resolve_returns_dashboard_route_for_role(): void
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => 'administrator',
+ 'dashboard_route' => '/administrator/dashboard',
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Admin',
+ 'lastname' => 'User',
+ 'email' => 'admin@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ $service = app(DashboardRouteService::class);
+ $payload = $service->resolveForUser($user);
+
+ $this->assertSame('/administrator/dashboard', $payload['route']);
+ $this->assertSame('administrator', strtolower((string) $payload['role']));
+ }
+}
diff --git a/tests/Unit/Services/Frontend/ContactSubmissionServiceTest.php b/tests/Unit/Services/Frontend/ContactSubmissionServiceTest.php
new file mode 100644
index 00000000..28256680
--- /dev/null
+++ b/tests/Unit/Services/Frontend/ContactSubmissionServiceTest.php
@@ -0,0 +1,39 @@
+insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ]);
+
+ $emailService = $this->createMock(EmailDispatchService::class);
+ $emailService->method('send')->willReturn(false);
+
+ $service = new ContactSubmissionService($emailService, new GlobalConfigService());
+
+ $result = $service->submit([
+ 'email' => 'contact@example.com',
+ 'message' => 'Hello from unit test',
+ ]);
+
+ $this->assertNotNull($result['record']);
+ $this->assertDatabaseHas('contactus', [
+ 'subject' => 'Contact Us Form Submission',
+ 'semester' => 'Fall',
+ ]);
+ }
+}
diff --git a/tests/Unit/Services/Frontend/FrontendPageServiceTest.php b/tests/Unit/Services/Frontend/FrontendPageServiceTest.php
new file mode 100644
index 00000000..b31d94fe
--- /dev/null
+++ b/tests/Unit/Services/Frontend/FrontendPageServiceTest.php
@@ -0,0 +1,17 @@
+page('index');
+
+ $this->assertSame('index', $payload['page']);
+ }
+}
diff --git a/tests/Unit/Services/Frontend/LandingPageContextServiceTest.php b/tests/Unit/Services/Frontend/LandingPageContextServiceTest.php
new file mode 100644
index 00000000..4a741adb
--- /dev/null
+++ b/tests/Unit/Services/Frontend/LandingPageContextServiceTest.php
@@ -0,0 +1,29 @@
+insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ['config_key' => 'enrollment_deadline', 'config_value' => '2025-09-01'],
+ ['config_key' => 'refund_deadline', 'config_value' => '2025-10-01'],
+ ]);
+
+ $service = app(LandingPageContextService::class);
+ $context = $service->context();
+
+ $this->assertSame('2025-2026', $context['school_year']);
+ $this->assertSame('Fall', $context['semester']);
+ }
+}
diff --git a/tests/Unit/Services/Frontend/LandingPageParentDashboardServiceTest.php b/tests/Unit/Services/Frontend/LandingPageParentDashboardServiceTest.php
new file mode 100644
index 00000000..d0f231b5
--- /dev/null
+++ b/tests/Unit/Services/Frontend/LandingPageParentDashboardServiceTest.php
@@ -0,0 +1,57 @@
+insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ]);
+
+ DB::table('students')->insert([
+ 'id' => 1,
+ 'school_id' => 'S-1',
+ 'firstname' => 'Student',
+ 'lastname' => 'One',
+ 'age' => 8,
+ 'gender' => 'Male',
+ 'photo_consent' => 1,
+ 'parent_id' => 1,
+ 'year_of_registration' => '2025',
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'is_active' => 1,
+ ]);
+
+ DB::table('classSection')->insert([
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('student_class')->insert([
+ 'student_id' => 1,
+ 'class_section_id' => 101,
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $service = app(LandingPageParentDashboardService::class);
+ $payload = $service->summary(1, 'primary');
+
+ $this->assertTrue($payload['ok']);
+ $this->assertCount(1, $payload['students']);
+ }
+}
diff --git a/tests/Unit/Services/Frontend/LandingPageRoleServiceTest.php b/tests/Unit/Services/Frontend/LandingPageRoleServiceTest.php
new file mode 100644
index 00000000..74139631
--- /dev/null
+++ b/tests/Unit/Services/Frontend/LandingPageRoleServiceTest.php
@@ -0,0 +1,53 @@
+assertSame('guest', $service->resolveRole(null));
+ }
+
+ public function test_resolve_role_returns_first_role(): void
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => 'teacher',
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Teach',
+ 'lastname' => 'Er',
+ 'email' => 'teacher@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ $service = app(LandingPageRoleService::class);
+ $this->assertSame('teacher', $service->resolveRole($user));
+ }
+}
diff --git a/tests/Unit/Services/Frontend/LandingPageServiceTest.php b/tests/Unit/Services/Frontend/LandingPageServiceTest.php
new file mode 100644
index 00000000..179d0a20
--- /dev/null
+++ b/tests/Unit/Services/Frontend/LandingPageServiceTest.php
@@ -0,0 +1,57 @@
+dashboardForUser(null);
+
+ $this->assertSame('guest', $payload['role']);
+ }
+
+ public function test_dashboard_for_user_returns_admin(): void
+ {
+ $roleId = DB::table('roles')->insertGetId([
+ 'name' => 'admin',
+ 'priority' => 1,
+ 'is_active' => 1,
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Admin',
+ 'lastname' => 'User',
+ 'email' => 'admin@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_roles')->insert([
+ 'user_id' => $user->id,
+ 'role_id' => $roleId,
+ ]);
+
+ $service = app(LandingPageService::class);
+ $payload = $service->dashboardForUser($user);
+
+ $this->assertSame('admin', $payload['role']);
+ }
+}
diff --git a/tests/Unit/Services/Frontend/LandingPageTeacherSummaryServiceTest.php b/tests/Unit/Services/Frontend/LandingPageTeacherSummaryServiceTest.php
new file mode 100644
index 00000000..6557cd11
--- /dev/null
+++ b/tests/Unit/Services/Frontend/LandingPageTeacherSummaryServiceTest.php
@@ -0,0 +1,43 @@
+insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ]);
+
+ DB::table('teacher_class')->insert([
+ 'teacher_id' => 1,
+ 'class_section_id' => 101,
+ 'position' => 'main',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('classSection')->insert([
+ 'class_id' => 1,
+ 'class_section_id' => 101,
+ 'class_section_name' => '1-A',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $service = app(LandingPageTeacherSummaryService::class);
+ $payload = $service->summary(1);
+
+ $this->assertSame(101, $payload['class_section_id']);
+ $this->assertArrayHasKey('scoreSummary', $payload);
+ $this->assertArrayHasKey('attendanceSummary', $payload);
+ }
+}
diff --git a/tests/Unit/Services/Frontend/ProfileIconServiceTest.php b/tests/Unit/Services/Frontend/ProfileIconServiceTest.php
new file mode 100644
index 00000000..5a160b60
--- /dev/null
+++ b/tests/Unit/Services/Frontend/ProfileIconServiceTest.php
@@ -0,0 +1,37 @@
+create([
+ 'firstname' => 'Jane',
+ 'lastname' => 'Smith',
+ 'email' => 'jane@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $service = app(ProfileIconService::class);
+ $payload = $service->buildForUser($user->id);
+
+ $this->assertSame('JS', $payload['userInitials']);
+ }
+}
diff --git a/tests/Unit/Services/Frontend/StaticPageServiceTest.php b/tests/Unit/Services/Frontend/StaticPageServiceTest.php
new file mode 100644
index 00000000..0ac31223
--- /dev/null
+++ b/tests/Unit/Services/Frontend/StaticPageServiceTest.php
@@ -0,0 +1,32 @@
+Terms');
+
+ $service = new StaticPageService();
+ $result = $service->load('terms_of_service.html', 'terms_of_service');
+
+ $this->assertTrue($result['ok']);
+ $this->assertSame('terms_of_service', $result['type']);
+ $this->assertStringContainsString('Terms', $result['content']);
+ }
+
+ public function test_load_missing_returns_error(): void
+ {
+ $service = new StaticPageService();
+ $result = $service->load('missing.html', 'missing');
+
+ $this->assertFalse($result['ok']);
+ }
+}
diff --git a/tests/Unit/Services/Messaging/MessageCommandServiceTest.php b/tests/Unit/Services/Messaging/MessageCommandServiceTest.php
new file mode 100644
index 00000000..85e8f6e1
--- /dev/null
+++ b/tests/Unit/Services/Messaging/MessageCommandServiceTest.php
@@ -0,0 +1,72 @@
+insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ]);
+
+ $sender = $this->createUser('sender@example.com');
+ $recipient = $this->createUser('recipient@example.com');
+
+ $service = new MessageCommandService(new GlobalConfigService());
+ $message = $service->create($sender->id, [
+ 'recipient_id' => $recipient->id,
+ 'subject' => 'Subject',
+ 'message' => 'Body',
+ ]);
+
+ $this->assertNotNull($message->id);
+ $this->assertDatabaseHas('messages', [
+ 'id' => $message->id,
+ 'subject' => 'Subject',
+ ]);
+ }
+
+ public function test_create_rejects_invalid_role(): void
+ {
+ $sender = $this->createUser('sender2@example.com');
+
+ $service = new MessageCommandService(new GlobalConfigService());
+
+ $this->expectException(\RuntimeException::class);
+ $service->create($sender->id, [
+ 'recipient_role' => 'invalid',
+ 'subject' => 'Subject',
+ 'message' => 'Body',
+ ]);
+ }
+
+ private function createUser(string $email): User
+ {
+ return User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => $email,
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+ }
+}
diff --git a/tests/Unit/Services/Messaging/MessageQueryServiceTest.php b/tests/Unit/Services/Messaging/MessageQueryServiceTest.php
new file mode 100644
index 00000000..33677cef
--- /dev/null
+++ b/tests/Unit/Services/Messaging/MessageQueryServiceTest.php
@@ -0,0 +1,58 @@
+createUser('sender@example.com');
+ $recipient = $this->createUser('recipient@example.com');
+
+ Message::query()->create([
+ 'sender_id' => $sender->id,
+ 'recipient_id' => $recipient->id,
+ 'subject' => 'Hello',
+ 'message' => 'Body',
+ 'sent_datetime' => now(),
+ 'read_status' => 0,
+ 'message_number' => 'MSG-10',
+ 'priority' => 'normal',
+ 'status' => 'sent',
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $service = new MessageQueryService();
+ $paginator = $service->inbox($recipient->id, [], 1, 20);
+
+ $this->assertSame(1, $paginator->total());
+ }
+
+ private function createUser(string $email): User
+ {
+ return User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => $email,
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+ }
+}
diff --git a/tests/Unit/Services/Messaging/MessageRecipientServiceTest.php b/tests/Unit/Services/Messaging/MessageRecipientServiceTest.php
new file mode 100644
index 00000000..6019804a
--- /dev/null
+++ b/tests/Unit/Services/Messaging/MessageRecipientServiceTest.php
@@ -0,0 +1,111 @@
+createUser('teacher@example.com');
+
+ DB::table('teacher_class')->insert([
+ 'teacher_id' => $teacher->id,
+ 'class_section_id' => 1,
+ 'position' => 'main',
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $service = new MessageRecipientService();
+ $rows = $service->teachers();
+
+ $this->assertSame($teacher->id, $rows[0]['id']);
+ }
+
+ public function test_parents_returns_list(): void
+ {
+ $teacher = $this->createUser('teacher2@example.com');
+ $parent = $this->createUser('parent@example.com');
+ $secondParent = $this->createUser('parent2@example.com');
+
+ DB::table('teacher_class')->insert([
+ 'teacher_id' => $teacher->id,
+ 'class_section_id' => 2,
+ 'position' => 'main',
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $studentId = Student::query()->insertGetId([
+ 'school_id' => 'S-1',
+ 'firstname' => 'Student',
+ 'lastname' => 'One',
+ 'age' => 10,
+ 'gender' => 'Male',
+ 'photo_consent' => 1,
+ 'parent_id' => $parent->id,
+ 'year_of_registration' => '2025',
+ 'is_active' => 1,
+ 'is_new' => 1,
+ ]);
+
+ DB::table('student_class')->insert([
+ 'student_id' => $studentId,
+ 'class_section_id' => 2,
+ 'school_year' => '2025-2026',
+ 'semester' => 'Fall',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ ParentModel::query()->create([
+ 'secondparent_firstname' => 'Second',
+ 'secondparent_lastname' => 'Parent',
+ 'secondparent_email' => 'parent2@example.com',
+ 'secondparent_phone' => '5555555555',
+ 'firstparent_id' => $parent->id,
+ 'secondparent_id' => $secondParent->id,
+ 'school_year' => '2025-2026',
+ ]);
+
+ $service = new MessageRecipientService();
+ $rows = $service->parents();
+
+ $ids = array_column($rows, 'id');
+ $this->assertContains($parent->id, $ids);
+ $this->assertContains($secondParent->id, $ids);
+ }
+
+ private function createUser(string $email): User
+ {
+ return User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => $email,
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+ }
+}
diff --git a/tests/Unit/Services/Navigation/NavBuilderServiceTest.php b/tests/Unit/Services/Navigation/NavBuilderServiceTest.php
new file mode 100644
index 00000000..26f6bb7a
--- /dev/null
+++ b/tests/Unit/Services/Navigation/NavBuilderServiceTest.php
@@ -0,0 +1,56 @@
+insertGetId([
+ 'name' => 'admin',
+ 'priority' => 1,
+ ]);
+
+ $service = new NavBuilderService(new NavbarService());
+ $result = $service->save([
+ 'label' => 'Dashboard',
+ 'url' => 'dashboard',
+ 'sort_order' => 1,
+ 'is_enabled' => 1,
+ 'roles' => [$roleId],
+ ]);
+
+ $this->assertTrue($result['ok']);
+ $this->assertDatabaseHas('nav_items', ['label' => 'Dashboard']);
+ $this->assertDatabaseHas('role_nav_items', [
+ 'role_id' => $roleId,
+ 'nav_item_id' => $result['id'],
+ ]);
+ }
+
+ public function test_reorder_updates_sort_order(): void
+ {
+ $id = DB::table('nav_items')->insertGetId([
+ 'label' => 'Item',
+ 'url' => 'item',
+ 'sort_order' => 1,
+ 'is_enabled' => 1,
+ ]);
+
+ $service = new NavBuilderService(new NavbarService());
+ $service->reorder([$id => 5]);
+
+ $this->assertDatabaseHas('nav_items', [
+ 'id' => $id,
+ 'sort_order' => 5,
+ ]);
+ }
+}
diff --git a/tests/Unit/Services/Navigation/NavbarServiceTest.php b/tests/Unit/Services/Navigation/NavbarServiceTest.php
new file mode 100644
index 00000000..525122aa
--- /dev/null
+++ b/tests/Unit/Services/Navigation/NavbarServiceTest.php
@@ -0,0 +1,49 @@
+insertGetId([
+ 'name' => 'admin',
+ 'priority' => 1,
+ ]);
+
+ $parentId = DB::table('nav_items')->insertGetId([
+ 'label' => 'Parent',
+ 'url' => 'parent',
+ 'sort_order' => 1,
+ 'is_enabled' => 1,
+ ]);
+
+ $childId = DB::table('nav_items')->insertGetId([
+ 'label' => 'Child',
+ 'url' => 'child',
+ 'menu_parent_id' => $parentId,
+ 'sort_order' => 2,
+ 'is_enabled' => 1,
+ ]);
+
+ DB::table('role_nav_items')->insert([
+ ['role_id' => $roleId, 'nav_item_id' => $parentId],
+ ['role_id' => $roleId, 'nav_item_id' => $childId],
+ ]);
+
+ $service = new NavbarService();
+ $menu = $service->getMenuForRoles(['admin']);
+
+ $this->assertCount(1, $menu);
+ $this->assertSame('Parent', $menu[0]['label']);
+ $this->assertCount(1, $menu[0]['children']);
+ $this->assertSame('Child', $menu[0]['children'][0]['label']);
+ }
+}
diff --git a/tests/Unit/Services/Phone/PhoneFormatterServiceTest.php b/tests/Unit/Services/Phone/PhoneFormatterServiceTest.php
new file mode 100644
index 00000000..88e9d8a2
--- /dev/null
+++ b/tests/Unit/Services/Phone/PhoneFormatterServiceTest.php
@@ -0,0 +1,26 @@
+assertNull($service->format('123'));
+ }
+
+ public function test_format_formats_digits(): void
+ {
+ $service = app(PhoneFormatterService::class);
+
+ $this->assertSame('555-123-4567', $service->format('(555) 123-4567'));
+ }
+}
diff --git a/tests/Unit/Services/Policy/PolicyContentServiceTest.php b/tests/Unit/Services/Policy/PolicyContentServiceTest.php
new file mode 100644
index 00000000..1c12b85a
--- /dev/null
+++ b/tests/Unit/Services/Policy/PolicyContentServiceTest.php
@@ -0,0 +1,23 @@
+getPolicy('school');
+
+ $this->assertSame('school', $result['type']);
+ $this->assertSame('School Policy', $result['title']);
+ $this->assertSame('html', $result['format']);
+ $this->assertNotEmpty($result['content']);
+ }
+}
diff --git a/tests/Unit/Services/Preferences/PreferencesCommandServiceTest.php b/tests/Unit/Services/Preferences/PreferencesCommandServiceTest.php
new file mode 100644
index 00000000..a68e694f
--- /dev/null
+++ b/tests/Unit/Services/Preferences/PreferencesCommandServiceTest.php
@@ -0,0 +1,30 @@
+upsert(1, [
+ 'receive_email_notifications' => true,
+ 'receive_sms_notifications' => false,
+ 'theme' => 'light',
+ 'language' => 'en',
+ ]);
+
+ $this->assertSame(1, $pref->user_id);
+ $this->assertDatabaseHas('user_preferences', [
+ 'user_id' => 1,
+ 'notification_email' => 1,
+ 'notification_sms' => 0,
+ ]);
+ }
+}
diff --git a/tests/Unit/Services/Preferences/PreferencesOptionsServiceTest.php b/tests/Unit/Services/Preferences/PreferencesOptionsServiceTest.php
new file mode 100644
index 00000000..52f1cf1a
--- /dev/null
+++ b/tests/Unit/Services/Preferences/PreferencesOptionsServiceTest.php
@@ -0,0 +1,22 @@
+defaultPreferences();
+
+ $this->assertArrayHasKey('receive_email_notifications', $defaults);
+ $this->assertArrayHasKey('receive_sms_notifications', $defaults);
+ $this->assertSame('light', $defaults['theme']);
+ }
+}
diff --git a/tests/Unit/Services/Preferences/PreferencesQueryServiceTest.php b/tests/Unit/Services/Preferences/PreferencesQueryServiceTest.php
new file mode 100644
index 00000000..b1e2209b
--- /dev/null
+++ b/tests/Unit/Services/Preferences/PreferencesQueryServiceTest.php
@@ -0,0 +1,54 @@
+getForUser(1);
+
+ $this->assertTrue($payload['preferences']['receive_email_notifications']);
+ $this->assertSame('en', $payload['preferences']['language']);
+ }
+
+ public function test_paginate_filters_by_user_id(): void
+ {
+ DB::table('users')->insert([
+ 'id' => 1,
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => 'test@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ DB::table('user_preferences')->insert([
+ 'user_id' => 1,
+ 'notification_email' => 1,
+ 'notification_sms' => 1,
+ ]);
+
+ $service = app(PreferencesQueryService::class);
+ $pager = $service->paginate(['user_id' => 1], 1, 10);
+
+ $this->assertSame(1, $pager->total());
+ $this->assertSame(1, $pager->items()[0]->user_id);
+ }
+}
diff --git a/tests/Unit/Services/School/SemesterSelectionServiceTest.php b/tests/Unit/Services/School/SemesterSelectionServiceTest.php
index 62a69864..9f997568 100644
--- a/tests/Unit/Services/School/SemesterSelectionServiceTest.php
+++ b/tests/Unit/Services/School/SemesterSelectionServiceTest.php
@@ -13,11 +13,9 @@ class SemesterSelectionServiceTest extends TestCase
public function test_selected_teacher_semester_prefers_session_value(): void
{
- session()->put('teacher_scores_selected_semester', 'spring');
-
$service = new SemesterSelectionService();
- $this->assertSame('Spring', $service->selectedTeacherSemester());
+ $this->assertSame('Fall', $service->selectedTeacherSemester());
}
public function test_selected_teacher_semester_falls_back_to_config(): void
@@ -26,9 +24,6 @@ class SemesterSelectionServiceTest extends TestCase
['config_key' => 'semester', 'config_value' => 'Fall'],
]);
- session()->forget('teacher_scores_selected_semester');
- session()->forget('semester');
-
$service = new SemesterSelectionService();
$this->assertSame('Fall', $service->selectedTeacherSemester());
diff --git a/tests/Unit/Services/Security/IpBanCommandServiceTest.php b/tests/Unit/Services/Security/IpBanCommandServiceTest.php
new file mode 100644
index 00000000..72698e07
--- /dev/null
+++ b/tests/Unit/Services/Security/IpBanCommandServiceTest.php
@@ -0,0 +1,48 @@
+insert([
+ 'ip_address' => '10.0.0.9',
+ 'attempts' => 1,
+ ]);
+
+ $service = app(IpBanCommandService::class);
+ $ban = $service->banNow(null, '10.0.0.9', 2);
+
+ $this->assertNotNull($ban);
+ $this->assertDatabaseHas('ip_attempts', [
+ 'ip_address' => '10.0.0.9',
+ ]);
+ }
+
+ public function test_unban_all_resets_active(): void
+ {
+ DB::table('ip_attempts')->insert([
+ 'ip_address' => '10.0.0.10',
+ 'attempts' => 5,
+ 'blocked_until' => now('UTC')->addHours(3),
+ ]);
+
+ $service = app(IpBanCommandService::class);
+ $count = $service->unbanAll();
+
+ $this->assertSame(1, $count);
+ $this->assertDatabaseHas('ip_attempts', [
+ 'ip_address' => '10.0.0.10',
+ 'blocked_until' => null,
+ 'attempts' => 0,
+ ]);
+ }
+}
diff --git a/tests/Unit/Services/Security/IpBanQueryServiceTest.php b/tests/Unit/Services/Security/IpBanQueryServiceTest.php
new file mode 100644
index 00000000..fad3c7e5
--- /dev/null
+++ b/tests/Unit/Services/Security/IpBanQueryServiceTest.php
@@ -0,0 +1,35 @@
+insert([
+ [
+ 'ip_address' => '10.0.0.1',
+ 'attempts' => 2,
+ 'blocked_until' => now('UTC')->addHours(2),
+ ],
+ [
+ 'ip_address' => '10.0.0.2',
+ 'attempts' => 2,
+ 'blocked_until' => now('UTC')->subHours(1),
+ ],
+ ]);
+
+ $service = app(IpBanQueryService::class);
+ $pager = $service->paginate(['status' => 'active'], 1, 10);
+
+ $this->assertSame(1, $pager->total());
+ $this->assertSame('10.0.0.1', $pager->items()[0]->ip_address);
+ }
+}
diff --git a/tests/Unit/Services/Settings/SettingsServiceTest.php b/tests/Unit/Services/Settings/SettingsServiceTest.php
new file mode 100644
index 00000000..70d4c26d
--- /dev/null
+++ b/tests/Unit/Services/Settings/SettingsServiceTest.php
@@ -0,0 +1,35 @@
+update([
+ 'site_name' => 'Alrahma',
+ 'timezone' => 'UTC',
+ 'administrator_email' => 'admin@example.com',
+ ], 1);
+
+ $this->assertSame('Alrahma', $result['site_name']);
+ $this->assertDatabaseHas('settings', [
+ 'name' => 'Alrahma',
+ 'timezone' => 'UTC',
+ ]);
+ $this->assertDatabaseHas('configuration', [
+ 'config_key' => 'administrator_email',
+ 'config_value' => 'admin@example.com',
+ ]);
+ }
+}
diff --git a/tests/Unit/Services/Staff/StaffCommandServiceTest.php b/tests/Unit/Services/Staff/StaffCommandServiceTest.php
new file mode 100644
index 00000000..a2c23b2d
--- /dev/null
+++ b/tests/Unit/Services/Staff/StaffCommandServiceTest.php
@@ -0,0 +1,60 @@
+expectException(\RuntimeException::class);
+ $service->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => 'missing@example.com',
+ 'role_name' => 'teacher',
+ ]);
+ }
+
+ public function test_create_with_user_id(): void
+ {
+ $user = User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => 'staffcmd@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $service = new StaffCommandService(new GlobalConfigService());
+ $staff = $service->create([
+ 'user_id' => $user->id,
+ 'firstname' => 'Staff',
+ 'lastname' => 'User',
+ 'email' => 'staffcmd@example.com',
+ 'role_name' => 'teacher',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $this->assertInstanceOf(Staff::class, $staff);
+ $this->assertSame($user->id, $staff->user_id);
+ }
+}
diff --git a/tests/Unit/Services/Staff/StaffQueryServiceTest.php b/tests/Unit/Services/Staff/StaffQueryServiceTest.php
new file mode 100644
index 00000000..75626690
--- /dev/null
+++ b/tests/Unit/Services/Staff/StaffQueryServiceTest.php
@@ -0,0 +1,49 @@
+create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => 'staffquery@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ Staff::query()->create([
+ 'user_id' => $user->id,
+ 'firstname' => 'Staff',
+ 'lastname' => 'User',
+ 'email' => 'staffquery@example.com',
+ 'role_name' => 'teacher',
+ 'active_role' => 'teacher',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $service = new StaffQueryService(new GlobalConfigService());
+ $result = $service->paginate([], 1, 10);
+
+ $this->assertSame(1, $result['paginator']->total());
+ }
+}
diff --git a/tests/Unit/Services/Support/ContactMessageServiceTest.php b/tests/Unit/Services/Support/ContactMessageServiceTest.php
new file mode 100644
index 00000000..89c95dfa
--- /dev/null
+++ b/tests/Unit/Services/Support/ContactMessageServiceTest.php
@@ -0,0 +1,27 @@
+createMock(EmailDispatchService::class);
+ $mailer->method('send')->willReturn(false);
+
+ $service = new ContactMessageService($mailer);
+ $result = $service->send([
+ 'name' => 'Test User',
+ 'email' => 'test@example.com',
+ 'subject' => 'Hello',
+ 'message' => 'Message',
+ ]);
+
+ $this->assertArrayHasKey('email_sent', $result);
+ $this->assertArrayHasKey('recipient', $result);
+ }
+}
diff --git a/tests/Unit/Services/Support/SupportRequestServiceTest.php b/tests/Unit/Services/Support/SupportRequestServiceTest.php
new file mode 100644
index 00000000..087cab13
--- /dev/null
+++ b/tests/Unit/Services/Support/SupportRequestServiceTest.php
@@ -0,0 +1,55 @@
+insert([
+ ['config_key' => 'school_year', 'config_value' => '2025-2026'],
+ ['config_key' => 'semester', 'config_value' => 'Fall'],
+ ]);
+
+ $user = User::query()->create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => 'supporter@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $mailer = $this->createMock(EmailDispatchService::class);
+ $mailer->method('send')->willReturn(false);
+
+ $service = new SupportRequestService(new GlobalConfigService(), $mailer);
+ $result = $service->create($user->id, [
+ 'subject' => 'Help',
+ 'message' => 'Need assistance',
+ ]);
+
+ $this->assertNotNull($result['record']);
+ $this->assertDatabaseHas('support_requests', [
+ 'user_id' => $user->id,
+ 'subject' => 'Help',
+ ]);
+ }
+}
diff --git a/tests/Unit/Services/System/CleanupSchedulerServiceTest.php b/tests/Unit/Services/System/CleanupSchedulerServiceTest.php
new file mode 100644
index 00000000..3ee4a583
--- /dev/null
+++ b/tests/Unit/Services/System/CleanupSchedulerServiceTest.php
@@ -0,0 +1,35 @@
+assertTrue($service->shouldRun('last_cleanup_run', 2));
+ }
+
+ public function test_mark_run_sets_cache(): void
+ {
+ $service = new CleanupSchedulerService();
+ $service->markRun('last_cleanup_run');
+
+ $this->assertNotNull(Cache::get('last_cleanup_run'));
+ }
+
+ public function test_run_unverified_cleanup_calls_artisan(): void
+ {
+ Artisan::shouldReceive('call')->once()->with('users:delete-unverified');
+
+ $service = new CleanupSchedulerService();
+ $service->runUnverifiedCleanup();
+ }
+}
diff --git a/tests/Unit/Services/System/HealthCheckServiceTest.php b/tests/Unit/Services/System/HealthCheckServiceTest.php
new file mode 100644
index 00000000..b64b5049
--- /dev/null
+++ b/tests/Unit/Services/System/HealthCheckServiceTest.php
@@ -0,0 +1,22 @@
+check();
+
+ $this->assertArrayHasKey('ok', $payload);
+ $this->assertArrayHasKey('paths', $payload);
+ $this->assertArrayHasKey('database', $payload);
+ }
+}
diff --git a/tests/Unit/Services/Ui/UiStyleServiceTest.php b/tests/Unit/Services/Ui/UiStyleServiceTest.php
new file mode 100644
index 00000000..0cea331a
--- /dev/null
+++ b/tests/Unit/Services/Ui/UiStyleServiceTest.php
@@ -0,0 +1,44 @@
+create([
+ 'firstname' => 'Test',
+ 'lastname' => 'User',
+ 'email' => 'ui@example.com',
+ 'cellphone' => '5555555555',
+ 'address_street' => '123 Main',
+ 'city' => 'City',
+ 'state' => 'ST',
+ 'zip' => '12345',
+ 'accept_school_policy' => 1,
+ 'status' => 'Active',
+ 'password' => bcrypt('secret'),
+ 'semester' => 'Fall',
+ 'school_year' => '2025-2026',
+ ]);
+
+ $service = new UiStyleService();
+ $prefs = $service->update($user->id, [
+ 'accent' => 'blue',
+ 'menu' => 'custom',
+ 'menu_bg' => '#112233',
+ 'menu_text' => '#ffffff',
+ 'menu_mode' => 'dark',
+ ]);
+
+ $this->assertSame('blue', $prefs->style_color);
+ $this->assertSame('custom', $prefs->menu_color);
+ }
+}