Compare commits

..

161 Commits

Author SHA1 Message Date
root 48ae2805d3 fix principal access denied
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m19s
2026-08-29 22:48:12 -04:00
root 22c4cf6fd2 fixed enrolled after first payment
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m19s
2026-08-29 18:47:18 -04:00
root 6cf3a607a4 fix installment for carry over balance parent account
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m19s
2026-08-29 18:33:28 -04:00
root 611a5c8e4b fix test and add invocie to parent enrollment
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m23s
2026-08-29 15:25:17 -04:00
root d2cb159451 fix enrollment and registration email send
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Failing after 1m20s
2026-08-28 16:16:59 -04:00
root 3133b3c584 fix principal access denied and fix test
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m20s
2026-08-28 00:26:44 -04:00
root 38644b32ae fix invoice and enrollment fees
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Failing after 1m19s
2026-08-27 18:34:36 -04:00
root 0ae9993d82 fix enrollment and email send to parent
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Failing after 1m20s
2026-08-27 12:39:08 -04:00
root 7b86cf5218 fix teacher and parent pages text font
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m18s
2026-08-26 21:43:37 -04:00
root 9676261d65 fix registration enrollment of students
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m19s
2026-08-26 21:36:03 -04:00
root a354d78fa9 fix reset password
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Failing after 1m20s
2026-08-26 20:45:23 -04:00
root 9899af7b37 fix all issues before deploy
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m18s
2026-08-25 03:10:44 -04:00
root 5f5afe97ad fix test issues
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 2m2s
Tests / PHPUnit (push) Failing after 1m19s
2026-08-24 22:14:05 -04:00
root 9036fcb1f5 update policy
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 2m2s
Tests / PHPUnit (push) Failing after 1m19s
2026-08-24 22:06:13 -04:00
root 608aca79b8 fix enrollment, invoice, payment and financila aid
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m19s
2026-08-24 21:20:58 -04:00
root 576da3bd78 fix the home page
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m21s
2026-08-24 03:07:02 -04:00
root a6e0ce1432 update policy and fix other issues
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m19s
2026-08-23 18:19:08 -04:00
root 103f970110 fix email body
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m18s
2026-08-22 16:55:54 -04:00
root 1d44123410 fix class section distribution
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m19s
2026-08-22 16:40:14 -04:00
root 29dd16db0c fix tests
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 46s
Tests / PHPUnit (push) Successful in 1m18s
2026-08-22 14:15:42 -04:00
root d906a915d6 add refund logic and fix books inventory logic
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m20s
2026-08-22 13:44:25 -04:00
root 23d1cbb64c fix logo circle, start email body
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Failing after 1m17s
2026-08-21 13:28:30 -04:00
root d3da699e55 fix deployment db issue and widthrawal administration page
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m19s
2026-08-20 20:18:00 -04:00
root 889c037660 fix is_new issue with enrollment fixes
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Failing after 1m22s
2026-08-20 19:57:25 -04:00
root 127098b87c fix close year and ignore not active students
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m17s
2026-08-20 17:27:28 -04:00
root 2b0206e7f2 move service logic from grading and administrator controller
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m16s
2026-08-20 16:59:51 -04:00
root 383bbb7269 fix delebration test
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m16s
2026-08-20 16:30:52 -04:00
root 246def8ca9 fix widthraw spelling
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m16s
2026-08-20 16:07:34 -04:00
root bfa343b69f fix is_active student flag and apply it in all pages
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m17s
2026-08-20 12:59:59 -04:00
root 0a31aa1393 fix is_active student flag logic and make moving with enrollment status
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m16s
2026-08-20 03:11:37 -04:00
root 6f722795c5 fix issues deploy
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 46s
Tests / PHPUnit (push) Successful in 1m16s
2026-08-19 23:53:27 -04:00
root 2bfb9b3aa1 fix issue
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 42s
Tests / PHPUnit (push) Successful in 1m24s
2026-08-19 23:51:27 -04:00
root 049baf2cb4 Fixed the failing test in [ExpenseControllerTest.php (line 136)](/Volumes/ExternalApps/repos/alrahma_sunday_school/tests/app/Controllers/View/ExpenseControllerTest.php:136). The test now handles the stubbed view() helper returning an inspectable array, while still accepting the fake renderer string path.
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 28s
Tests / PHPUnit (push) Successful in 3m26s
2026-08-16 17:56:48 -04:00
root 0ac3a8375e Fix semester context, attendance rosters, and billing workflows
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 31s
Tests / PHPUnit (push) Failing after 55s
- load global semester helpers consistently and use date-based semester defaults
- fix grading and daily attendance duplicate student/section rows
- keep attendance violations scoped to the current semester by default
- update invoice, refund, discount, payment, and financial aid flows
- add configuration cleanup migrations for duplicate calendar/semester keys
- refresh parent registration/report-card and print request handling
- update related models, services, views, cron notes, and test coverage
2026-08-16 17:41:11 -04:00
root 36c7e3fc6d Trigger deploy on develop pushes
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 33s
Tests / PHPUnit (push) Successful in 50s
2026-08-15 21:27:52 -04:00
root 717baaea79 Fix last-name exception carry-forward and parent action label
Tests / PHPUnit (push) Successful in 55s
2026-08-15 21:20:51 -04:00
root 3cbfc40934 fix school year and enrollment steps
Tests / PHPUnit (push) Failing after 58s
2026-08-15 21:15:05 -04:00
root 4603d9ced2 fix enrollment logic, add financial aid, fix class distribution
Tests / PHPUnit (push) Failing after 1m6s
2026-08-15 15:07:16 -04:00
root c12bb59372 fix closing year
Tests / PHPUnit (push) Successful in 1m20s
2026-08-10 01:11:16 -04:00
root 1ef2800f12 fix enrollment for the new school-year
Tests / PHPUnit (push) Successful in 1m26s
2026-08-07 23:43:31 -04:00
root b6f3b14e7b fix distribution page
Tests / PHPUnit (push) Successful in 1m17s
2026-07-31 19:49:49 -04:00
root 10dc8a33b4 fix test issues
Tests / PHPUnit (push) Successful in 1m16s
2026-07-31 18:59:46 -04:00
root 09ea144bb2 fix issues related to school year
Tests / PHPUnit (push) Failing after 34s
2026-07-31 18:52:25 -04:00
root 8d7ee3b9fc add autologout session
Tests / PHPUnit (push) Failing after 35s
2026-07-30 01:15:53 -04:00
root 81a72a4c59 fix school year and related issues
Tests / PHPUnit (push) Failing after 34s
2026-07-30 00:48:54 -04:00
root f8f00c70c8 update php
Tests / PHPUnit (push) Failing after 35s
2026-07-29 19:03:57 -04:00
root 8509497326 sort navbar admin
Tests / PHPUnit (push) Successful in 1m23s
2026-07-18 23:37:23 -04:00
root 2be16553df fix test run issue
Tests / PHPUnit (push) Successful in 1m21s
2026-07-18 23:18:49 -04:00
root a30c1398a1 fix financials
Tests / PHPUnit (push) Failing after 1m21s
2026-07-18 22:57:40 -04:00
root 068e739408 fix payment issues
Tests / PHPUnit (push) Successful in 1m17s
2026-07-18 19:12:13 -04:00
root 4aac9472af update manual pay recording
Tests / PHPUnit (push) Successful in 1m17s
2026-07-18 15:06:52 -04:00
root d34dbd2a47 fix tests run
Tests / PHPUnit (push) Successful in 1m14s
2026-07-18 14:50:42 -04:00
root 716cc8b8d3 fix school year for all tables
Tests / PHPUnit (push) Failing after 1m20s
2026-07-18 14:45:38 -04:00
root 4db8334a3c remove school year from pages
Tests / PHPUnit (push) Successful in 1m15s
2026-07-17 01:07:31 -04:00
root 0d0a771dd1 fix teacher pages and adopt the school year
Tests / PHPUnit (push) Successful in 1m14s
2026-07-17 00:38:59 -04:00
root 539d0eb220 fix parent pages to adopt the school year logic
Tests / PHPUnit (push) Successful in 1m20s
2026-07-17 00:02:53 -04:00
root a5517b516a fic deploy packages
Tests / PHPUnit (push) Successful in 1m13s
2026-07-16 01:14:37 -04:00
root 3c8b9b587e fix deploy
Tests / PHPUnit (push) Successful in 1m16s
2026-07-16 01:11:23 -04:00
root d0b0fb9d3d store packages to getia registry
Tests / PHPUnit (push) Successful in 1m14s
2026-07-16 00:52:01 -04:00
root 0699fbbdd6 add deploy creation
Tests / PHPUnit (push) Successful in 1m14s
2026-07-16 00:48:17 -04:00
root 636231b62a fix deploy button visibility
Tests / PHPUnit (push) Successful in 1m13s
2026-07-16 00:41:55 -04:00
root 84be5e44ca add deploy button
Tests / PHPUnit (push) Successful in 1m13s
2026-07-16 00:36:47 -04:00
root df4e2cf994 add deploy
Tests / PHPUnit (push) Successful in 1m16s
2026-07-16 00:34:19 -04:00
root 7c341ca624 fix db for tests
Tests / PHPUnit (push) Successful in 1m16s
2026-07-16 00:23:49 -04:00
root 745c294012 fix the tests by adding db on host
Tests / PHPUnit (push) Failing after 1m2s
2026-07-15 23:55:22 -04:00
root 3408d5ab20 fix tests failure
Tests / PHPUnit (push) Failing after 53s
2026-07-15 23:42:16 -04:00
root ba87598d3a fix pages and add distribution system
Tests / PHPUnit (push) Failing after 1m13s
2026-07-15 23:03:51 -04:00
root 7f3b24e47f fix parent page
Tests / PHPUnit (push) Failing after 1m10s
2026-07-15 21:42:06 -04:00
root 9de2ab2a9f fix teacher pages display between school years
Tests / PHPUnit (push) Failing after 1m12s
2026-07-15 20:52:19 -04:00
root 5f27dccd0f fix parent and teacher pages to follow year filter
Tests / PHPUnit (push) Failing after 1m15s
2026-07-15 20:03:36 -04:00
root feb1b29a32 apply the school year concept
Tests / PHPUnit (push) Failing after 1m19s
2026-07-14 00:59:00 -04:00
root 504c3bc9f9 fix db delete data issue
Tests / PHPUnit (push) Failing after 1m24s
2026-07-13 01:43:32 -04:00
root f3ac521d7c tests fixes
Tests / PHPUnit (push) Failing after 1m18s
2026-07-13 01:02:03 -04:00
root a56aca4342 fix tests issues
Tests / PHPUnit (push) Failing after 1m16s
2026-07-13 00:24:00 -04:00
root 439a695727 fix test issues
Tests / PHPUnit (push) Failing after 53s
2026-07-12 23:39:11 -04:00
root f83ebe66b9 fix tests issues
Tests / PHPUnit (push) Failing after 43s
2026-07-12 20:36:40 -04:00
root 62492a5644 fix test issues
Tests / PHPUnit (push) Failing after 51s
2026-07-12 19:37:19 -04:00
root 84337e8a50 fix test issues
Tests / PHPUnit (push) Failing after 42s
2026-07-12 19:16:49 -04:00
root 55f8027550 fix missing school year pages
Tests / PHPUnit (push) Failing after 42s
2026-07-12 19:09:38 -04:00
root e06ccc9cc0 ADD SCHOOL YEAR MANAGEMENT
Tests / PHPUnit (push) Failing after 40s
2026-07-12 02:21:39 -04:00
root c7f67da9bf fix gitea checkout ssl
Tests / PHPUnit (push) Failing after 40s
2026-07-12 01:16:28 -04:00
root ed95286050 add gitea test workflow
Tests / PHPUnit (push) Failing after 1m19s
2026-07-12 01:06:56 -04:00
root ec9fca8c45 fix db tables to have school year 2026-07-12 01:02:04 -04:00
root ed11cccecc fix payments 2026-07-08 23:30:16 -04:00
root 6e8da3cc2c fix batch issue 2026-06-08 23:20:06 -04:00
root 384ae8b719 fix the forecast tuition 2026-06-07 00:27:27 -04:00
root 654453222f financial fix 2026-06-06 23:30:41 -04:00
root a18e8b92a6 fix batch 2026-06-04 23:09:58 -04:00
root 6444b61416 fix financial issues 2026-06-01 02:08:27 -04:00
root 090cb88573 fix attendance coemment in report card 2026-05-30 17:12:10 -04:00
root 95bcefc3a9 fix decision email 2026-05-30 16:55:20 -04:00
root f24f4311e8 fix delibration and below 60 2026-05-30 15:00:16 -04:00
root 89913d7473 add trophy winner name print 2026-05-30 03:58:59 -04:00
root 079c869477 fix ties 2026-05-30 03:29:48 -04:00
root 9ee75fe4cc fix decisions and rank 2026-05-30 03:19:10 -04:00
root fcfa56b3f5 fix below 60 pages 2026-05-30 02:17:47 -04:00
root 39ab0d113e fix score comments 2026-05-29 01:52:17 -04:00
root ce56c96cdd fix paste button for comment review spring 2026-05-29 00:18:12 -04:00
root b4a06903e2 fix certificate qr code verification 2026-05-28 02:15:08 -04:00
root 0654668530 fix report ranking 2026-05-28 01:55:34 -04:00
root 3737b3522d fix reportcard and certificate 2026-05-27 14:11:08 -04:00
root c294d7bed7 fix logo 2026-05-26 23:14:55 -04:00
root 5ed65a867c report cadr and trophy 2026-05-26 05:11:10 -04:00
root 22d6f960ea move qr code inside the certificate 2026-05-26 03:09:48 -04:00
root cc0b83c1b9 fix attendance spring days 2026-05-26 03:03:03 -04:00
root 31976752da fix font 2026-05-26 02:13:39 -04:00
root 0bf8d13777 fix certificates 2026-05-26 01:44:57 -04:00
root 4ae62e37a3 test new server 2026-05-22 00:58:43 -04:00
root d8fdbeb4c3 fix certificate qr 2026-05-21 18:37:39 -04:00
root 239d35f8ff fix the event print and certificate qr 2026-05-21 17:00:38 -04:00
root 3333e71c94 add qr code to certificate 2026-05-17 17:24:07 -04:00
root af75475214 add certificate generation 2026-05-17 17:04:47 -04:00
root a8ef665239 fix event parent list 2026-05-16 16:03:46 -04:00
root 7c97a60795 add trophy prediction and treshold 2026-05-16 15:35:56 -04:00
root acca87c77b fix tracking attendance 2026-05-09 15:51:34 -04:00
root 9aacbe8972 fix draft exam upload 2026-05-08 00:33:40 -04:00
root 76866ffe1e update login scurity 2026-05-08 00:17:42 -04:00
root b01a4496e6 fixexam files 2026-05-06 18:45:13 -04:00
root 1f51be91e4 fix external kids for events 2026-05-05 22:37:32 -04:00
root 92016f90d0 fix issues on examdraft view and event print page 2026-05-05 01:38:20 -04:00
root 45d7895182 add waiver column to event table 2026-04-19 12:32:30 -04:00
root 596d368b1d fix event issues 2026-04-19 12:05:10 -04:00
root 35988e9ce1 fix logs issues 2026-04-18 11:44:02 -04:00
root 5fc4d8be30 fix double cote issue 2026-04-18 11:33:48 -04:00
root dcfd1f9542 fix users page 2026-04-18 11:29:18 -04:00
root b99f816261 update gitignore 2026-04-18 11:14:08 -04:00
root 931e30b6f1 update gitignore 2026-04-18 11:13:55 -04:00
root 189bb1a628 fix redirect page 2026-04-18 11:06:31 -04:00
root f04934039e add email sent for event 2026-04-18 10:51:22 -04:00
root c13dcb07d1 add event to calendar 2026-04-18 10:18:06 -04:00
root dd1d492c30 fix event charge and invoice update 2026-04-18 09:54:29 -04:00
root a448288878 update event charge as cash payment 2026-04-13 01:42:33 -04:00
root 821e9cdc81 update event charge at the invoice 2026-04-13 01:38:40 -04:00
root 4e2d12e524 fix events logic 2026-04-11 20:08:47 -04:00
root 7bc999643a exam draft fix 2026-04-11 15:33:12 -04:00
root 89e95b7851 add command to send teacher exam draft notification 2026-04-10 16:31:32 -04:00
root 2543df3d33 exam draft notification and submission 2026-04-09 01:50:24 -04:00
root 112d073235 fic exam draft submission 2026-04-08 19:45:47 -04:00
root 2611730ec6 update homework tracking 2026-04-07 18:42:39 -04:00
root 05dad52e10 fix score submission, and class progress 2026-04-04 17:44:40 -04:00
Administrator d158650be9 Merge branch 'develop_fix_expenses' into 'develop'
Fixed the date shift by making date-only strings stay in the user timezone...

See merge request root/alrahma_sunday_school!5
2026-04-02 04:05:31 +00:00
root 61facee902 fix the exam draft 2026-04-02 00:03:59 -04:00
root ed67836701 fix class progress unit numbers calculation 2026-03-31 00:16:55 -04:00
root d2abbc1458 fix duplicate restored student 2026-03-30 23:50:20 -04:00
root b52475ff0b fix teacher submissions 2026-03-30 18:46:04 -04:00
root b2026812d5 AVP-87 Multiple Class updated not showing for multiple students in parent portal 2026-03-29 14:03:50 -04:00
root 58445b2a48 fix all issues 2026-03-24 01:02:36 -04:00
root 0f8a1fa0b1 Fixed the date shift by making date-only strings stay in the user timezone instead of being converted from UTC, which caused the one-day rollback. 2026-03-03 16:46:36 -05:00
Administrator fee07bcceb Merge branch 'develop_fix_bugs' into 'develop'
remove zip file

See merge request root/alrahma_sunday_school!4
2026-03-03 16:24:23 +00:00
root 70a6e2c104 remove zip file 2026-03-02 15:18:30 -05:00
Administrator 11c93d3e82 Merge branch 'develop_fix_bugs' into 'develop'
update financial controller and fix curriculum subjects

See merge request root/alrahma_sunday_school!3
2026-03-02 05:06:01 +00:00
root 7c5028a76d update financial controller and fix curriculum subjects 2026-03-02 00:04:38 -05:00
Administrator aa1260afd6 Merge branch 'develop_fix_bugs' into 'develop'
Develop fix bugs

See merge request root/alrahma_sunday_school!2
2026-03-01 22:25:37 +00:00
root 35b9cba882 AVP-85 Active link when clicking of Parent's email address 2026-03-01 17:24:20 -05:00
root a18198d547 AVP-86 Below 60 score 2026-03-01 16:56:45 -05:00
root 97c3b03c39 Merge branch 'develop' of https://gitlab.rentaldrivego.ma/root/alrahma_sunday_school into develop 2026-03-01 16:35:13 -05:00
root 7770b60658 Add full CodeIgniter 4 + Composer + npm + Docker .gitignore 2026-03-01 16:29:16 -05:00
Administrator 4f5925e10a Merge branch 'feature_updatecode' into 'develop'
Feature updatecode

See merge request root/alrahma_sunday_school!1
2026-02-27 06:27:32 +00:00
root 3cc5546733 AVP-78 Feature to check if parents looked at report card 2026-02-27 01:21:39 -05:00
root fe7b99581d update code 2026-02-26 23:50:24 -05:00
root a6880ea14f update code 2026-02-26 23:27:50 -05:00
10025 changed files with 241256 additions and 1021264 deletions
Vendored
BIN
View File
Binary file not shown.
-180
View File
@@ -1,180 +0,0 @@
#--------------------------------------------------------------------
# Example Environment Configuration file
#
# This file can be used as a starting point for your own
# custom .env files, and contains most of the possible settings
# available in a default install.
#
# By default, all of the settings are commented out. If you want
# to override the setting, you must un-comment it by removing the '#'
# at the beginning of the line.
#--------------------------------------------------------------------
#--------------------------------------------------------------------
# ENVIRONMENT
#--------------------------------------------------------------------
CI_ENVIRONMENT = development
# Which profile to use if none is supplied to sendEmail()
# Options: default, communication, payment, ...
MAIL_PROFILE_DEFAULT=default
# ===== DEFAULT (system) =====
# ---- Legacy fallbacks (kept for compatibility) ----
SMTP_HOST = smtp.gmail.com
SMTP_USER = alrahma.sunday.school@gmail.com
SMTP_PASS = "psnp emdq dykw ypul"
SMTP_PORT = 587
SMTP_ENCRYPTION = tls # was SSL; set to ssl to match 587
# Reply-To for all mail profiles
MAIL_DEFAULT_REPLY_TO=alrahma.isgl@gmail.com
MAIL_DEFAULT_REPLY_TO_NAME="Al Rahma Sunday School"
MAIL_COMMUNICATION_REPLY_TO=alrahma.isgl@gmail.com
MAIL_COMMUNICATION_REPLY_TO_NAME="School Communications"
MAIL_PAYMENT_REPLY_TO=alrahma.isgl@gmail.com
MAIL_PAYMENT_REPLY_TO_NAME="School Payments"
MAIL_DEFAULT_REPLY_TO="alrahma.isgl@gmail.com"
# Optional sender directory you already use elsewhere
MAIL_SENDERS="{\"general\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma No-Reply\"},\
\"registration\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Register Office\"},\
\"notifications\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Notifications\"},\
\"finance\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Finance Office\"}}"
# Principal notification recipient for TimeOff requests (comma-separated allowed)
PRINCIPAL_EMAIL="principal@alrahmaisgl.org"
#--------------------------------------------------------------------
# APP
#--------------------------------------------------------------------
# If you have trouble with `.`, you could also use `_`.
app_baseURL = 'http://localhost:8080/'
# app.forceGlobalSecureRequests = true
# app.CSPEnabled = false
#--------------------------------------------------------------------
# DATABASE
#--------------------------------------------------------------------
database.default.hostname = 127.0.0.1
database.default.database = school
database.default.username = root
database.default.password = root
database.default.DBDriver = MySQLi
database.default.DBPrefix =
database.default.port = 3306
#--------------------------------------------------------------------
# CONTENT SECURITY POLICY
#--------------------------------------------------------------------
# contentsecuritypolicy.reportOnly = false
# contentsecuritypolicy.defaultSrc = 'none'
# contentsecuritypolicy.scriptSrc = 'self'
# contentsecuritypolicy.styleSrc = 'self'
# contentsecuritypolicy.imageSrc = 'self'
# contentsecuritypolicy.baseURI = null
# contentsecuritypolicy.childSrc = null
# contentsecuritypolicy.connectSrc = 'self'
# contentsecuritypolicy.fontSrc = null
# contentsecuritypolicy.formAction = null
# contentsecuritypolicy.frameAncestors = null
# contentsecuritypolicy.frameSrc = null
# contentsecuritypolicy.mediaSrc = null
# contentsecuritypolicy.objectSrc = null
# contentsecuritypolicy.pluginTypes = null
# contentsecuritypolicy.reportURI = null
# contentsecuritypolicy.sandbox = false
# contentsecuritypolicy.upgradeInsecureRequests = false
# contentsecuritypolicy.styleNonceTag = '{csp-style-nonce}'
# contentsecuritypolicy.scriptNonceTag = '{csp-script-nonce}'
# contentsecuritypolicy.autoNonce = true
#--------------------------------------------------------------------
# COOKIE
#--------------------------------------------------------------------
# cookie.prefix = ''
# cookie.expires = 0
# cookie.path = '/'
# cookie.domain = ''
# cookie.secure = false
# cookie.httponly = false
# cookie.samesite = 'Lax'
# cookie.raw = false
#--------------------------------------------------------------------
# ENCRYPTION
#--------------------------------------------------------------------
# encryption.key =
# encryption.driver = OpenSSL
# encryption.blockSize = 16
# encryption.digest = SHA512
#--------------------------------------------------------------------
# HONEYPOT
#--------------------------------------------------------------------
# honeypot.hidden = 'true'
# honeypot.label = 'Fill This Field'
# honeypot.name = 'honeypot'
# honeypot.template = '<label>{label}</label><input type="text" name="{name}" value=""/>'
# honeypot.container = '<div style="display:none">{template}</div>'
#--------------------------------------------------------------------
# SECURITY
#--------------------------------------------------------------------
# security.csrfProtection = 'cookie'
# security.tokenRandomize = false
# security.tokenName = 'csrf_token_name'
# security.headerName = 'X-CSRF-TOKEN'
# security.cookieName = 'csrf_cookie_name'
# security.expires = 7200
# security.regenerate = true
# security.redirect = false
# security.samesite = 'Lax'
#--------------------------------------------------------------------
# SESSION
#--------------------------------------------------------------------
# session.driver = 'CodeIgniter\Session\Handlers\FileHandler'
# session.cookieName = 'ci_session'
# session.expiration = 7200
# session.savePath = null
# session.matchIP = false
# session.timeToUpdate = 300
# session.regenerateDestroy = false
#--------------------------------------------------------------------
# LOGGER
#--------------------------------------------------------------------
# logger.threshold = 4
#--------------------------------------------------------------------
# CURLRequest
#--------------------------------------------------------------------
# curlrequest.shareOptions = false
mail.protocol = smtp
mail.SMTPHost = ${SMTP_HOST}
mail.SMTPUser = ${SMTP_USER}
mail.SMTPPass = ${SMTP_PASS}
mail.SMTPPort = ${SMTP_PORT}
mail.SMTPCrypto = tls
mail.fromEmail = ${SMTP_USER}
mail.fromName = "Al Rahma Sunday School"
mail.mailType = text
mail.charset = UTF-8
+189
View File
@@ -0,0 +1,189 @@
name: Deploy to Shared Hosting
on:
push:
branches:
- develop
workflow_dispatch:
inputs:
target:
description: 'Deployment target'
required: true
default: 'production'
jobs:
deploy:
name: Shared hosting deploy
runs-on: ubuntu-latest
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_APP_PATH: ${{ secrets.DEPLOY_APP_PATH }}
DEPLOY_PUBLIC_PATH: ${{ secrets.DEPLOY_PUBLIC_PATH }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_PHP_BIN: ${{ secrets.DEPLOY_PHP_BIN }}
DEPLOY_INDEX_REQUIRE: ${{ secrets.DEPLOY_INDEX_REQUIRE }}
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
GITEA_PACKAGE_OWNER: ${{ secrets.GITEA_PACKAGE_OWNER }}
GITEA_PACKAGE_USER: ${{ secrets.GITEAPACKAGEUSER }}
GITEA_PACKAGE_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
GIT_SSL_NO_VERIFY: 'true'
steps:
- name: Checkout
uses: actions/checkout@v4
env:
GIT_SSL_NO_VERIFY: 'true'
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: dom, gd, intl, mbstring, mysqli, zip
coverage: none
- name: Validate Composer config
run: composer validate --no-check-publish --strict
- name: Install production dependencies
run: composer install --no-dev --no-interaction --prefer-dist --no-progress --optimize-autoloader
- name: Create deployment artifact
run: bash scripts/build-deploy-artifact.sh
- name: Publish deployment package
if: ${{ env.GITEA_PACKAGE_USER != '' && env.GITEA_PACKAGE_TOKEN != '' }}
run: |
BASE_URL="${GITEA_BASE_URL:-https://192.168.3.80}"
PACKAGE_OWNER="${GITEA_PACKAGE_OWNER:-melabidi}"
PACKAGE_NAME="alrahma_sunday_school"
REF_NAME="${GITHUB_REF_NAME:-manual}"
SHORT_SHA="$(printf '%s' "${GITHUB_SHA}" | cut -c1-7)"
VERSION="$(printf '%s-%s-%s' "${REF_NAME}" "${GITHUB_RUN_NUMBER}" "${SHORT_SHA}" | sed 's/[^A-Za-z0-9._+-]/-/g')"
FILE_NAME="${PACKAGE_NAME}-${VERSION}.tar.gz"
PACKAGE_URL="${BASE_URL}/api/packages/${PACKAGE_OWNER}/generic/${PACKAGE_NAME}/${VERSION}/${FILE_NAME}"
tar -czf "build/${FILE_NAME}" -C build/deploy .
echo "Publishing package ${PACKAGE_NAME} ${VERSION}"
curl --fail-with-body --insecure \
--user "${GITEA_PACKAGE_USER}:${GITEA_PACKAGE_TOKEN}" \
--upload-file "build/${FILE_NAME}" \
"${PACKAGE_URL}"
echo "Package uploaded: ${PACKAGE_URL}"
- name: Resolve shared-hosting paths
run: |
APP_PATH="${DEPLOY_APP_PATH:-${DEPLOY_PATH}}"
PUBLIC_PATH="${DEPLOY_PUBLIC_PATH}"
if [ -z "${APP_PATH}" ]; then
echo "Set DEPLOY_APP_PATH (application directory next to public_html)."
echo "Example: /home/u280815660/domains/home.alrahmaisgl.org/alrahma"
exit 1
fi
if [ -z "${PUBLIC_PATH}" ]; then
PUBLIC_PATH="$(dirname "${APP_PATH}")/public_html"
echo "DEPLOY_PUBLIC_PATH is not set; using ${PUBLIC_PATH}"
fi
echo "APP_PATH=${APP_PATH}" >> "$GITHUB_ENV"
echo "PUBLIC_PATH=${PUBLIC_PATH}" >> "$GITHUB_ENV"
echo "SSH_PORT=${DEPLOY_PORT:-65002}" >> "$GITHUB_ENV"
echo "PHP_BIN=${DEPLOY_PHP_BIN:-/opt/alt/php85/usr/bin/php}" >> "$GITHUB_ENV"
echo "INDEX_REQUIRE=${DEPLOY_INDEX_REQUIRE:-../alrahma/app/Config/Paths.php}" >> "$GITHUB_ENV"
- name: Configure SSH
run: |
test -n "${DEPLOY_HOST}"
test -n "${DEPLOY_USER}"
test -n "${DEPLOY_SSH_KEY}"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "${DEPLOY_SSH_KEY}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
{
echo "Host shared-hosting"
echo " HostName ${DEPLOY_HOST}"
echo " User ${DEPLOY_USER}"
echo " Port ${SSH_PORT}"
echo " IdentityFile ~/.ssh/deploy_key"
echo " IdentitiesOnly yes"
echo " StrictHostKeyChecking accept-new"
echo " ServerAliveInterval 30"
} > ~/.ssh/config
chmod 600 ~/.ssh/config
ssh-keyscan -p "${SSH_PORT}" "${DEPLOY_HOST}" >> ~/.ssh/known_hosts || true
- name: Prepare remote directories
run: |
ssh shared-hosting "mkdir -p \
'${APP_PATH}' \
'${APP_PATH}/writable/cache' \
'${APP_PATH}/writable/logs' \
'${APP_PATH}/writable/session' \
'${APP_PATH}/writable/uploads' \
'${APP_PATH}/writable/debugbar' \
'${APP_PATH}/writable/reports' \
'${PUBLIC_PATH}'"
- name: Deploy application files
run: |
rsync -az --delete \
-e ssh \
--exclude '.env' \
--exclude 'writable/cache/***' \
--exclude 'writable/logs/***' \
--exclude 'writable/session/***' \
--exclude 'writable/uploads/***' \
--exclude 'writable/debugbar/***' \
--exclude 'writable/reports/***' \
build/deploy/ "shared-hosting:${APP_PATH}/"
- name: Deploy public_html document root
run: |
rsync -az --delete \
-e ssh \
--exclude 'uploads/***' \
--exclude 'cgi-bin/***' \
--exclude '.well-known/***' \
--exclude 'error_log' \
--exclude 'default.php' \
build/deploy/public/ "shared-hosting:${PUBLIC_PATH}/"
- name: Finalize shared-hosting release
run: |
ssh shared-hosting "APP_PATH=$(printf '%q' "${APP_PATH}") PUBLIC_PATH=$(printf '%q' "${PUBLIC_PATH}") INDEX_REQUIRE=$(printf '%q' "${INDEX_REQUIRE}") PHP_BIN=$(printf '%q' "${PHP_BIN}") bash -s" <<'REMOTE'
set -euo pipefail
test -f "${PUBLIC_PATH}/index.php"
test -d "${APP_PATH}/app"
test -d "${APP_PATH}/vendor"
grep -q "require FCPATH . '" "${PUBLIC_PATH}/index.php"
sed -i "s|require FCPATH . '../[^']*app/Config/Paths.php';|require FCPATH . '${INDEX_REQUIRE}';|" "${PUBLIC_PATH}/index.php"
grep -F "require FCPATH . '${INDEX_REQUIRE}';" "${PUBLIC_PATH}/index.php"
chmod 644 "${PUBLIC_PATH}/index.php" "${PUBLIC_PATH}/.htaccess" || true
find "${PUBLIC_PATH}" -type d -exec chmod 755 {} +
find "${APP_PATH}/writable" -type d -exec chmod 775 {} + || true
find "${APP_PATH}/writable" -type f -exec chmod 664 {} + || true
if [ -x "${PHP_BIN}" ]; then
(cd "${APP_PATH}" && "${PHP_BIN}" spark cache:clear) || true
else
(cd "${APP_PATH}" && php spark cache:clear) || true
fi
echo "Deployed app to ${APP_PATH}"
echo "Deployed document root to ${PUBLIC_PATH}"
echo "index.php requires ${INDEX_REQUIRE}"
REMOTE
+173
View File
@@ -0,0 +1,173 @@
name: Tests
on:
push:
branches:
- develop
pull_request:
workflow_dispatch:
jobs:
phpunit:
name: PHPUnit
runs-on: ubuntu-latest
services:
mysql:
image: mariadb:10.11
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: alrahma_test
MYSQL_USER: alrahma
MYSQL_PASSWORD: alrahma
ports:
- 3306:3306
options: >-
--health-cmd="mariadb-admin ping -h 127.0.0.1 -uroot -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=10
env:
GIT_SSL_NO_VERIFY: 'true'
CI_ENVIRONMENT: testing
TEST_DB_HOST: mysql
TEST_DB_PORT: 3306
TEST_DB_NAME: alrahma_test
TEST_DB_USER: alrahma
TEST_DB_PASSWORD: alrahma
database.tests.hostname: mysql
database.tests.database: alrahma_test
database.tests.username: alrahma
database.tests.password: alrahma
database.tests.DBDriver: MySQLi
database.tests.DBPrefix: ''
database.tests.port: 3306
JWT_SECRET: test-secret-for-ci
steps:
- name: Checkout
uses: actions/checkout@v4
env:
GIT_SSL_NO_VERIFY: 'true'
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: dom, gd, intl, mbstring, mysqli, zip
coverage: xdebug
- name: Validate Composer config
run: composer validate --no-check-publish --strict
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: Prepare writable directories
run: |
mkdir -p \
writable/cache/testing \
writable/logs \
writable/session \
writable/uploads \
writable/debugbar
chown -R "$(id -u):$(id -g)" writable || true
chmod -R u+rwX,g+rwX writable
- name: Verify writable cache
run: |
test -d writable/cache/testing
test -w writable/cache/testing
touch writable/cache/testing/.ci-write-test
rm writable/cache/testing/.ci-write-test
- name: Verify database configuration
run: |
php -d variables_order=EGPCS -r '
define("FCPATH", __DIR__ . "/public/");
define("ENVIRONMENT", getenv("CI_ENVIRONMENT") ?: "production");
require "app/Config/Paths.php";
$paths = new Config\Paths();
require $paths->systemDirectory . "/Boot.php";
CodeIgniter\Boot::bootConsole($paths);
$database = config("Database");
$dbConfig = $database->{$database->defaultGroup};
echo "Environment: " . ENVIRONMENT . PHP_EOL;
echo "DB group: " . $database->defaultGroup . PHP_EOL;
echo "DB host: " . $dbConfig["hostname"] . PHP_EOL;
echo "DB port: " . $dbConfig["port"] . PHP_EOL;
echo "DB name: " . $dbConfig["database"] . PHP_EOL;
'
- name: Wait for MariaDB
run: |
for attempt in $(seq 1 30); do
php -r '
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli(
getenv("TEST_DB_HOST"),
getenv("TEST_DB_USER"),
getenv("TEST_DB_PASSWORD"),
getenv("TEST_DB_NAME"),
(int) getenv("TEST_DB_PORT")
);
exit($db->connect_errno === 0 ? 0 : 1);
' && break
echo "Waiting for MariaDB, attempt ${attempt}/30"
sleep 2
done
php -r '
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
new mysqli(
getenv("TEST_DB_HOST"),
getenv("TEST_DB_USER"),
getenv("TEST_DB_PASSWORD"),
getenv("TEST_DB_NAME"),
(int) getenv("TEST_DB_PORT")
);
echo "MariaDB connection successful\n";
'
- name: Import baseline schema
run: php tests/_support/import_baseline.php Database_Dump/u280815660_school_12072025
- name: Run database migrations
run: |
php -d variables_order=EGPCS spark migrate --all --no-interaction
php -r '
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$db = new mysqli(
getenv("TEST_DB_HOST"),
getenv("TEST_DB_USER"),
getenv("TEST_DB_PASSWORD"),
getenv("TEST_DB_NAME"),
(int) getenv("TEST_DB_PORT")
);
$tables = [];
$result = $db->query("SHOW TABLES");
while ($row = $result->fetch_array(MYSQLI_NUM)) {
$tables[$row[0]] = true;
}
foreach (["users", "students", "attendance_data"] as $table) {
if (!isset($tables[$table])) {
fwrite(STDERR, "Migration schema missing required table: {$table}\n");
exit(1);
}
}
echo "Migration schema verified\n";
'
- name: Run tests
run: composer test
+61
View File
@@ -0,0 +1,61 @@
.env
.env.*
!.env.example
/env
/env.production
/env.backup
/env_for_test*
/environment.txt
/env\ (copy\ 1)
/vendor/
/node_modules/
/writable/cache/*
/writable/debugbar/*
/writable/logs/*
/writable/session/*
/writable/uploads/*
/writable/tmp/*
/writable/*.log
!/writable/.gitkeep
!/writable/cache/.gitkeep
!/writable/debugbar/.gitkeep
!/writable/logs/.gitkeep
!/writable/session/.gitkeep
!/writable/uploads/.gitkeep
!/writable/tmp/.gitkeep
/public/build/
/public/hot/
/public/uploads/**
!/public/uploads/**/index.html
!/public/uploads/.gitkeep
/uploads/**
!/uploads/**/index.html
!/uploads/.gitkeep
docker-compose.override.yml
docker-compose.local.yml
*.log
.swp
.swo
*~
.tmp
.DS_Store
Thumbs.db
.idea/
.vscode/
.codex/
/coverage/
/build/
/build.tar.gz
/builds
/phpunit.xml.cache
/.phpunit.result.cache
/writable/reports/*
/writable/test.txt
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+9 -2
View File
@@ -1,4 +1,11 @@
# Current Software Architecture of `alrahma_sunday_school`
<p>
<a href="/melabidi/alrahma_sunday_school/actions">
<img src="https://img.shields.io/badge/Deploy-Hosting%20Server-2ea44f?style=for-the-badge" alt="Deploy to Hosting Server"/>
</a>
</p>
<p align="center">
<img src="/image/system_architecture.png" alt="software-architecture" width="1000"/>
</p>
@@ -55,7 +62,7 @@ Problems with it can be raised on our forum, or as issues in the main repository
## Server Requirements
PHP version 8.1 or higher is required, with the following extensions installed:
PHP version 8.5 or higher is required, with the following extensions installed:
- [intl](http://php.net/manual/en/intl.requirements.php)
- [mbstring](http://php.net/manual/en/mbstring.installation.php)
@@ -64,7 +71,7 @@ PHP version 8.1 or higher is required, with the following extensions installed:
> - The end of life date for PHP 7.4 was November 28, 2022.
> - The end of life date for PHP 8.0 was November 26, 2023.
> - If you are still using PHP 7.4 or 8.0, you should upgrade immediately.
> - The end of life date for PHP 8.1 will be December 31, 2025.
> - The end of life date for PHP 8.1 was December 31, 2025.
Additionally, make sure that the following extensions are enabled in your PHP:
-32
View File
@@ -1,32 +0,0 @@
Al Rahma Sunday School — API Docs Pack
=================================================
Generated: 2025-11-07T21:01:43.815073
WHAT'S INCLUDED
---------------
- app/Controllers/Api/*.php (API controllers)
- app/Controllers/{DocsController, ApiDocsController}
- app/Filters/ApiDocsAuthFilter.php
- app/Views/docs/{index.php, swagger_ui.php, swagger_ui_public.php}
- public/docs/openapi/*.yaml (separate specs + master.yaml)
- ROUTES_SNIPPET.php (add to app/Config/Routes.php)
- FILTERS_SNIPPET.php (add alias to app/Config/Filters.php)
INSTALL
-------
1) Unzip into your project root.
2) Merge routes:
- Open app/Config/Routes.php and paste the contents of ROUTES_SNIPPET.php
3) Add filter alias:
- Open app/Config/Filters.php and add the 'apiDocsAuth' alias from FILTERS_SNIPPET.php
4) Ensure your Models exist and match the controller expectations.
5) Set JWT secret in .env:
JWT_SECRET=your_super_secret_key
6) Visit:
- /docs (landing)
- /docs/api (secured, admin only)
- /docs/api/public (public read-only)
7) API base path:
- /api/v1/... (see YAMLs for full paths)
Enjoy!
View File
BIN
View File
Binary file not shown.
View File
View File
View File
View File
Executable → Regular
+4 -6
View File
@@ -98,10 +98,9 @@ class ConfigUpdate extends BaseCommand
return true; // no-op is success
}
CLI::write("Set semester = Spring" . ($dry ? ' [DRY]' : ''), 'light_gray');
if ($dry) return true;
CLI::write('Semester is derived from fall_semester_start and spring_semester_start; no config row is updated.', 'yellow');
return (bool) $this->configModel->setConfigValueByKey('semester', 'Spring');
return true;
}
protected function taskSetSemesterFall(bool $dry, DateTimeZone $tz): bool
@@ -118,10 +117,9 @@ class ConfigUpdate extends BaseCommand
return true;
}
CLI::write("Set semester = Fall" . ($dry ? ' [DRY]' : ''), 'light_gray');
if ($dry) return true;
CLI::write('Semester is derived from fall_semester_start and spring_semester_start; no config row is updated.', 'yellow');
return (bool) $this->configModel->setConfigValueByKey('semester', 'Fall');
return true;
}
public function run(array $params)
View File
+323
View File
@@ -0,0 +1,323 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use CodeIgniter\Database\BaseConnection;
class EnrollmentCloseoutReport extends BaseCommand
{
protected $group = 'Registration';
protected $name = 'registration:closeout-report';
protected $description = 'Generate end-of-registration reconciliation and closeout exception lists.';
protected $usage = 'php spark registration:closeout-report [--school-year=2026-2027] [--json] [--export=/tmp/registration-closeout.csv]';
protected $options = [
'--school-year' => 'Target school year name. Defaults to the active/current configured year.',
'--json' => 'Print machine-readable JSON.',
'--export' => 'Write closeout exception rows to a CSV file.',
];
private BaseConnection $db;
public function run(array $params)
{
$this->db = \Config\Database::connect();
$schoolYear = trim((string) (CLI::getOption('school-year') ?? ''));
if ($schoolYear === '') {
$schoolYear = $this->currentSchoolYear();
}
$report = $this->buildReport($schoolYear);
$exportPath = $this->optionValue('export');
if ($exportPath !== '') {
$this->writeCsv($exportPath, $report['exceptions']);
$report['export_path'] = $exportPath;
}
if (CLI::getOption('json') !== null) {
CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return;
}
$this->printReport($report);
}
private function buildReport(string $schoolYear): array
{
$previousYear = $this->previousSchoolYearName($schoolYear);
$expected = $this->expectedReturningStudents($previousYear);
$enrolled = $this->enrolledStudents($schoolYear);
$unsubmitted = $this->unsubmittedReturningStudents($schoolYear, $previousYear);
$pendingEnrollments = $this->pendingEnrollments($schoolYear);
$unresolvedFlags = $this->unresolvedFlags($schoolYear);
$failedEmails = $this->failedEmails($schoolYear);
$exceptions = array_merge(
$this->exceptionRows('unsubmitted_returning_student', $unsubmitted),
$this->exceptionRows('pending_enrollment', $pendingEnrollments),
$this->exceptionRows('unresolved_flag', $unresolvedFlags),
$this->exceptionRows('failed_email', $failedEmails)
);
$summary = [
'expected_returning_students' => count($expected),
'students_with_target_year_enrollment' => count($enrolled),
'unsubmitted_returning_students' => count($unsubmitted),
'pending_enrollments' => count($pendingEnrollments),
'unresolved_flags' => count($unresolvedFlags),
'failed_emails' => count($failedEmails),
'closeout_exception_total' => count($exceptions),
];
return [
'school_year' => $schoolYear,
'source_school_year' => $previousYear,
'generated_at' => date('Y-m-d H:i:s'),
'ready_to_close' => $summary['closeout_exception_total'] === 0,
'summary' => $summary,
'exceptions' => $exceptions,
];
}
private function expectedReturningStudents(?string $previousYear): array
{
if ($previousYear === null || ! $this->db->tableExists('student_class')) {
return [];
}
return $this->db->table('student_class sc')
->select('DISTINCT sc.student_id', false)
->where('sc.school_year', $previousYear)
->get()
->getResultArray();
}
private function enrolledStudents(string $schoolYear): array
{
if (! $this->db->tableExists('enrollments')) {
return [];
}
return $this->db->table('enrollments')
->select('DISTINCT student_id', false)
->where('school_year', $schoolYear)
->get()
->getResultArray();
}
private function unsubmittedReturningStudents(string $schoolYear, ?string $previousYear): array
{
if ($previousYear === null || ! $this->db->tableExists('student_class') || ! $this->db->tableExists('enrollments')) {
return [];
}
$join = 'e.student_id = sc.student_id AND e.school_year = ' . $this->db->escape($schoolYear);
return $this->db->table('student_class sc')
->select('sc.student_id, s.firstname, s.lastname, s.school_id')
->select('e.id AS enrollment_id, e.enrollment_status, e.admission_status, e.registration_submitted_at')
->join('students s', 's.id = sc.student_id', 'left')
->join('enrollments e', $join, 'left', false)
->where('sc.school_year', $previousYear)
->groupStart()
->where('e.id IS NULL')
->orWhere('e.registration_submitted_at IS NULL', null, false)
->groupEnd()
->groupBy('sc.student_id, s.firstname, s.lastname, s.school_id, e.id, e.enrollment_status, e.admission_status, e.registration_submitted_at')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->getResultArray();
}
private function pendingEnrollments(string $schoolYear): array
{
if (! $this->db->tableExists('enrollments')) {
return [];
}
return $this->db->table('enrollments e')
->select('e.student_id, e.id AS enrollment_id, e.enrollment_status, e.admission_status, e.registration_submitted_at, e.registration_confirmed_at')
->select('s.firstname, s.lastname, s.school_id')
->join('students s', 's.id = e.student_id', 'left')
->where('e.school_year', $schoolYear)
->groupStart()
->where('e.enrollment_status', 'admission under review')
->orWhere('e.admission_status', 'pending')
->orWhere('e.registration_confirmed_at IS NULL', null, false)
->groupEnd()
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->getResultArray();
}
private function unresolvedFlags(string $schoolYear): array
{
if (! $this->db->tableExists('enrollment_flags')) {
return [];
}
return $this->db->table('enrollment_flags ef')
->select('ef.id AS flag_id, ef.student_id, ef.flag_type, ef.priority, ef.created_at')
->select('s.firstname, s.lastname, s.school_id')
->join('students s', 's.id = ef.student_id', 'left')
->where('ef.school_year', $schoolYear)
->where('ef.status', 'open')
->orderBy('ef.priority', 'DESC')
->orderBy('ef.created_at', 'ASC')
->get()
->getResultArray();
}
private function failedEmails(string $schoolYear): array
{
if (! $this->db->tableExists('enrollment_email_records')) {
return [];
}
return $this->db->table('enrollment_email_records')
->select('id AS email_record_id, parent_user_id, recipient_addresses_json, delivery_status, failure_reason, retry_count, updated_at')
->where('school_year', $schoolYear)
->where('delivery_status', 'failed')
->orderBy('updated_at', 'DESC')
->get()
->getResultArray();
}
private function exceptionRows(string $type, array $rows): array
{
$exceptions = [];
foreach ($rows as $row) {
$studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
$exceptions[] = [
'type' => $type,
'student_id' => $row['student_id'] ?? '',
'student_name' => $studentName,
'school_id' => $row['school_id'] ?? '',
'reference_id' => $row['enrollment_id'] ?? $row['flag_id'] ?? $row['email_record_id'] ?? '',
'status' => $row['enrollment_status'] ?? $row['flag_type'] ?? $row['delivery_status'] ?? '',
'detail' => $this->exceptionDetail($type, $row),
];
}
return $exceptions;
}
private function exceptionDetail(string $type, array $row): string
{
return match ($type) {
'unsubmitted_returning_student' => empty($row['enrollment_id'])
? 'No target-year enrollment exists.'
: 'Target-year enrollment exists but registration has not been submitted.',
'pending_enrollment' => 'Enrollment status: ' . (string) ($row['enrollment_status'] ?? '') . '; admission status: ' . (string) ($row['admission_status'] ?? ''),
'unresolved_flag' => 'Open flag priority: ' . (string) ($row['priority'] ?? ''),
'failed_email' => 'Retry count: ' . (int) ($row['retry_count'] ?? 0) . '; reason: ' . (string) ($row['failure_reason'] ?? ''),
default => '',
};
}
private function writeCsv(string $path, array $rows): void
{
$directory = dirname($path);
if ($directory !== '' && $directory !== '.' && ! is_dir($directory)) {
throw new \RuntimeException('Export directory does not exist: ' . $directory);
}
$handle = fopen($path, 'wb');
if ($handle === false) {
throw new \RuntimeException('Unable to write export file: ' . $path);
}
fputcsv($handle, ['type', 'student_id', 'student_name', 'school_id', 'reference_id', 'status', 'detail']);
foreach ($rows as $row) {
fputcsv($handle, [
$row['type'] ?? '',
$row['student_id'] ?? '',
$row['student_name'] ?? '',
$row['school_id'] ?? '',
$row['reference_id'] ?? '',
$row['status'] ?? '',
$row['detail'] ?? '',
]);
}
fclose($handle);
}
private function optionValue(string $name): string
{
$value = CLI::getOption($name);
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
$argv = $_SERVER['argv'] ?? [];
$long = '--' . $name;
foreach ($argv as $index => $arg) {
if (str_starts_with((string) $arg, $long . '=')) {
return trim(substr((string) $arg, strlen($long) + 1));
}
if ($arg === $long && isset($argv[$index + 1]) && ! str_starts_with((string) $argv[$index + 1], '--')) {
return trim((string) $argv[$index + 1]);
}
}
return '';
}
private function currentSchoolYear(): string
{
if ($this->db->tableExists('school_years')) {
$row = $this->db->table('school_years')
->select('name')
->where('status', 'active')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
if (! empty($row['name'])) {
return (string) $row['name'];
}
}
if ($this->db->tableExists('configuration')) {
$row = $this->db->table('configuration')
->select('config_value')
->where('config_key', 'school_year')
->limit(1)
->get()
->getRowArray();
if (! empty($row['config_value'])) {
return (string) $row['config_value'];
}
}
return '';
}
private function previousSchoolYearName(string $schoolYear): ?string
{
return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)
? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1)
: null;
}
private function printReport(array $report): void
{
CLI::write('Registration Closeout Report: ' . (string) ($report['school_year'] ?? ''), ($report['ready_to_close'] ?? false) ? 'green' : 'yellow');
CLI::write('Generated at: ' . (string) ($report['generated_at'] ?? ''));
CLI::write('Ready to close: ' . (($report['ready_to_close'] ?? false) ? 'yes' : 'no'));
CLI::newLine();
foreach (($report['summary'] ?? []) as $key => $value) {
CLI::write($key . ': ' . $value);
}
if (! empty($report['export_path'])) {
CLI::newLine();
CLI::write('Export written: ' . (string) $report['export_path'], 'green');
}
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
namespace App\Commands;
use App\Support\Enrollment\DeliberationDecision;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use CodeIgniter\Database\BaseConnection;
class EnrollmentDataAuditCommand extends BaseCommand
{
protected $group = 'Registration';
protected $name = 'registration:enrollment-data-audit';
protected $description = 'Audit withdrawn terminology normalization and duplicate enrollment rows per student/year.';
protected $usage = 'php spark registration:enrollment-data-audit [--school-year=2026-2027] [--json]';
protected $options = [
'--school-year' => 'Optional school year filter for duplicate enrollment detection.',
'--json' => 'Print machine-readable JSON.',
];
private BaseConnection $db;
public function run(array $params)
{
$this->db = \Config\Database::connect();
$schoolYear = trim((string) (CLI::getOption('school-year') ?? ''));
$report = [
'generated_at' => date('Y-m-d H:i:s'),
'school_year_filter' => $schoolYear !== '' ? $schoolYear : null,
'withdrawn_normalization' => $this->auditWithdrawnValues(),
'duplicate_enrollments' => $this->auditDuplicateEnrollments($schoolYear),
];
if (CLI::getOption('json') !== null) {
CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return;
}
CLI::write('Enrollment Data Audit', 'green');
CLI::newLine();
CLI::write('Withdrawn / misspelling findings: ' . count($report['withdrawn_normalization']), 'yellow');
foreach ($report['withdrawn_normalization'] as $row) {
CLI::write(sprintf(
' [%s] %s.%s = %s',
$row['table'],
$row['column'],
$row['id'] ?? $row['student_id'] ?? '?',
$row['value']
));
}
CLI::newLine();
CLI::write('Duplicate enrollment groups: ' . count($report['duplicate_enrollments']), 'yellow');
foreach ($report['duplicate_enrollments'] as $group) {
CLI::write(sprintf(
' student=%d year=%s rows=%d ids=[%s]',
$group['student_id'],
$group['school_year'],
$group['row_count'],
implode(',', $group['enrollment_ids'])
));
}
}
/**
* @return list<array<string, mixed>>
*/
private function auditWithdrawnValues(): array
{
$findings = [];
$patterns = ['widthrawan', 'widthrwan', 'withdraw'];
if ($this->db->tableExists('enrollments')) {
$rows = $this->db->table('enrollments')
->select('id, student_id, school_year, enrollment_status')
->get()
->getResultArray();
foreach ($rows as $row) {
$status = strtolower(trim((string) ($row['enrollment_status'] ?? '')));
foreach ($patterns as $pattern) {
if ($status === $pattern || str_contains($status, $pattern)) {
$findings[] = [
'table' => 'enrollments',
'column' => 'enrollment_status',
'id' => (int) ($row['id'] ?? 0),
'student_id' => (int) ($row['student_id'] ?? 0),
'school_year' => (string) ($row['school_year'] ?? ''),
'value' => (string) ($row['enrollment_status'] ?? ''),
'recommended' => 'withdrawn',
];
}
}
}
}
if ($this->db->tableExists('student_decisions')) {
$rows = $this->db->table('student_decisions')
->select('id, student_id, school_year, decision, deliberation_decision_standard')
->get()
->getResultArray();
foreach ($rows as $row) {
foreach (['decision', 'deliberation_decision_standard'] as $column) {
$raw = (string) ($row[$column] ?? '');
$normalized = DeliberationDecision::normalize($raw);
if ($raw !== '' && $normalized === DeliberationDecision::WITHDRAWN && strtoupper($raw) !== DeliberationDecision::WITHDRAWN) {
$findings[] = [
'table' => 'student_decisions',
'column' => $column,
'id' => (int) ($row['id'] ?? 0),
'student_id' => (int) ($row['student_id'] ?? 0),
'school_year' => (string) ($row['school_year'] ?? ''),
'value' => $raw,
'recommended' => DeliberationDecision::WITHDRAWN,
];
}
}
}
}
return $findings;
}
/**
* @return list<array<string, mixed>>
*/
private function auditDuplicateEnrollments(string $schoolYear): array
{
if (! $this->db->tableExists('enrollments')) {
return [];
}
$builder = $this->db->table('enrollments')
->select('student_id, school_year, COUNT(*) AS row_count, GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) AS enrollment_ids', false)
->groupBy('student_id, school_year')
->having('row_count >', 1);
if ($schoolYear !== '') {
$builder->where('school_year', $schoolYear);
}
$groups = [];
foreach ($builder->get()->getResultArray() as $row) {
$ids = array_values(array_filter(array_map('intval', explode(',', (string) ($row['enrollment_ids'] ?? '')))));
$groups[] = [
'student_id' => (int) ($row['student_id'] ?? 0),
'school_year' => (string) ($row['school_year'] ?? ''),
'row_count' => (int) ($row['row_count'] ?? 0),
'enrollment_ids' => $ids,
];
}
return $groups;
}
}
@@ -0,0 +1,295 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use CodeIgniter\Database\BaseConnection;
class EnrollmentPostLaunchMonitor extends BaseCommand
{
protected $group = 'Registration';
protected $name = 'registration:monitor';
protected $description = 'Monitor post-launch enrollment progress, blocks, flags, and email delivery.';
protected $usage = 'php spark registration:monitor [--school-year=2026-2027] [--days=7] [--json]';
protected $options = [
'--school-year' => 'Target school year name. Defaults to the active/current configured year.',
'--days' => 'Recent activity window in days. Defaults to 7.',
'--json' => 'Print machine-readable JSON.',
];
private BaseConnection $db;
public function run(array $params)
{
$this->db = \Config\Database::connect();
$schoolYear = trim((string) (CLI::getOption('school-year') ?? ''));
if ($schoolYear === '') {
$schoolYear = $this->currentSchoolYear();
}
$days = max(1, (int) (CLI::getOption('days') ?? 7));
$report = $this->buildReport($schoolYear, $days);
if (CLI::getOption('json') !== null) {
CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return;
}
$this->printReport($report);
}
private function buildReport(string $schoolYear, int $days): array
{
$previousYear = $this->previousSchoolYearName($schoolYear);
$expected = $this->expectedReturningStudentCount($previousYear);
$enrollments = $this->enrollmentSummary($schoolYear);
$flags = $this->flagSummary($schoolYear, $days);
$emails = $this->emailSummary($schoolYear);
$audits = $this->auditSummary($schoolYear, $days);
$submitted = (int) ($enrollments['submitted'] ?? 0);
$progressPercent = $expected > 0 ? round(($submitted / $expected) * 100, 1) : null;
$needsAttention = (int) ($flags['open_total'] ?? 0)
+ (int) ($flags['stale_total'] ?? 0)
+ (int) ($emails['failed'] ?? 0)
+ (int) ($enrollments['blocked_total'] ?? 0);
return [
'school_year' => $schoolYear,
'source_school_year' => $previousYear,
'generated_at' => date('Y-m-d H:i:s'),
'activity_window_days' => $days,
'expected_returning_students' => $expected,
'submitted_registrations' => $submitted,
'progress_percent' => $progressPercent,
'needs_attention_count' => $needsAttention,
'enrollments' => $enrollments,
'flags' => $flags,
'emails' => $emails,
'audits' => $audits,
];
}
private function expectedReturningStudentCount(?string $previousYear): int
{
if ($previousYear === null || ! $this->db->tableExists('student_class')) {
return 0;
}
return (int) ($this->db->table('student_class')
->select('COUNT(DISTINCT student_id) AS total', false)
->where('school_year', $previousYear)
->get()
->getRowArray()['total'] ?? 0);
}
private function enrollmentSummary(string $schoolYear): array
{
if (! $this->db->tableExists('enrollments')) {
return [
'total' => 0,
'submitted' => 0,
'confirmed' => 0,
'blocked_total' => 0,
'by_status' => [],
'by_placement_status' => [],
];
}
$total = $this->countRows('enrollments', ['school_year' => $schoolYear]);
$submitted = $this->db->fieldExists('registration_submitted_at', 'enrollments')
? $this->countRows('enrollments', ['school_year' => $schoolYear], 'registration_submitted_at IS NOT NULL')
: $total;
$confirmed = $this->db->fieldExists('registration_confirmed_at', 'enrollments')
? $this->countRows('enrollments', ['school_year' => $schoolYear], 'registration_confirmed_at IS NOT NULL')
: $this->countRows('enrollments', ['school_year' => $schoolYear, 'enrollment_status' => 'enrolled']);
$blocked = 0;
if ($this->db->fieldExists('parent_enrollment_allowed', 'enrollments')) {
$blocked += $this->countRows('enrollments', ['school_year' => $schoolYear, 'parent_enrollment_allowed' => 0]);
}
if ($this->db->fieldExists('exception_required', 'enrollments')) {
$blocked += $this->countRows('enrollments', ['school_year' => $schoolYear, 'exception_required' => 1]);
}
return [
'total' => $total,
'submitted' => $submitted,
'confirmed' => $confirmed,
'blocked_total' => $blocked,
'by_status' => $this->groupCount('enrollments', 'enrollment_status', ['school_year' => $schoolYear]),
'by_placement_status' => $this->db->fieldExists('placement_status', 'enrollments')
? $this->groupCount('enrollments', 'placement_status', ['school_year' => $schoolYear])
: [],
];
}
private function flagSummary(string $schoolYear, int $days): array
{
if (! $this->db->tableExists('enrollment_flags')) {
return [
'open_total' => 0,
'stale_total' => 0,
'by_type' => [],
'by_priority' => [],
];
}
$staleBefore = date('Y-m-d H:i:s', strtotime('-' . $days . ' days'));
return [
'open_total' => $this->countRows('enrollment_flags', ['school_year' => $schoolYear, 'status' => 'open']),
'stale_total' => $this->countRows('enrollment_flags', ['school_year' => $schoolYear, 'status' => 'open'], 'created_at < ' . $this->db->escape($staleBefore)),
'by_type' => $this->groupCount('enrollment_flags', 'flag_type', ['school_year' => $schoolYear, 'status' => 'open']),
'by_priority' => $this->groupCount('enrollment_flags', 'priority', ['school_year' => $schoolYear, 'status' => 'open']),
];
}
private function emailSummary(string $schoolYear): array
{
if (! $this->db->tableExists('enrollment_email_records')) {
return [
'total' => 0,
'sent' => 0,
'failed' => 0,
'pending' => 0,
'by_status' => [],
];
}
return [
'total' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear]),
'sent' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear, 'delivery_status' => 'sent']),
'failed' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear, 'delivery_status' => 'failed']),
'pending' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear, 'delivery_status' => 'pending']),
'by_status' => $this->groupCount('enrollment_email_records', 'delivery_status', ['school_year' => $schoolYear]),
];
}
private function auditSummary(string $schoolYear, int $days): array
{
if (! $this->db->tableExists('enrollment_transition_audits')) {
return [
'recent_total' => 0,
'by_action' => [],
];
}
$since = date('Y-m-d H:i:s', strtotime('-' . $days . ' days'));
return [
'recent_total' => $this->countRows('enrollment_transition_audits', ['school_year' => $schoolYear], 'created_at >= ' . $this->db->escape($since)),
'by_action' => $this->groupCount('enrollment_transition_audits', 'action', ['school_year' => $schoolYear], 'created_at >= ' . $this->db->escape($since)),
];
}
private function countRows(string $table, array $where, ?string $rawWhere = null): int
{
$builder = $this->db->table($table);
foreach ($where as $field => $value) {
$builder->where($field, $value);
}
if ($rawWhere !== null) {
$builder->where($rawWhere, null, false);
}
return $builder->countAllResults();
}
private function groupCount(string $table, string $field, array $where, ?string $rawWhere = null): array
{
if (! $this->db->fieldExists($field, $table)) {
return [];
}
$builder = $this->db->table($table)
->select($field . ' AS value, COUNT(*) AS total', false)
->groupBy($field)
->orderBy('total', 'DESC', false);
foreach ($where as $whereField => $value) {
$builder->where($whereField, $value);
}
if ($rawWhere !== null) {
$builder->where($rawWhere, null, false);
}
$result = [];
foreach ($builder->get()->getResultArray() as $row) {
$key = trim((string) ($row['value'] ?? '')) ?: 'blank';
$result[$key] = (int) ($row['total'] ?? 0);
}
return $result;
}
private function currentSchoolYear(): string
{
if ($this->db->tableExists('school_years')) {
$row = $this->db->table('school_years')
->select('name')
->where('status', 'active')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
if (! empty($row['name'])) {
return (string) $row['name'];
}
}
if ($this->db->tableExists('configuration')) {
$row = $this->db->table('configuration')
->select('config_value')
->where('config_key', 'school_year')
->limit(1)
->get()
->getRowArray();
if (! empty($row['config_value'])) {
return (string) $row['config_value'];
}
}
return '';
}
private function previousSchoolYearName(string $schoolYear): ?string
{
return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)
? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1)
: null;
}
private function printReport(array $report): void
{
CLI::write('Registration Post-Launch Monitor: ' . (string) ($report['school_year'] ?? ''), 'cyan');
CLI::write('Generated at: ' . (string) ($report['generated_at'] ?? ''));
CLI::write('Expected returning students: ' . (int) ($report['expected_returning_students'] ?? 0));
CLI::write('Submitted registrations: ' . (int) ($report['submitted_registrations'] ?? 0));
CLI::write('Progress: ' . (($report['progress_percent'] ?? null) === null ? 'n/a' : (string) $report['progress_percent'] . '%'));
CLI::write('Needs attention: ' . (int) ($report['needs_attention_count'] ?? 0), ((int) ($report['needs_attention_count'] ?? 0)) > 0 ? 'yellow' : 'green');
CLI::newLine();
$this->printSection('Enrollments', $report['enrollments'] ?? []);
$this->printSection('Flags', $report['flags'] ?? []);
$this->printSection('Emails', $report['emails'] ?? []);
$this->printSection('Recent Audits', $report['audits'] ?? []);
}
private function printSection(string $title, array $data): void
{
CLI::write($title, 'white');
foreach ($data as $key => $value) {
if (is_array($value)) {
CLI::write(' ' . $key . ':');
foreach ($value as $nestedKey => $nestedValue) {
CLI::write(' ' . $nestedKey . ': ' . $nestedValue);
}
} else {
CLI::write(' ' . $key . ': ' . $value);
}
}
CLI::newLine();
}
}
+319
View File
@@ -0,0 +1,319 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use CodeIgniter\Database\BaseConnection;
class EnrollmentReleaseAudit extends BaseCommand
{
protected $group = 'Registration';
protected $name = 'registration:release-audit';
protected $description = 'Audit school-year transition and registration readiness before launch.';
protected $usage = 'php spark registration:release-audit [--school-year=2026-2027] [--json]';
protected $options = [
'--school-year' => 'Target school year name. Defaults to the active/current configured year.',
'--json' => 'Print machine-readable JSON.',
];
private BaseConnection $db;
public function run(array $params)
{
$this->db = \Config\Database::connect();
$schoolYear = trim((string) (CLI::getOption('school-year') ?? ''));
if ($schoolYear === '') {
$schoolYear = $this->currentSchoolYear();
}
$report = $this->buildReport($schoolYear);
if (CLI::getOption('json') !== null) {
CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return;
}
$this->printReport($report);
}
private function buildReport(string $schoolYear): array
{
$previousYear = $this->previousSchoolYearName($schoolYear);
$checks = [
'school_year_configuration' => $this->schoolYearConfigurationCheck($schoolYear),
'launch_approval' => $this->launchApprovalCheck($schoolYear),
'deliberation_decisions' => $this->deliberationDecisionCheck($schoolYear, $previousYear),
'open_enrollment_flags' => $this->openEnrollmentFlagsCheck($schoolYear),
'failed_registration_emails' => $this->failedEmailCheck($schoolYear),
'duplicate_or_invalid_contacts' => $this->contactQualityCheck(),
'email_preview_sample' => $this->emailPreviewCheck($schoolYear),
];
$blocking = 0;
$warnings = 0;
foreach ($checks as $check) {
if (($check['severity'] ?? '') === 'blocking') {
$blocking++;
} elseif (($check['severity'] ?? '') === 'warning') {
$warnings++;
}
}
return [
'school_year' => $schoolYear,
'source_school_year' => $previousYear,
'generated_at' => date('Y-m-d H:i:s'),
'ready' => $blocking === 0,
'blocking_count' => $blocking,
'warning_count' => $warnings,
'checks' => $checks,
];
}
private function schoolYearConfigurationCheck(string $schoolYear): array
{
if (! $this->db->tableExists('school_years')) {
return $this->check('blocking', 'school_years table is missing.');
}
$row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray();
if ($row === null) {
return $this->check('blocking', 'Target school year record was not found.');
}
$missing = [];
foreach ([
'registration_starts_on' => 'registration opening date',
'registration_ends_on' => 'registration deadline',
] as $field => $label) {
if (empty($row[$field])) {
$missing[] = $label;
}
}
if (! $this->db->tableExists('email_templates')) {
$missing[] = 'email templates table';
} elseif ($this->activeTemplateCount('registration_opening') <= 0) {
$missing[] = 'active registration email template';
}
return $missing === []
? $this->check('pass', 'Required school-year launch configuration is present.')
: $this->check('blocking', 'Missing: ' . implode(', ', $missing));
}
private function launchApprovalCheck(string $schoolYear): array
{
if (! $this->db->tableExists('school_years') || ! $this->db->fieldExists('registration_launch_approved_at', 'school_years')) {
return $this->check('warning', 'Launch approval fields have not been migrated yet.');
}
$row = $this->db->table('school_years')
->select('registration_launch_approved_at')
->where('name', $schoolYear)
->limit(1)
->get()
->getRowArray();
return ! empty($row['registration_launch_approved_at'] ?? null)
? $this->check('pass', 'Registration launch has been approved.')
: $this->check('warning', 'Registration launch has not been approved yet.');
}
private function deliberationDecisionCheck(string $schoolYear, ?string $previousYear): array
{
if ($previousYear === null) {
return $this->check('warning', 'Previous school year could not be inferred.');
}
if (! $this->db->tableExists('student_class') || ! $this->db->tableExists('student_decisions')) {
return $this->check('warning', 'Student placement or decision tables are missing.');
}
$rows = $this->db->table('student_class sc')
->select('COUNT(DISTINCT sc.student_id) AS total', false)
->join('student_decisions sd', 'sd.student_id = sc.student_id AND sd.school_year = ' . $this->db->escape($previousYear), 'left', false)
->where('sc.school_year', $previousYear)
->groupStart()
->where('sd.id IS NULL')
->orWhere('sd.decision IS NULL')
->orWhere('TRIM(sd.decision) =', '')
->groupEnd()
->get()
->getRowArray();
$missing = (int) ($rows['total'] ?? 0);
return $missing === 0
? $this->check('pass', 'All placed source-year students have a recorded deliberation decision.')
: $this->check('blocking', $missing . ' source-year student(s) are missing deliberation decisions.');
}
private function openEnrollmentFlagsCheck(string $schoolYear): array
{
if (! $this->db->tableExists('enrollment_flags')) {
return $this->check('warning', 'Enrollment flags table has not been migrated yet.');
}
$count = $this->db->table('enrollment_flags')
->where('school_year', $schoolYear)
->where('status', 'open')
->countAllResults();
return $count === 0
? $this->check('pass', 'No open enrollment follow-up flags remain.')
: $this->check('warning', $count . ' open enrollment follow-up flag(s) remain.');
}
private function failedEmailCheck(string $schoolYear): array
{
if (! $this->db->tableExists('enrollment_email_records')) {
return $this->check('warning', 'Enrollment email record table has not been migrated yet.');
}
$failed = $this->db->table('enrollment_email_records')
->where('school_year', $schoolYear)
->where('delivery_status', 'failed')
->countAllResults();
return $failed === 0
? $this->check('pass', 'No failed registration email records found.')
: $this->check('warning', $failed . ' failed registration email record(s) need retry or review.');
}
private function contactQualityCheck(): array
{
if (! $this->db->tableExists('users')) {
return $this->check('warning', 'Users table is missing.');
}
if (! $this->db->fieldExists('email', 'users')) {
return $this->check('warning', 'Users table does not include an email field.');
}
$rows = $this->db->query(
"SELECT LOWER(TRIM(email)) AS normalized_email, COUNT(*) AS total
FROM users
WHERE email IS NOT NULL AND email <> ''
GROUP BY LOWER(TRIM(email))
HAVING COUNT(*) > 1"
)->getResultArray();
$invalid = 0;
foreach ($this->db->table('users')->select('email')->where('email IS NOT NULL')->where('email !=', '')->get()->getResultArray() as $row) {
if (! filter_var((string) ($row['email'] ?? ''), FILTER_VALIDATE_EMAIL)) {
$invalid++;
}
}
$duplicates = count($rows);
if ($duplicates === 0 && $invalid === 0) {
return $this->check('pass', 'No duplicate or invalid parent/user email addresses were detected.');
}
return $this->check('warning', $duplicates . ' duplicate email value(s) and ' . $invalid . ' invalid email address(es) were detected.');
}
private function emailPreviewCheck(string $schoolYear): array
{
if (! $this->db->tableExists('students')) {
return $this->check('warning', 'Students table is missing.');
}
$row = $this->db->table('students')
->select('parent_id')
->where('parent_id IS NOT NULL', null, false)
->orderBy('parent_id', 'ASC')
->limit(1)
->get()
->getRowArray();
$parentId = is_numeric($row['parent_id'] ?? null) ? (int) $row['parent_id'] : 0;
if ($parentId <= 0) {
return $this->check('warning', 'No parent with students was found for email preview.');
}
try {
$message = service('enrollmentRegistrationEmail')->previewForParent($schoolYear, $parentId);
} catch (\Throwable $e) {
return $this->check('blocking', 'Email preview generation failed: ' . $e->getMessage());
}
return $message !== null && trim((string) ($message['body'] ?? '')) !== ''
? $this->check('pass', 'A consolidated registration email preview can be generated.')
: $this->check('blocking', 'No consolidated registration email preview could be generated.');
}
private function currentSchoolYear(): string
{
if ($this->db->tableExists('school_years')) {
$row = $this->db->table('school_years')
->select('name')
->where('status', 'active')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
if (! empty($row['name'])) {
return (string) $row['name'];
}
}
if ($this->db->tableExists('configuration')) {
$row = $this->db->table('configuration')
->select('config_value')
->where('config_key', 'school_year')
->limit(1)
->get()
->getRowArray();
if (! empty($row['config_value'])) {
return (string) $row['config_value'];
}
}
return '';
}
private function activeTemplateCount(string $key): int
{
$fields = $this->db->getFieldNames('email_templates');
$keyField = in_array('code', $fields, true) ? 'code' : 'template_key';
return $this->db->table('email_templates')
->where($keyField, $key)
->where('is_active', 1)
->countAllResults();
}
private function previousSchoolYearName(string $schoolYear): ?string
{
return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)
? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1)
: null;
}
private function check(string $severity, string $message): array
{
return ['severity' => $severity, 'message' => $message];
}
private function printReport(array $report): void
{
CLI::write('Registration Release Audit: ' . (string) ($report['school_year'] ?? ''), ($report['ready'] ?? false) ? 'green' : 'red');
CLI::write('Generated at: ' . (string) ($report['generated_at'] ?? ''));
CLI::write('Ready: ' . (($report['ready'] ?? false) ? 'yes' : 'no'));
CLI::newLine();
foreach (($report['checks'] ?? []) as $name => $check) {
$severity = (string) ($check['severity'] ?? 'warning');
$color = match ($severity) {
'pass' => 'green',
'blocking' => 'red',
default => 'yellow',
};
CLI::write(sprintf('[%s] %s: %s', strtoupper($severity), $name, (string) ($check['message'] ?? '')), $color);
}
CLI::newLine();
CLI::write('Blocking: ' . (int) ($report['blocking_count'] ?? 0), ((int) ($report['blocking_count'] ?? 0)) > 0 ? 'red' : 'green');
CLI::write('Warnings: ' . (int) ($report['warning_count'] ?? 0), ((int) ($report['warning_count'] ?? 0)) > 0 ? 'yellow' : 'green');
}
}
View File
+171
View File
@@ -0,0 +1,171 @@
<?php
namespace App\Commands;
use App\Libraries\InvoiceLedgerService;
use App\Models\InvoiceModel;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class RecalculateInvoices extends BaseCommand
{
protected $group = 'Financial';
protected $name = 'financial:recalculate-invoices';
protected $description = 'Audits invoice totals against the centralized ledger and optionally writes the corrected values.';
protected $usage = 'financial:recalculate-invoices [--commit] [--invoice-id=123] [--parent-id=456] [--school-year=2025-2026] [--semester=Fall]';
protected $options = [
'--commit' => 'Persist recalculated totals. Without this flag the command runs in dry-run mode.',
'--invoice-id' => 'Only process a single invoice ID.',
'--parent-id' => 'Only process invoices for one parent.',
'--school-year' => 'Only process invoices for the specified school year.',
'--semester' => 'Only process invoices for the specified semester.',
];
public function run(array $params)
{
$options = $this->parseOptions($params);
$invoiceModel = new InvoiceModel();
$ledgerService = new InvoiceLedgerService();
$invoiceIds = $this->loadInvoiceIds($invoiceModel, $options);
if ($invoiceIds === []) {
CLI::write('No invoices matched the provided filters.', 'yellow');
return;
}
$commit = !empty($options['commit']);
CLI::write(($commit ? 'Applying' : 'Dry-run auditing') . ' ' . count($invoiceIds) . ' invoice(s)...', 'yellow');
$changed = 0;
$unchanged = 0;
$errors = 0;
foreach ($invoiceIds as $invoiceId) {
$invoice = $invoiceModel->find($invoiceId);
if (!$invoice) {
continue;
}
try {
$calculation = $ledgerService->calculateInvoice($invoiceId);
$diffs = $this->diffInvoice($invoice, $calculation);
if ($diffs === []) {
$unchanged++;
CLI::write('Invoice #' . $invoiceId . ' unchanged.', 'green');
continue;
}
$changed++;
CLI::write('Invoice #' . $invoiceId . ' requires recalculation:', 'light_yellow');
foreach ($diffs as $label => $values) {
CLI::write(sprintf(
' %s: %s -> %s',
$label,
(string) $values['from'],
(string) $values['to']
));
}
if ($commit) {
$ledgerService->recalculateInvoice($invoiceId);
CLI::write(' saved', 'blue');
}
} catch (\Throwable $e) {
$errors++;
CLI::error('Invoice #' . $invoiceId . ' failed: ' . $e->getMessage());
}
}
CLI::newLine();
CLI::write('Processed: ' . count($invoiceIds), 'white');
CLI::write('Changed: ' . $changed, $changed > 0 ? 'yellow' : 'white');
CLI::write('Unchanged: ' . $unchanged, 'green');
CLI::write('Errors: ' . $errors, $errors > 0 ? 'red' : 'white');
if (!$commit) {
CLI::write('Dry-run complete. Re-run with --commit to persist corrections.', 'light_blue');
}
}
protected function parseOptions(array $params): array
{
$options = [
'commit' => false,
'invoice-id' => null,
'parent-id' => null,
'school-year' => null,
'semester' => null,
];
foreach ($params as $param) {
$value = trim((string) $param);
if ($value === '--commit') {
$options['commit'] = true;
continue;
}
if (!str_starts_with($value, '--') || !str_contains($value, '=')) {
continue;
}
[$key, $raw] = explode('=', substr($value, 2), 2);
if (array_key_exists($key, $options)) {
$options[$key] = $raw;
}
}
return $options;
}
protected function loadInvoiceIds(InvoiceModel $invoiceModel, array $options): array
{
$builder = $invoiceModel->select('id')->orderBy('id', 'ASC');
if (!empty($options['invoice-id'])) {
$builder->where('id', (int) $options['invoice-id']);
}
if (!empty($options['parent-id'])) {
$builder->where('parent_id', (int) $options['parent-id']);
}
if (!empty($options['school-year'])) {
$builder->where('school_year', (string) $options['school-year']);
}
if (!empty($options['semester'])) {
$builder->where('semester', (string) $options['semester']);
}
return array_map(
static fn (array $row): int => (int) ($row['id'] ?? 0),
$builder->findAll()
);
}
protected function diffInvoice(array $invoice, array $calculation): array
{
$fields = [
'total_amount' => 'total_amount',
'paid_amount' => 'paid_amount',
'balance' => 'balance',
'status' => 'status',
'has_discount' => 'has_discount',
];
$diffs = [];
foreach ($fields as $invoiceKey => $calcKey) {
$current = (string) ($invoice[$invoiceKey] ?? '');
$recalculated = (string) ($calculation[$calcKey] ?? '');
if ($current !== $recalculated) {
$diffs[$invoiceKey] = [
'from' => $current,
'to' => $recalculated,
];
}
}
return $diffs;
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
/**
* Converts one legacy distribution movement into immutable, priced issue
* evidence. The existing stock movement is linked, never replayed, so this
* command cannot decrement inventory twice.
*/
class ReconcileLegacyBookIssue extends BaseCommand
{
protected $group = 'Inventory';
protected $name = 'inventory:reconcile-legacy-book-issue';
protected $description = 'List or explicitly reconcile legacy student book distributions without guessing a price.';
protected $usage = 'php spark inventory:reconcile-legacy-book-issue [--school-year=2025-2026] [--movement-id=123 --unit-price=25.00 --reason="approved evidence" --actor-id=7 --commit]';
protected $options = [
'--school-year' => 'Filter the read-only orphan list.',
'--movement-id' => 'One legacy distribution movement to reconcile.',
'--unit-price' => 'Admin-approved historical unit charge price.',
'--reason' => 'Required audit explanation for the evidence source.',
'--actor-id' => 'Required administrator user ID.',
'--commit' => 'Actually write; without this flag the command is read-only.',
'--json' => 'Print listing or result as JSON.',
];
public function run(array $params)
{
$db = \Config\Database::connect();
foreach (['inventory_movements', 'inventory_item_years', 'student_book_issues', 'withdrawal_financial_calculations', 'refunds'] as $table) {
if (! $db->tableExists($table)) {
CLI::error('Required table is missing: ' . $table . '. Run migrations first.');
return;
}
}
$movementId = (int) (CLI::getOption('movement-id') ?? 0);
if ($movementId <= 0) {
$builder = $db->table('inventory_movements im')
->select('im.id, im.item_id, im.student_id, im.class_section_id, im.qty_change, im.school_year, im.created_at, i.name AS book_name')
->join('inventory_items i', 'i.id = im.item_id', 'inner')
->join('student_book_issues sbi', 'sbi.distribution_movement_id = im.id', 'left')
->where('im.movement_type', 'distribution')
->where('im.student_id IS NOT NULL', null, false)
->where('im.qty_change <', 0)
->where('im.status', 'posted')
->where('sbi.id', null)
->where('i.type', 'book')
->orderBy('im.id', 'ASC');
$year = trim((string) (CLI::getOption('school-year') ?? ''));
if ($year !== '') {
$builder->where('im.school_year', $year);
}
$rows = $builder->get()->getResultArray();
if (CLI::getOption('json') !== null) {
CLI::write(json_encode(['count' => count($rows), 'legacy_distributions' => $rows], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return;
}
CLI::write('Legacy book distributions requiring priced issue evidence: ' . count($rows), 'yellow');
foreach ($rows as $row) {
CLI::write(sprintf('#%d %s student=%d qty=%d year=%s', (int) $row['id'], (string) $row['book_name'], (int) $row['student_id'], abs((int) $row['qty_change']), (string) $row['school_year']));
}
return;
}
try {
$priceCents = $this->priceToCents((string) (CLI::getOption('unit-price') ?? ''));
$reason = trim((string) (CLI::getOption('reason') ?? ''));
$actorId = (int) (CLI::getOption('actor-id') ?? 0);
if ($reason === '' || $actorId <= 0) {
throw new InvalidArgumentException('--reason and a positive --actor-id are required.');
}
$db->transBegin();
$movement = $db->query('SELECT * FROM inventory_movements WHERE id = ? FOR UPDATE', [$movementId])->getRowArray();
if ($movement === null || ($movement['movement_type'] ?? '') !== 'distribution' || ($movement['status'] ?? '') !== 'posted' || (int) ($movement['qty_change'] ?? 0) >= 0 || (int) ($movement['student_id'] ?? 0) <= 0) {
throw new InvalidArgumentException('The movement is not a negative student distribution.');
}
if ($db->table('student_book_issues')->where('distribution_movement_id', $movementId)->countAllResults() > 0) {
throw new InvalidArgumentException('This movement is already linked to student issue evidence.');
}
$book = $db->table('inventory_items')->select('id, name, type')->where('id', (int) $movement['item_id'])->get(1)->getRowArray();
if ($book === null || ($book['type'] ?? '') !== 'book') {
throw new InvalidArgumentException('The movement item is not a book.');
}
$schoolYear = trim((string) ($movement['school_year'] ?? ''));
if ($schoolYear === '') {
throw new InvalidArgumentException('The legacy movement has no school year. Correct that evidence first.');
}
$itemYear = $db->table('inventory_item_years')
->where('inventory_item_id', (int) $movement['item_id'])
->where('school_year', $schoolYear)->get(1)->getRowArray();
if ($itemYear === null) {
throw new InvalidArgumentException('The book has no inventory-year record for ' . $schoolYear . '.');
}
$enrollments = $db->table('enrollments')->select('id, parent_id, class_section_id')
->where('student_id', (int) $movement['student_id'])->where('school_year', $schoolYear)->get()->getResultArray();
if (count($enrollments) !== 1 || (int) ($enrollments[0]['parent_id'] ?? 0) <= 0) {
throw new InvalidArgumentException('Exactly one parent-linked enrollment must exist for the student and year.');
}
$quantity = abs((int) $movement['qty_change']);
if ($quantity <= 0 || $priceCents > intdiv(2147483647, $quantity)) {
throw new InvalidArgumentException('The quantity and approved price exceed the supported charge range.');
}
$now = date('Y-m-d H:i:s');
$issuedAt = trim((string) ($movement['created_at'] ?? '')) ?: $now;
$issue = [
'student_id' => (int) $movement['student_id'],
'enrollment_id' => (int) $enrollments[0]['id'],
'parent_id' => (int) $enrollments[0]['parent_id'],
'inventory_item_id' => (int) $movement['item_id'],
'inventory_item_year_id' => (int) $itemYear['id'],
'school_year' => $schoolYear,
'class_section_id' => (int) ($movement['class_section_id'] ?? $enrollments[0]['class_section_id'] ?? 0) ?: null,
'quantity' => $quantity,
'unit_charge_price_cents' => $priceCents,
'total_charge_cents' => $priceCents * $quantity,
'distribution_movement_id' => $movementId,
'idempotency_key' => 'legacy-distribution:' . $movementId,
'status' => 'issued',
'issued_at' => $issuedAt,
'issued_by' => $actorId,
'created_at' => $now,
'updated_at' => $now,
];
if (CLI::getOption('commit') === null) {
$db->transRollback();
$result = ['mode' => 'dry-run', 'movement_id' => $movementId, 'book' => $book['name'], 'issue' => $issue, 'reason' => $reason];
CLI::write(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
CLI::write('DRY RUN — add --commit to save.', 'yellow');
return;
}
if (! $db->table('student_book_issues')->insert($issue)) {
throw new RuntimeException('Unable to insert issue evidence.');
}
$issueId = (int) $db->insertID();
$db->table('inventory_movements')->where('id', $movementId)->update([
'item_year_id' => (int) $itemYear['id'],
'idempotency_key' => 'legacy-distribution-movement:' . $movementId,
'status' => 'posted',
'source_type' => 'student_book_issue',
'source_id' => $issueId,
'note' => trim((string) ($movement['note'] ?? '') . "\nLegacy evidence approved by user #{$actorId}: {$reason}"),
'updated_at' => $now,
]);
$affectedCalculations = $db->table('withdrawal_financial_calculations')
->select('id')
->where('student_id', (int) $movement['student_id'])
->where('school_year', $schoolYear)
->where('status', 'posted')
->where('withdrawal_request_date >=', substr($issuedAt, 0, 10))
->get()->getResultArray();
$affectedIds = array_values(array_filter(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $affectedCalculations)));
if ($affectedIds !== []) {
$db->table('withdrawal_financial_calculations')->whereIn('id', $affectedIds)->update([
'status' => 'requires_review',
'active_posted_key' => null,
'updated_at' => $now,
]);
$db->table('refunds')->whereIn('withdrawal_calculation_id', $affectedIds)->update([
'reconciliation_status' => 'requires_review',
'reconciliation_reason' => 'Legacy book issue evidence was added after the withdrawal calculation was posted.',
'reconciliation_required_at' => $now,
'updated_at' => $now,
]);
}
if (! $db->transCommit()) {
throw new RuntimeException('Unable to commit the reconciliation.');
}
CLI::write('Reconciled movement #' . $movementId . ' as issue #' . $issueId . ' without changing stock.', 'green');
} catch (Throwable $e) {
$db->transRollback();
CLI::error($e->getMessage());
}
}
private function priceToCents(string $price): int
{
$price = trim($price);
if (! preg_match('/^\d+(?:\.\d{1,2})?$/', $price)) {
throw new InvalidArgumentException('--unit-price is required with no more than two decimal places.');
}
[$whole, $fraction] = array_pad(explode('.', $price, 2), 2, '');
$cents = ((int) $whole * 100) + (int) str_pad($fraction, 2, '0');
if ($cents <= 0 || $cents > 2147483647) {
throw new InvalidArgumentException('Unit price must be greater than zero and within range.');
}
return $cents;
}
}
@@ -0,0 +1,217 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use CodeIgniter\Database\BaseConnection;
use Throwable;
class RefreshEnrollmentAdminTables extends BaseCommand
{
protected $group = 'Registration';
protected $name = 'registration:refresh-admin-tables';
protected $description = 'Refresh enrollment admin dashboard data from the transition service.';
protected $usage = 'php spark registration:refresh-admin-tables [--school-year=2026-2027] [--limit=50] [--dry-run]';
protected $options = [
'--school-year' => 'Target school year name. Defaults to the active/current configured year.',
'--limit' => 'Maximum number of source-year students to process.',
'--dry-run' => 'Preview target/source years and source student count without writing.',
];
private BaseConnection $db;
public function run(array $params)
{
$this->db = \Config\Database::connect();
$options = $this->parseOptions($params);
$schoolYear = trim((string) ($options['school-year'] ?? ''));
if ($schoolYear === '') {
$schoolYear = $this->currentSchoolYear();
}
$sourceYear = $this->previousSchoolYearName($schoolYear);
if ($schoolYear === '' || $sourceYear === null) {
CLI::error('Unable to determine target/source school year.');
return;
}
$limit = (int) ($options['limit'] ?? 0);
$dryRun = ! empty($options['dry-run']);
$students = $this->sourceStudents($sourceYear, $limit);
CLI::write('Enrollment admin refresh', 'cyan');
CLI::write('Target year: ' . $schoolYear);
CLI::write('Source year: ' . $sourceYear);
CLI::write('Source students: ' . count($students));
$before = $this->dashboardCounts($schoolYear);
$this->printCounts('Before', $before);
if ($dryRun) {
CLI::write('Dry-run complete. Re-run without --dry-run to write updates.', 'yellow');
return;
}
$processed = 0;
$eligible = 0;
$blocked = 0;
$errors = 0;
$transitionService = service('enrollmentTransition');
foreach ($students as $student) {
$studentId = (int) ($student['student_id'] ?? 0);
if ($studentId <= 0) {
continue;
}
try {
$evaluation = $transitionService->applyInitialTransition(
$studentId,
$sourceYear,
$schoolYear,
is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : null,
null,
'admin'
);
$processed++;
if (! empty($evaluation['academic_eligible']) && ($evaluation['blockers'] ?? []) === []) {
$eligible++;
} else {
$blocked++;
}
} catch (Throwable $e) {
$errors++;
CLI::error('Student #' . $studentId . ' failed: ' . $e->getMessage());
}
}
$after = $this->dashboardCounts($schoolYear);
$this->printCounts('After', $after);
CLI::write('Processed: ' . $processed, 'white');
CLI::write('Eligible/applied: ' . $eligible, 'green');
CLI::write('Blocked/flagged: ' . $blocked, $blocked > 0 ? 'yellow' : 'white');
CLI::write('Errors: ' . $errors, $errors > 0 ? 'red' : 'white');
}
private function parseOptions(array $params): array
{
$options = [
'school-year' => '',
'limit' => 0,
'dry-run' => false,
];
$rawParams = array_merge($params, array_slice($_SERVER['argv'] ?? [], 2));
foreach ($rawParams as $param) {
$value = trim((string) $param);
if ($value === '--dry-run') {
$options['dry-run'] = true;
continue;
}
if (! str_starts_with($value, '--') || ! str_contains($value, '=')) {
continue;
}
[$key, $raw] = explode('=', substr($value, 2), 2);
if (array_key_exists($key, $options)) {
$options[$key] = $raw;
}
}
return $options;
}
private function sourceStudents(string $sourceYear, int $limit): array
{
if (! $this->db->tableExists('student_class')) {
return [];
}
$builder = $this->db->table('student_class sc')
->select('sc.student_id, s.parent_id')
->join('students s', 's.id = sc.student_id', 'left')
->where('sc.school_year', $sourceYear)
->groupBy('sc.student_id, s.parent_id')
->orderBy('sc.student_id', 'ASC');
if ($limit > 0) {
$builder->limit($limit);
}
return $builder->get()->getResultArray();
}
private function dashboardCounts(string $schoolYear): array
{
return [
'enrollments' => $this->countRows('enrollments', ['school_year' => $schoolYear]),
'open_flags' => $this->countRows('enrollment_flags', ['school_year' => $schoolYear, 'status' => 'open']),
'audits' => $this->countRows('enrollment_transition_audits', ['school_year' => $schoolYear]),
'exceptions' => $this->countRows('enrollment_exceptions', ['school_year' => $schoolYear]),
'email_records' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear]),
];
}
private function countRows(string $table, array $where): int
{
if (! $this->db->tableExists($table)) {
return 0;
}
$builder = $this->db->table($table);
foreach ($where as $field => $value) {
$builder->where($field, $value);
}
return $builder->countAllResults();
}
private function printCounts(string $label, array $counts): void
{
CLI::write($label . ' counts:', 'white');
foreach ($counts as $key => $value) {
CLI::write(' ' . $key . ': ' . $value);
}
}
private function currentSchoolYear(): string
{
if ($this->db->tableExists('school_years')) {
$row = $this->db->table('school_years')
->select('name')
->where('status', 'active')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
if (! empty($row['name'])) {
return (string) $row['name'];
}
}
if ($this->db->tableExists('configuration')) {
$row = $this->db->table('configuration')
->select('config_value')
->where('config_key', 'school_year')
->limit(1)
->get()
->getRowArray();
if (! empty($row['config_value'])) {
return (string) $row['config_value'];
}
}
return '';
}
private function previousSchoolYearName(string $schoolYear): ?string
{
return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)
? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1)
: null;
}
}
View File
+2 -5
View File
@@ -23,7 +23,7 @@ class SendExamDraftDeadlineReminders extends BaseCommand
$configModel = new ConfigurationModel();
$schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
$semester = (string) ($configModel->getConfig('semester') ?? '');
$semester = (string) (getSemester() ?? '');
$deadlineValue = trim((string) ($configModel->getConfig('exam_draft_deadline') ?? ''));
if ($deadlineValue === '') {
CLI::write('exam_draft_deadline is not configured.', 'yellow');
@@ -54,13 +54,10 @@ class SendExamDraftDeadlineReminders extends BaseCommand
$db = Database::connect();
$teacherClassRows = $db->table('teacher_class')
->select('teacher_id, class_section_id, school_year, semester')
->select('teacher_id, class_section_id, school_year')
->when($schoolYear !== '', static function ($builder) use ($schoolYear) {
return $builder->where('school_year', $schoolYear);
})
->when($semester !== '', static function ($builder) use ($semester) {
return $builder->where('semester', $semester);
})
->get()
->getResultArray();
View File
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class SendRegistrationOpeningEmail extends BaseCommand
{
protected $group = 'Registration';
protected $name = 'registration:send-opening-email';
protected $description = 'Send the parent registration opening email on the configured registration start date.';
protected $usage = 'php spark registration:send-opening-email [--force] [--date=YYYY-MM-DD] [--email=parent@example.com] [--dry-run] [--tz=America/New_York]';
public function run(array $params)
{
$tzName = (string) (CLI::getOption('tz') ?? config('School')->attendance['timezone'] ?? 'America/New_York');
$timezone = new \DateTimeZone($tzName);
$dateOption = CLI::getOption('date');
$date = $dateOption
? new \DateTimeImmutable((string) $dateOption, $timezone)
: new \DateTimeImmutable('today', $timezone);
$force = CLI::getOption('force') !== null;
$dryRun = CLI::getOption('dry-run') !== null;
$email = CLI::getOption('email');
$email = is_string($email) && trim($email) !== '' ? trim($email) : null;
if ($email !== null && ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
CLI::error('Invalid --email value.');
return;
}
$service = service('enrollmentRegistrationEmail');
$summary = $service->sendForDate($date, $force, $email, $dryRun);
foreach ($summary['messages'] as $message) {
CLI::write($message, 'yellow');
}
$sentLabel = $dryRun ? 'Would send' : 'Sent';
CLI::write(sprintf(
'Registration opening email complete. Years: %d. Recipients: %d. %s: %d. Failed: %d. Skipped: %d%s.',
$summary['school_years'],
$summary['recipients'],
$sentLabel,
$summary['sent'],
$summary['failed'],
$summary['skipped'],
$dryRun ? ' (dry run)' : ''
), $summary['failed'] > 0 ? 'red' : 'green');
}
}
View File
-232
View File
@@ -1,232 +0,0 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\PayPalPaymentModel;
use App\Models\PaymentModel;
use App\Models\UserModel;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\StudentModel;
use App\Models\EnrollmentModel;
class SyncPaypalPayments extends BaseCommand
{
protected $group = 'Payments';
protected $name = 'payments:sync-paypal';
protected $description = 'Sync PayPal payments to internal payments table from paypal_payments';
protected $configModel;
protected $semester;
protected $schoolYear;
protected $paypalModel;
protected $paymentModel;
protected $userModel;
protected $invoiceModel;
protected $studentModel;
protected $enrollmentModel;
public function __construct()
{
$this->configModel = new ConfigurationModel();
$this->paypalModel = new PayPalPaymentModel();
$this->paymentModel = new PaymentModel();
$this->userModel = new UserModel();
$this->invoiceModel = new InvoiceModel();
$this->studentModel = new StudentModel();
$this->enrollmentModel = new EnrollmentModel();
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
}
public function run(array $params)
{
$dryRun = CLI::getOption('dry-run');
$reportOnly = CLI::getOption('report-only');
$mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE');
$paypalEntries = $this->paypalModel
->where('status', 'COMPLETED')
->where('synced', 0)
->where('sync_attempts <', 3)
->where('transaction_id IS NOT NULL')
->findAll();
$syncedCount = 0;
$failed = [];
foreach ($paypalEntries as $entry) {
$parentId = null;
$invoiceId = 0;
$users = $this->userModel->getUsersBySchoolId($entry['parent_school_id']);
$user = $users[0] ?? null;
// Always increment sync_attempts unless report-only
if (!$reportOnly) {
$this->paypalModel->update($entry['id'], [
'sync_attempts' => $entry['sync_attempts'] + 1
]);
}
if ($user) {
$parentId = $user['id'];
$invoice = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear);
if (!$reportOnly && !$dryRun) {
if ($invoice) {
$invoiceId = $invoice['id'];
$success = $this->processPayment(
$invoiceId,
$entry['amount'],
'PayPal',
null,
$entry['transaction_id'],
date('Y-m-d', strtotime($entry['created_at'])),
$this->schoolYear,
$this->semester
);
if (!$success) {
$failed[] = $entry['transaction_id'];
continue;
}
} else {
$this->paymentModel->insert([
'parent_id' => $parentId,
'invoice_id' => 0,
'total_amount' => $entry['amount'],
'paid_amount' => $entry['amount'],
'balance' => 0.00,
'number_of_installments' => 1,
'transaction_id' => $entry['transaction_id'],
'payment_method' => 'PayPal',
'payment_date' => date('Y-m-d', strtotime($entry['created_at'])),
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'status' => 'Completed',
'updated_by' => null,
]);
}
// Mark as synced only in LIVE mode
$this->paypalModel->update($entry['id'], ['synced' => 1]);
}
$syncedCount++;
} else {
log_message('error', "[PAYPAL SYNC FAILED] No user found for parent_school_id: {$entry['parent_school_id']}");
$failed[] = $entry['transaction_id'];
}
}
// === Logging ===
log_message('info', "[$mode] PAYPAL SYNC: $syncedCount processed.");
if (!empty($failed)) {
log_message('error', "[$mode] PAYPAL SYNC Failed: " . implode(', ', $failed));
}
// === CLI Output ===
CLI::write("[$mode] $syncedCount PayPal payments processed.", 'green');
if (!empty($failed)) {
CLI::error("[$mode] Failed transactions: " . implode(', ', $failed));
}
// === Email Report: Only if there's any update ===
if ($syncedCount > 0 || !empty($failed)) {
helper('email');
$email = \Config\Services::email();
$email->setTo('support@alrahmaisgl.org');
$email->setFrom('no-parentsreply@alrahmaisgl.org', 'PayPal Sync Report');
$email->setSubject("[$mode] PayPal Sync Report - " . date('Y-m-d H:i'));
$body = "PayPal Sync Mode: $mode\n\n";
$body .= "$syncedCount PayPal payments processed.\n\n";
if (!empty($failed)) {
$body .= count($failed) . " failed transactions:\n";
$body .= implode("\n", $failed);
} else {
$body .= "No failed transactions.\n";
}
$email->setMessage(nl2br($body));
if ($email->send()) {
CLI::write("[$mode] Email report sent successfully.", 'yellow');
} else {
CLI::error("[$mode] Failed to send email report.");
log_message('error', 'Email send error: ' . $email->printDebugger(['headers']));
}
} else {
log_message('info', "[$mode] No PayPal sync updates. Email not sent.");
CLI::write("[$mode] No changes to report. Email not sent.", 'blue');
}
}
private function processPayment($invoiceId, $amount, $paymentMethod, $checkFile = null, $transactionId = null, $paymentDate = null, $schoolYear = null, $semester = null)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) {
return false;
}
$transactionId = $transactionId ?? 'INV-' . $invoiceId . '-' . time();
$paymentDate = $paymentDate ?? date('Y-m-d');
$newPaid = $invoice['paid_amount'] + $amount;
$newBalance = $invoice['balance'] - $amount;
$invoiceUpdateData = [
'paid_amount' => $newPaid,
'balance' => $newBalance,
'status' => ($newBalance <= 0) ? 'Paid' : $invoice['status'],
];
if (!$this->invoiceModel->update($invoiceId, $invoiceUpdateData)) {
return false;
}
$this->paymentModel->insert([
'parent_id' => $invoice['parent_id'],
'invoice_id' => $invoiceId,
'total_amount' => $invoice['total_amount'],
'paid_amount' => $amount,
'balance' => $newBalance,
'number_of_installments' => 1,
'transaction_id' => $transactionId,
'payment_method' => $paymentMethod,
'payment_date' => $paymentDate,
'status' => ($newBalance <= 0) ? 'Full' : 'Partial',
'check_file' => $checkFile,
'updated_by' => null, // Avoid using session()->get() in CLI
'school_year' => $schoolYear,
'semester' => $semester
]);
$this->updateEnrollmentStatusIfPaid($invoiceId, $schoolYear);
return true;
}
private function updateEnrollmentStatusIfPaid($invoiceId, $schoolYear)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice || $invoice['balance'] > 0) {
return;
}
$students = $this->studentModel->where('parent_id', $invoice['parent_id'])
->where('school_year', $schoolYear)
->findAll();
foreach ($students as $student) {
$this->enrollmentModel->set(['enrollment_status' => 'enrolled'])
->where('student_id', $student['id'])
->update();
}
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
namespace App\Commands;
use App\Services\EnrollmentStatusService;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use CodeIgniter\Database\BaseConnection;
class SyncStudentActivity extends BaseCommand
{
protected $group = 'Enrollment';
protected $name = 'students:sync-activity';
protected $description = 'Synchronize students.is_active from current-year enrollment_status.';
protected $usage = 'php spark students:sync-activity --school-year=YYYY-YYYY [--dry-run]';
protected $options = [
'--school-year' => 'School year to use as the controlling enrollment year.',
'--dry-run' => 'Report changes without updating students.',
];
private BaseConnection $db;
private EnrollmentStatusService $statusService;
public function run(array $params)
{
$this->db = \Config\Database::connect();
$this->statusService = new EnrollmentStatusService($this->db);
$schoolYear = $this->optionValue('school-year');
if ($schoolYear === '') {
$schoolYear = $this->statusService->currentSchoolYear();
}
if ($schoolYear === '') {
CLI::error('Missing --school-year and no configured current school year was found.');
return EXIT_ERROR;
}
$dryRun = CLI::getOption('dry-run') !== null;
$duplicates = $this->duplicates($schoolYear);
$unknownStatuses = [];
$withoutEnrollment = [];
$mismatches = [];
$updated = 0;
$students = $this->db->table('students')
->select('id, school_id, firstname, lastname, is_active')
->orderBy('id', 'ASC')
->get()
->getResultArray();
foreach ($students as $student) {
$studentId = (int) $student['id'];
$enrollment = $this->statusService->controllingEnrollment($studentId, $schoolYear);
if ($enrollment === null) {
$expected = 0;
$withoutEnrollment[] = $this->studentLabel($student);
} else {
try {
$status = $this->statusService->normalizeStatus((string) ($enrollment['enrollment_status'] ?? ''));
$expected = $this->statusService->activeFlagForStatus($status);
} catch (\InvalidArgumentException $e) {
$unknownStatuses[] = [
'student' => $this->studentLabel($student),
'enrollment_id' => (int) ($enrollment['id'] ?? 0),
'status' => (string) ($enrollment['enrollment_status'] ?? ''),
];
continue;
}
}
$actual = (int) ($student['is_active'] ?? 1);
if ($actual !== $expected) {
$mismatches[] = [
'student' => $this->studentLabel($student),
'current' => $actual,
'expected' => $expected,
'status' => (string) ($enrollment['enrollment_status'] ?? 'no enrollment'),
];
if (! $dryRun) {
$this->db->table('students')->where('id', $studentId)->update(['is_active' => $expected]);
$updated++;
}
}
}
CLI::write('School year: ' . $schoolYear);
CLI::write('Dry run: ' . ($dryRun ? 'yes' : 'no'));
CLI::write('Duplicate enrollment groups: ' . count($duplicates));
foreach ($duplicates as $row) {
CLI::write(sprintf(
' student_id=%d school_year=%s semester=%s count=%d',
(int) $row['student_id'],
(string) $row['school_year'],
(string) ($row['semester'] ?? ''),
(int) $row['row_count']
));
}
CLI::write('Unknown statuses: ' . count($unknownStatuses));
foreach ($unknownStatuses as $row) {
CLI::write(sprintf(' %s enrollment_id=%d status=%s', $row['student'], $row['enrollment_id'], $row['status']));
}
CLI::write('Students without current enrollment: ' . count($withoutEnrollment));
foreach (array_slice($withoutEnrollment, 0, 50) as $label) {
CLI::write(' ' . $label);
}
if (count($withoutEnrollment) > 50) {
CLI::write(' ... ' . (count($withoutEnrollment) - 50) . ' more');
}
CLI::write('Mismatches: ' . count($mismatches));
foreach ($mismatches as $row) {
CLI::write(sprintf(
' %s current=%d expected=%d status=%s',
$row['student'],
$row['current'],
$row['expected'],
$row['status']
));
}
if (! $dryRun) {
CLI::write('Updated students: ' . $updated);
}
return EXIT_SUCCESS;
}
private function duplicates(string $schoolYear): array
{
return $this->db->table('enrollments')
->select('student_id, school_year, semester, COUNT(*) AS row_count')
->where('school_year', $schoolYear)
->groupBy('student_id, school_year, semester')
->having('COUNT(*) >', 1)
->orderBy('student_id', 'ASC')
->get()
->getResultArray();
}
private function studentLabel(array $student): string
{
$name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
return '#' . (int) ($student['id'] ?? 0) . ($name !== '' ? ' ' . $name : '');
}
private function optionValue(string $name): string
{
$value = CLI::getOption($name);
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
$argv = $_SERVER['argv'] ?? [];
$prefix = '--' . $name . '=';
foreach ($argv as $index => $arg) {
if (is_string($arg) && str_starts_with($arg, $prefix)) {
return trim(substr($arg, strlen($prefix)));
}
if ($arg === '--' . $name && isset($argv[$index + 1])) {
return trim((string) $argv[$index + 1]);
}
}
return '';
}
}
+182
View File
@@ -0,0 +1,182 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
/** Read-only audit for withdrawal refund posting prerequisites. */
class WithdrawalInventoryAudit extends BaseCommand
{
protected $group = 'Financial';
protected $name = 'financial:withdrawal-inventory-audit';
protected $description = 'Audit withdrawal policy, book inventory evidence, invoices, and open refunds without changing data.';
protected $usage = 'php spark financial:withdrawal-inventory-audit --school-year=2025-2026 [--json]';
protected $options = ['--school-year' => 'Required school year.', '--json' => 'Print JSON.'];
public function run(array $params)
{
$db = \Config\Database::connect();
$year = trim((string) (CLI::getOption('school-year') ?? ''));
if ($year === '') {
CLI::error('--school-year is required; the audit will not guess an active year.');
return;
}
$checks = [];
$policy = $db->table('school_years')->where('name', $year)->get(1)->getRowArray();
$configWeeksRow = $db->table('configuration')
->select('config_value')
->where('config_key', 'total_instructional_weeks')
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
$configWeeks = filter_var($configWeeksRow['config_value'] ?? null, FILTER_VALIDATE_INT);
$checks['policy'] = $policy !== null
&& $configWeeks !== false
&& $configWeeks > 0
&& (int) ($policy['annual_fee_includes_books'] ?? 0) === 1
? $this->check('pass', 'School-year refund inputs are present.')
: $this->check('blocking', 'Configuration total_instructional_weeks or book-inclusive policy is missing.');
$missingPrices = $db->table('inventory_item_years iy')
->join('inventory_items i', 'i.id = iy.inventory_item_id', 'inner')
->where('iy.school_year', $year)->where('i.type', 'book')
->groupStart()->where('iy.charge_price_cents <=', 0)->orWhere('iy.price_confirmed !=', 1)->groupEnd()
->countAllResults();
$checks['book_prices'] = $missingPrices === 0 ? $this->check('pass', 'All book-year prices are confirmed.') : $this->check('blocking', $missingPrices . ' book-year price(s) are missing or unconfirmed.');
$catalogRows = $db->table('inventory_items')->select('id, name, isbn, edition, sku, author')->where('type', 'book')->get()->getResultArray();
$identityCounts = [];
$skuCounts = [];
foreach ($catalogRows as $catalogRow) {
$isbn = strtoupper((string) preg_replace('/[^0-9X]/i', '', (string) ($catalogRow['isbn'] ?? '')));
$edition = strtolower(trim((string) ($catalogRow['edition'] ?? '')));
$sku = strtolower(trim((string) ($catalogRow['sku'] ?? '')));
if ($isbn !== '') {
$identityCounts[$isbn . '|' . $edition] = ($identityCounts[$isbn . '|' . $edition] ?? 0) + 1;
}
if ($sku !== '') {
$skuCounts[$sku] = ($skuCounts[$sku] ?? 0) + 1;
}
}
$duplicateCount = count(array_filter($identityCounts, static fn (int $count): bool => $count > 1))
+ count(array_filter($skuCounts, static fn (int $count): bool => $count > 1));
$checks['duplicate_catalog'] = $duplicateCount === 0 ? $this->check('pass', 'No duplicate normalized ISBN/edition or SKU keys found.') : $this->check('blocking', $duplicateCount . ' duplicate normalized ISBN/edition or SKU group(s) require reconciliation.');
$missingAuthorCount = count(array_filter($catalogRows, static fn (array $row): bool => trim((string) ($row['author'] ?? '')) === ''));
$checks['catalog_author'] = $missingAuthorCount === 0
? $this->check('pass', 'All book catalog rows have an author.')
: $this->check('warning', $missingAuthorCount . ' book catalog row(s) have no author.');
$unassignedBooks = $db->table('inventory_items i')
->join('inventory_book_class_assignments a', 'a.inventory_item_id = i.id AND a.school_year = ' . $db->escape($year), 'left')
->where('i.type', 'book')->where('i.is_active', 1)->where('a.id', null)->countAllResults();
$checks['book_class_assignments'] = $unassignedBooks === 0
? $this->check('pass', 'All active books have explicit year/class assignments.')
: $this->check('warning', $unassignedBooks . ' active book(s) rely on category fallback instead of explicit class assignments.');
$malformedYearTags = $db->table('inventory_movements')->where('school_year IS NOT NULL', null, false)
->where("school_year NOT REGEXP '^[0-9]{4}-[0-9]{4}$'", null, false)->countAllResults();
$checks['movement_year_tags'] = $malformedYearTags === 0
? $this->check('pass', 'Inventory movement year tags are well formed.')
: $this->check('warning', $malformedYearTags . ' inventory movement(s) have malformed school-year tags.');
$legacyDistributions = $db->table('inventory_movements im')
->join('student_book_issues sbi', 'sbi.distribution_movement_id = im.id', 'left')
->where('im.school_year', $year)->where('im.movement_type', 'distribution')->where('sbi.id', null)
->countAllResults();
$checks['legacy_distributions'] = $legacyDistributions === 0 ? $this->check('pass', 'Every distribution has a price-snapshotted issue.') : $this->check('blocking', $legacyDistributions . ' distribution movement(s) lack student issue evidence and cannot be priced automatically.');
$positiveStudentMovements = $db->table('inventory_movements')
->where('school_year', $year)->where('student_id IS NOT NULL', null, false)->where('qty_change >', 0)
->groupStart()->where('source_type !=', 'student_book_issue_reversal')->orWhere('source_type', null)->groupEnd()->countAllResults();
$checks['possible_returns'] = $positiveStudentMovements === 0 ? $this->check('pass', 'No unclassified positive student book movements found.') : $this->check('blocking', $positiveStudentMovements . ' positive student movement(s) look like legacy returns/corrections and require manual classification.');
$duplicateOpenings = $db->table('inventory_movements')
->select('item_id')->where('school_year', $year)->where('movement_type', 'initial')->groupBy('item_id')->having('COUNT(*) > 1', null, false)->countAllResults();
$checks['duplicate_openings'] = $duplicateOpenings === 0 ? $this->check('pass', 'No duplicate legacy opening movements found.') : $this->check('blocking', $duplicateOpenings . ' book/item(s) have duplicate opening movements.');
$quantityMismatches = $db->query(
"SELECT COUNT(*) AS total FROM (
SELECT i.id
FROM inventory_items i
JOIN inventory_item_years iy ON iy.inventory_item_id = i.id AND iy.school_year = ?
LEFT JOIN inventory_movements im ON im.item_year_id = iy.id AND im.status IN ('posted','reversed')
WHERE i.type = 'book'
GROUP BY i.id, i.quantity, iy.opening_quantity
HAVING i.quantity != iy.opening_quantity + COALESCE(SUM(im.qty_change), 0)
) mismatches",
[$year]
)->getRowArray();
$quantityMismatchCount = (int) ($quantityMismatches['total'] ?? 0);
$checks['quantity_projection'] = $quantityMismatchCount === 0
? $this->check('pass', 'Book catalog quantities match the year movement ledger.')
: $this->check('blocking', $quantityMismatchCount . ' book quantity projection(s) disagree with the year movement ledger.');
$legacyRefunds = $db->table('refunds')->where('school_year', $year)
->whereIn('status', ['Pending', 'pending', 'requested', 'Approved', 'approved', 'Partial', 'partial', 'partially_paid'])
->groupStart()->where('withdrawal_calculation_id', null)->orWhere('withdrawal_calculation_id', 0)->groupEnd()
->groupStart()->where('source_type', 'tuition_withdrawal')->orLike('reason', 'Withdrawal')->groupEnd()->countAllResults();
$checks['legacy_refunds'] = $legacyRefunds === 0 ? $this->check('pass', 'Open withdrawal refunds have calculation links.') : $this->check('blocking', $legacyRefunds . ' open withdrawal refund(s) have no versioned calculation.');
$comparisonRows = $db->query(
"SELECT r.id AS refund_id, r.invoice_id, r.refund_amount,
r.requested_amount_cents AS stored_requested_cents,
wfc.id AS calculation_id, wfc.new_refund_request_cents AS calculated_requested_cents,
wfc.status AS calculation_status
FROM refunds r
LEFT JOIN withdrawal_financial_calculations wfc ON wfc.id = r.withdrawal_calculation_id
WHERE r.school_year = ?
AND (r.source_type = 'tuition_withdrawal' OR r.reason LIKE '%Withdrawal%')
AND LOWER(COALESCE(r.status,'')) IN ('pending','requested','approved','partial','partially_paid')
ORDER BY r.id",
[$year]
)->getResultArray();
$comparisonDifferences = 0;
foreach ($comparisonRows as &$comparison) {
$storedCents = (int) ($comparison['stored_requested_cents'] ?? 0);
if ($storedCents === 0) {
$storedCents = (int) round(((float) ($comparison['refund_amount'] ?? 0)) * 100, 0, PHP_ROUND_HALF_UP);
}
$comparison['stored_requested_cents'] = $storedCents;
$comparison['difference_cents'] = $comparison['calculation_id'] === null
? null
: $storedCents - (int) ($comparison['calculated_requested_cents'] ?? 0);
if ($comparison['difference_cents'] !== null && $comparison['difference_cents'] !== 0) {
$comparisonDifferences++;
}
}
unset($comparison);
$checks['refund_comparison'] = $comparisonDifferences === 0
? $this->check('pass', 'Linked open refund requests match their versioned calculations.')
: $this->check('blocking', $comparisonDifferences . ' linked open refund request(s) differ from their calculation snapshot.');
$duplicateInvoices = $db->query(
"SELECT COUNT(*) AS total FROM (
SELECT parent_id FROM invoices WHERE school_year = ?
AND LOWER(COALESCE(status,'')) NOT IN ('void','voided','cancelled','canceled')
GROUP BY parent_id HAVING COUNT(*) > 1
) duplicates",
[$year]
)->getRowArray();
$duplicateInvoiceCount = (int) ($duplicateInvoices['total'] ?? 0);
$checks['duplicate_invoices'] = $duplicateInvoiceCount === 0 ? $this->check('pass', 'No parent has multiple active invoices.') : $this->check('blocking', $duplicateInvoiceCount . ' parent(s) have multiple active invoices and require review.');
$blocking = count(array_filter($checks, static fn (array $check): bool => $check['severity'] === 'blocking'));
$report = ['school_year' => $year, 'generated_at' => date('Y-m-d H:i:s'), 'ready_to_enable' => $blocking === 0, 'blocking_count' => $blocking, 'checks' => $checks, 'open_refund_comparison' => $comparisonRows];
if (CLI::getOption('json') !== null) {
CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return;
}
CLI::write('Withdrawal and inventory audit for ' . $year, 'yellow');
foreach ($checks as $name => $check) {
$color = $check['severity'] === 'blocking' ? 'red' : ($check['severity'] === 'warning' ? 'yellow' : 'green');
CLI::write(strtoupper($check['severity']) . ' ' . $name . ': ' . $check['message'], $color);
}
CLI::write($blocking === 0 ? 'READY TO ENABLE' : 'DO NOT ENABLE — blockers remain', $blocking === 0 ? 'green' : 'red');
}
private function check(string $severity, string $message): array
{
return ['severity' => $severity, 'message' => $message];
}
}
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
+12
View File
@@ -215,4 +215,16 @@ class App extends BaseConfig
// Sessions (also in App.php)
public string $sessionCookieName = '__Host-ci_session';
public bool $sessionRegenerateDestroy = true;
public function __construct()
{
parent::__construct();
$isProduction = ENVIRONMENT === 'production';
$this->forceGlobalSecureRequests = $isProduction;
$this->cookieSecure = $isProduction;
if (! $isProduction) {
$this->cookiePrefix = '';
$this->sessionCookieName = 'ci_session';
}
}
}
Executable → Regular
+1 -1
View File
@@ -98,6 +98,6 @@ class Autoload extends AutoloadConfig
*
* @var list<string>
*/
public $helpers = ['url', 'form', 'pbkdf2', 'document', 'time', 'api'];
public $helpers = ['url', 'form', 'pbkdf2', 'document', 'time', 'api', 'global_config', 'student_status'];
}
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
+8
View File
@@ -168,4 +168,12 @@ class Cache extends BaseConfig
'redis' => RedisHandler::class,
'wincache' => WincacheHandler::class,
];
public function __construct()
{
if (ENVIRONMENT === 'testing') {
$this->storePath = WRITEPATH . 'cache/testing/';
$this->file['storePath'] = WRITEPATH . 'cache/testing/';
}
}
}
Executable → Regular
+3 -2
View File
@@ -16,11 +16,12 @@ class Commands extends BaseService
\App\Commands\CleanupPasswordResets::class,
\App\Commands\ConfigUpdate::class,
\App\Commands\DeleteInactiveUsers::class,
\App\Commands\RecalculateInvoices::class,
\App\Commands\SendAbsenteesSummary::class,
\App\Commands\SendLatesSummary::class,
\App\Commands\SendMonthlyPaymentNotifications::class,
\App\Commands\SendRegistrationOpeningEmail::class,
\App\Commands\SendTestPaymentNotification::class,
\App\Commands\SyncPaypalPayments::class,
\App\Commands\RecalculateAttendance::class,
];
@@ -30,4 +31,4 @@ class Commands extends BaseService
}
}
}
}
}
Executable → Regular
+13
View File
@@ -25,6 +25,19 @@ defined('APP_NAMESPACE') || define('APP_NAMESPACE', 'App');
*/
defined('COMPOSER_PATH') || define('COMPOSER_PATH', ROOTPATH . 'vendor/autoload.php');
/*
| --------------------------------------------------------------------------
| Test Support Path
| --------------------------------------------------------------------------
|
| Spark may be run with CI_ENVIRONMENT=testing before CodeIgniter's PHPUnit
| bootstrap has had a chance to define SUPPORTPATH. The framework autoloader
| expects this constant when that environment flag is present.
*/
if (! defined('SUPPORTPATH') && defined('TESTPATH') && is_dir(TESTPATH . '_support')) {
define('SUPPORTPATH', realpath(TESTPATH . '_support') . DIRECTORY_SEPARATOR);
}
/*
|--------------------------------------------------------------------------
| Timing Constants
+46 -5
View File
@@ -47,28 +47,63 @@ class ContentSecurityPolicy extends BaseConfig
*
* @var list<string>|string|null
*/
public $defaultSrc;
public $defaultSrc = 'self';
/**
* Lists allowed scripts' URLs.
*
* @var list<string>|string
*/
public $scriptSrc = 'self';
public $scriptSrc = [
'self',
'unsafe-inline',
'https://cdn.jsdelivr.net',
'https://code.jquery.com',
'https://cdnjs.cloudflare.com',
];
/**
* Lists allowed stylesheets' URLs.
*
* @var list<string>|string
*/
public $styleSrc = 'self';
public $styleSrc = [
'self',
'unsafe-inline',
'https://cdn.jsdelivr.net',
'https://fonts.googleapis.com',
'https://cdnjs.cloudflare.com',
];
/**
* Lists allowed stylesheet element URLs.
*
* @var list<string>|string
*/
public $styleSrcElem = [
'self',
'unsafe-inline',
'https://cdn.jsdelivr.net',
'https://fonts.googleapis.com',
'https://cdnjs.cloudflare.com',
];
/**
* Allows inline style attributes used by legacy views.
*
* @var list<string>|string
*/
public $styleSrcAttr = [
'self',
'unsafe-inline',
];
/**
* Defines the origins from which images can be loaded.
*
* @var list<string>|string
*/
public $imageSrc = 'self';
public $imageSrc = ['self', 'data:', 'https:'];
/**
* Restricts the URLs that can appear in a page's `<base>` element.
@@ -99,7 +134,13 @@ class ContentSecurityPolicy extends BaseConfig
*
* @var list<string>|string
*/
public $fontSrc;
public $fontSrc = [
'self',
'data:',
'https://fonts.gstatic.com',
'https://cdn.jsdelivr.net',
'https://cdnjs.cloudflare.com',
];
/**
* Lists valid endpoints for submission from `<form>` tags.
Executable → Regular
+9
View File
@@ -56,6 +56,15 @@ class Cookie extends BaseConfig
*/
public bool $secure = false;
public function __construct()
{
parent::__construct();
$this->secure = ENVIRONMENT === 'production';
if ($this->secure) {
$this->prefix = '__Host-';
}
}
/**
* --------------------------------------------------------------------------
* Cookie HTTPOnly
Executable → Regular
+2 -2
View File
@@ -34,7 +34,7 @@ class Cors extends BaseConfig
* - ['http://localhost:8080']
* - ['https://www.example.com']
*/
'allowedOrigins' => ['*'], // Allow all origins for mobile apps
'allowedOrigins' => ['*'],
/**
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
@@ -57,7 +57,7 @@ class Cors extends BaseConfig
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
*/
'supportsCredentials' => true, // Enable for mobile apps using cookies/auth
'supportsCredentials' => false,
/**
* Set headers to allow.
Executable → Regular
+13 -9
View File
@@ -16,6 +16,10 @@ class Database extends Config
{
parent::__construct();
if (ENVIRONMENT === 'testing') {
$this->defaultGroup = 'tests';
}
$this->default = [
'DSN' => '',
'hostname' => env('database.default.hostname'),
@@ -25,7 +29,7 @@ class Database extends Config
'DBDriver' => env('database.default.DBDriver', 'MySQLi'),
'DBPrefix' => '',
'pConnect' => false,
'DBDebug' => (ENVIRONMENT !== 'development'),
'DBDebug' => (ENVIRONMENT === 'development'),
'charset' => 'utf8',
'DBCollat' => 'utf8_general_ci',
'swapPre' => '',
@@ -38,22 +42,22 @@ class Database extends Config
$this->tests = [
'DSN' => '',
'hostname' => env('database.tests.hostname', env('database.default.hostname')),
'username' => env('database.tests.username', env('database.default.username')),
'password' => env('database.tests.password', env('database.default.password')),
'database' => env('database.tests.database', env('database.default.database')),
'hostname' => env('TEST_DB_HOST', env('database.tests.hostname', env('database.default.hostname'))),
'username' => env('TEST_DB_USER', env('database.tests.username', env('database.default.username'))),
'password' => env('TEST_DB_PASSWORD', env('database.tests.password', env('database.default.password'))),
'database' => env('TEST_DB_NAME', env('database.tests.database', 'alrahma_test')),
'DBDriver' => env('database.tests.DBDriver', env('database.default.DBDriver', 'MySQLi')),
'DBPrefix' => env('database.tests.DBPrefix', 'db_'),
'DBPrefix' => env('database.tests.DBPrefix', ''),
'pConnect' => false,
'DBDebug' => true,
'charset' => 'utf8',
'DBCollat' => 'utf8_general_ci',
'charset' => 'utf8mb4',
'DBCollat' => 'utf8mb4_general_ci',
'swapPre' => '',
'encrypt' => false,
'compress' => false,
'strictOn' => false,
'failover' => [],
'port' => (int) env('database.tests.port', env('database.default.port', 3306)),
'port' => (int) env('TEST_DB_PORT', env('database.tests.port', env('database.default.port', 3306))),
];
}
}
Executable → Regular
View File
Executable → Regular
+25 -10
View File
@@ -6,16 +6,16 @@ use CodeIgniter\Config\BaseConfig;
class Email extends BaseConfig
{
public string $protocol = 'smtp';
public string $SMTPHost = 'smtp.gmail.com';
public string $SMTPUser = 'alrahma.sunday.school@gmail.com';
public string $SMTPPass = 'psnp emdq dykw ypul'; // Consider using ENV()
public int $SMTPPort = 465;
public string $SMTPCrypto = 'ssl'; // ✅ Correct for port 465
public string $protocol;
public string $SMTPHost;
public string $SMTPUser;
public string $SMTPPass;
public int $SMTPPort;
public string $SMTPCrypto;
public bool $SMTPAuth = true;
public int $SMTPTimeout = 5;
public bool $SMTPKeepAlive = true;
public bool $SMTPAuth;
public int $SMTPTimeout;
public bool $SMTPKeepAlive;
public string $charset = 'UTF-8';
public string $mailType = 'html';
@@ -23,4 +23,19 @@ class Email extends BaseConfig
public string $newline = "\r\n";
public string $CRLF = "\r\n";
}
public function __construct()
{
parent::__construct();
$this->protocol = (string) env('mail.protocol', env('email.protocol', 'smtp'));
$this->SMTPHost = (string) env('mail.SMTPHost', env('email.SMTPHost', env('SMTP_HOST', 'smtp.gmail.com')));
$this->SMTPUser = (string) env('mail.SMTPUser', env('email.SMTPUser', env('SMTP_USER', '')));
$this->SMTPPass = (string) env('mail.SMTPPass', env('email.SMTPPass', env('SMTP_PASS', '')));
$this->SMTPPort = (int) env('mail.SMTPPort', env('email.SMTPPort', env('SMTP_PORT', 465)));
$this->SMTPCrypto = (string) env('mail.SMTPCrypto', env('email.SMTPCrypto', env('SMTP_ENCRYPTION', 'ssl')));
$this->SMTPAuth = filter_var(env('mail.SMTPAuth', env('email.SMTPAuth', true)), FILTER_VALIDATE_BOOLEAN);
$this->SMTPTimeout = (int) env('mail.SMTPTimeout', env('email.SMTPTimeout', 5));
$this->SMTPKeepAlive = filter_var(env('mail.SMTPKeepAlive', env('email.SMTPKeepAlive', true)), FILTER_VALIDATE_BOOLEAN);
}
}
Executable → Regular
+9
View File
@@ -23,6 +23,15 @@ class Encryption extends BaseConfig
*/
public string $key = '';
public function __construct()
{
parent::__construct();
$fromEnv = (string) env('encryption.key', env('ENCRYPTION_KEY', ''));
if ($fromEnv !== '') {
$this->key = $fromEnv;
}
}
/**
* --------------------------------------------------------------------------
* Encryption Driver to Use
Executable → Regular
+2
View File
@@ -9,6 +9,7 @@ use App\Listeners\SchoolEventListener;
use App\Listeners\AttendanceConsequenceListener;
use App\Listeners\WhatsappInviteListener;
use App\Listeners\BelowSixtyEmailListener;
use App\Listeners\DecisionEmailListener;
// Create an instance so we can use $this->emailService like your other handlers
$waListener = new WhatsappInviteListener(service('emailService'));
@@ -118,6 +119,7 @@ Events::on('attendance.follow_up', [AttendanceConsequenceListener::class, 'fol
Events::on('attendance.final_warning', [AttendanceConsequenceListener::class, 'finalWarning']);
Events::on('attendance.dismissal', [AttendanceConsequenceListener::class, 'dismissal']);
Events::on('below60.email', [BelowSixtyEmailListener::class, 'handle']);
Events::on('below60.decision_email', [DecisionEmailListener::class, 'handle']);
//Whatsapp Event listener
Events::on('whatsapp_invites.send', [$waListener, 'handle']);
Executable → Regular
View File
Executable → Regular
+5
View File
@@ -27,4 +27,9 @@ class Feature extends BaseConfig
* Use improved new auto routing instead of the default legacy version.
*/
public bool $autoRoutesImproved = false;
/**
* Show the global school-year selector in authenticated admin layouts.
*/
public bool $globalSchoolYearSelector = true;
}
Executable → Regular
+37 -11
View File
@@ -24,11 +24,15 @@ class Filters extends BaseConfig
'honeypot' => Honeypot::class,
'invalidchars' => InvalidChars::class,
'secureheaders' => SecureHeaders::class,
'sanitizeinput' => \App\Filters\SanitizeInputFilter::class,
'apiratelimit' => \App\Filters\ApiRateLimitFilter::class,
'auth' => \App\Filters\AuthFilter::class, // Define the alias for your auth filter
'apiAuth' => \App\Filters\ApiAuthFilter::class, // JWT-based API authentication
'cleanupScheduler' => \App\Filters\CleanupScheduler::class,
'permission' => \App\Filters\PermissionFilter::class,
'permission' => \App\Filters\PermissionFilter::class,
'timezone' => \App\Filters\TimezoneFilter::class,
'schoolYear' => \App\Filters\RequireSchoolYearFilter::class,
'schoolYearWritable'=> \App\Filters\SchoolYearWritableFilter::class,
];
@@ -41,16 +45,31 @@ class Filters extends BaseConfig
*/
public array $globals = [
'before' => [
'timezone',
// Heartbeat must not load timezone preferences / settings.
'timezone' => ['except' => [
'session/ping-activity',
'index.php/session/ping-activity',
'session/ping',
'index.php/session/ping',
'session/check-timeout',
'index.php/session/check-timeout',
'session/get-timeout-config',
'index.php/session/get-timeout-config',
]],
'sanitizeinput',
'invalidchars',
// Heartbeat is not a school-year write; skip writable-year DB work.
'schoolYearWritable' => ['except' => [
'session/ping-activity',
'index.php/session/ping-activity',
'session/ping',
'index.php/session/ping',
'session/check-timeout',
'index.php/session/check-timeout',
'session/get-timeout-config',
'index.php/session/get-timeout-config',
]],
'csrf' => ['except' => [
// Webhooks / integrations
'api/paypal-webhook',
'index.php/api/paypal-webhook',
// WhatsApp membership management (legacy allowances retained)
'whatsapp/update-membership',
'index.php/whatsapp/update-membership',
// Attendance management AJAX saves
'attendance/update',
'index.php/attendance/update',
@@ -77,9 +96,16 @@ class Filters extends BaseConfig
// Badge PDF generation posts (handled by auth; allow multiple submits without reload)
'badge',
'index.php/badge',
// Session keep-alive endpoint updates only the current browser session.
'session/ping',
'index.php/session/ping',
'session/ping-activity',
'index.php/session/ping-activity',
]],
],
'after' => [
'secureheaders',
'toolbar',
'cleanupScheduler' => ['except' => ['cleanup/*']],
],
@@ -112,7 +138,7 @@ class Filters extends BaseConfig
* @var array<string, array<string, list<string>>>
*/
public array $filters = [
'apiratelimit' => ['before' => ['api/*', 'index.php/api/*']],
];
}
Executable → Regular
View File
Executable → Regular
+1
View File
@@ -9,6 +9,7 @@ use CodeIgniter\Format\XMLFormatter;
class Format extends BaseConfig
{
public int $jsonEncodeDepth = 512;
/**
* --------------------------------------------------------------------------
* Available Response Formats
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
-13
View File
@@ -1,13 +0,0 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class PaypalConfig extends BaseConfig
{
// PayPal API Credentials
public $paypalClientId = 'YOUR_PAYPAL_CLIENT_ID';
public $paypalSecret = 'YOUR_PAYPAL_SECRET';
public $paypalMode = 'sandbox'; // Change to 'live' when moving to production
}
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
+313 -291
View File
@@ -47,6 +47,8 @@ $routes->setAutoRoute(false);
* --------------------------------------------------------------------
*/
$routes->post('api/proofread', 'ProofreadController::check', ['filter' => 'auth']);
$routes->post('school-year/select', 'View\SchoolYearSelectionController::select', ['filter' => 'auth']);
$routes->post('school-year/reset', 'View\SchoolYearSelectionController::reset', ['filter' => 'auth']);
/*
* --------------------------------------------------------------------
@@ -105,6 +107,28 @@ $routes->post('student/score-card', 'View\StudentController::scoreCard');
$routes->get('student/score-card', 'View\StudentController::scoreCardIndex');
$routes->get('student/score-card/list', 'View\StudentController::scoreCardList');
$routes->get('administrator/student-score-card', 'View\StudentController::scoreCardAdmin');
$enrollmentAdminFilter = 'auth:admin|administrator|principal|vice_principal|vice principal';
$routes->get('administrator/enrollment-admin', 'View\EnrollmentAdminController::dashboard', ['filter' => $enrollmentAdminFilter]);
$routes->get('administrator/enrollment-admin/email-preview', 'View\EnrollmentAdminController::previewEmail', ['filter' => $enrollmentAdminFilter]);
$routes->post('administrator/enrollment-admin/approve-launch', 'View\EnrollmentAdminController::approveLaunch', ['filter' => $enrollmentAdminFilter]);
$routes->post('administrator/enrollment-admin/send-registration-emails', 'View\EnrollmentAdminController::sendRegistrationEmails', ['filter' => $enrollmentAdminFilter]);
$routes->post('administrator/enrollment-admin/flags/(:num)/resolve', 'View\EnrollmentAdminController::resolveFlag/$1', ['filter' => $enrollmentAdminFilter]);
$routes->post('administrator/enrollment-admin/flags/(:num)/assign-class', 'View\EnrollmentAdminController::assignClass/$1', ['filter' => $enrollmentAdminFilter]);
$routes->post('administrator/enrollment-admin/flags/(:num)/makeup-promotion', 'View\EnrollmentAdminController::confirmMakeupPromotion/$1', ['filter' => $enrollmentAdminFilter]);
$routes->post('administrator/enrollment-admin/flags/(:num)/approve-exception', 'View\EnrollmentAdminController::approveException/$1', ['filter' => $enrollmentAdminFilter]);
$routes->post('administrator/enrollment-admin/exceptions/create', 'View\EnrollmentAdminController::createException', ['filter' => $enrollmentAdminFilter]);
$routes->post('administrator/enrollment-admin/exceptions/(:num)/revoke', 'View\EnrollmentAdminController::revokeException/$1', ['filter' => $enrollmentAdminFilter]);
$routes->get('administrator/financial-aid', 'Administrator\FinancialAidController::index', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
$routes->get('administrator/financial-aid/(:num)', 'Administrator\FinancialAidController::show/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
$routes->post('administrator/financial-aid/(:num)/estimate', 'Administrator\FinancialAidController::estimate/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
$routes->post('administrator/financial-aid/(:num)/approve', 'Administrator\FinancialAidController::approve/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
$routes->post('administrator/financial-aid/(:num)/deny', 'Administrator\FinancialAidController::deny/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
$withdrawalFinancialFilter = 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal';
$routes->get('administrator/withdrawals/(:num)/review', 'Administrator\WithdrawalFinancialController::review/$1', ['filter' => $withdrawalFinancialFilter]);
$routes->post('administrator/withdrawals/(:num)/recalculate', 'Administrator\WithdrawalFinancialController::recalculate/$1', ['filter' => $withdrawalFinancialFilter . ',update']);
$routes->post('administrator/withdrawal-calculations/(:num)/confirm', 'Administrator\WithdrawalFinancialController::confirm/$1', ['filter' => $withdrawalFinancialFilter . ',update']);
$routes->get('administrator/withdrawal-calculations/(:num)', 'Administrator\WithdrawalFinancialController::calculation/$1', ['filter' => $withdrawalFinancialFilter]);
$routes->get('administrator/invoices/(:num)/withdrawal-calculations', 'Administrator\WithdrawalFinancialController::invoiceSummary/$1', ['filter' => $withdrawalFinancialFilter]);
// API for report card meta (students, class sections, school years)
$routes->get('api/printables/report-card/meta', 'View\ReportCardsController::reportCardMeta', ['filter' => 'auth']);
$routes->get('api/printables/report-card/completeness', 'View\ReportCardsController::reportCardCompleteness', ['filter' => 'auth']);
@@ -174,6 +198,18 @@ $routes->group('administrator/subject-curriculum', ['filter' => 'auth:update_cur
$routes->post('delete/(:num)', 'View\SubjectCurriculumController::delete/$1');
});
$routes->get('administrator/trophy', 'View\TrophyController::index', ['filter' => 'auth:admin|principal']);
$routes->get('administrator/trophy/winners', 'View\TrophyController::winners', ['filter' => 'auth:admin|principal']);
$routes->get('administrator/trophy/final', 'View\TrophyController::final', ['filter' => 'auth:admin|principal']);
// Certificates
$routes->get('administrator/certificates', 'View\CertificateController::index', ['filter' => 'auth:admin|principal']);
$routes->get('administrator/certificates/csrf-token', 'View\CertificateController::csrfToken', ['filter' => 'auth:admin|principal']);
$routes->post('administrator/certificates/generate', 'View\CertificateController::generate', ['filter' => 'auth:admin|principal']);
$routes->get('administrator/certificates/log', 'View\CertificateController::auditLog', ['filter' => 'auth:admin|principal']);
$routes->get('administrator/certificates/reprint/(:any)', 'View\CertificateController::reprint/$1', ['filter' => 'auth:read']);
$routes->get('verify/(:segment)', 'View\CertificateController::verify/$1');
/*
@@ -190,10 +226,11 @@ $routes->get('/classes', 'View\UserController::classes'); // Classes page route
$routes->get('/contact', 'View\UserController::contact'); // Contact Us page route
$routes->post('/user/login', 'AuthController::login');
$routes->post('api/login', 'AuthController::apiLogin');
$routes->get('admin/api/v1/admin/auth/me', 'AuthController::adminAuthMe');
$routes->get('/welcome_back', 'View\UserController::welcomeBack');
$routes->get('user/forgot_password', 'View\UserController::forgotPassword'); //display forgot password form
//$routes->post('user/forgot_password', 'View\UserController::processForgotPassword'); //process the password forgot request
$routes->post('user/forgot_password', 'View\UserController::processForgotPassword'); //process the password forgot request
$routes->get('login', 'AuthController::loginMask');
$routes->get(
@@ -241,19 +278,24 @@ $routes->post('administrator/remove_class_student', 'View\StudentController::rem
// Sections auto-distribution (admin)
$routes->get('administrator/sections/auto-distribute', 'View\StudentController::autoDistributePage');
$routes->post('administrator/sections/auto-distribute', 'View\StudentController::autoDistributeSections');
$routes->post('administrator/sections/distribution-draft/update', 'View\StudentController::updateDistributionDraft');
$routes->post('administrator/sections/distribution-candidate/update', 'View\StudentController::updateDistributionCandidate');
// Totals API for dashboard
$routes->get('administrator/sections/promotion-totals', 'View\StudentController::promotionTotalsApi');
$routes->get('refunds/list', 'View\RefundController::listRefunds');
$routes->post('refunds/request', 'View\RefundController::requestRefund');
$routes->post('refunds/approve/(:num)', 'View\RefundController::approveRefund/$1');
$routes->post('refunds/pay/(:num)', 'View\RefundController::payRefund/$1');
$routes->post('refunds/updateStatus/(:num)', 'View\RefundController::updateStatus/$1');
$routes->post('refunds/processRefunds', 'View\RefundController::processRefunds');
$routes->post('refunds/updateStatus', 'View\RefundController::updateStatus');
$routes->post('refunds/updatePayment', 'View\RefundController::updatePayment');
$routes->post('refunds/recalculateOverpayments', 'View\RefundController::recalculateOverpayments');
$routes->get('refunds/list', 'View\RefundController::listRefunds', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('refunds/request', 'View\RefundController::requestRefund', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
$routes->post('refunds/approve/(:num)', 'View\RefundController::approveRefund/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/pay/(:num)', 'View\RefundController::payRefund/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/reversePayout/(:num)', 'View\RefundController::reversePayout/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/updateStatus/(:num)', 'View\RefundController::updateStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/processRefunds', 'View\RefundController::processRefunds', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/updateStatus', 'View\RefundController::updateStatus', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/updatePayment', 'View\RefundController::updatePayment', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/recalculateOverpayments', 'View\RefundController::recalculateOverpayments', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('refunds/file/(:num)', 'View\RefundController::serveRefundFile/$1', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('refunds/file/(:num)/(:segment)', 'View\RefundController::serveRefundFile/$1/$2', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
@@ -271,7 +313,9 @@ $routes->get('ui/style', 'View\UiController::style');
//Timeout page after timeout
$routes->get('session/get-timeout-config', 'View\SessionTimeoutController::getTimeoutConfig');
$routes->get('session/check-timeout', 'View\SessionTimeoutController::checkTimeout');
$routes->post('session/ping-activity', 'View\SessionTimeoutController::pingActivity');
$routes->post('session/ping', 'View\SessionTimeoutController::pingActivity');
@@ -379,7 +423,7 @@ $routes->get('/teacher/exam-drafts/status', 'View\ExamDraftController::teacherSt
$routes->get('teacher/print-requests', 'PrintRequests::teacher_index', ['filter' => 'auth:teacher,teacher_assistant']);
$routes->get('admin/print-requests', 'PrintRequests::admin_index', ['filter' => 'auth:admin']);
$routes->get('admin/print-requests', 'PrintRequests::admin_index', ['filter' => 'auth:admin|principal']);
$routes->post('print-requests/create', 'PrintRequests::create', ['filter' => 'auth:teacher,teacher_assistant']);
$routes->post('print-requests/create-copy', 'PrintRequests::createCopy', ['filter' => 'auth:teacher,teacher_assistant']);
@@ -398,10 +442,7 @@ $routes->get('exam-drafts/files/final/(:segment)', 'View\FilesController::examDr
$routes->get('teacher/progress', 'ClassProgressController::history', ['filter' => 'auth:teacher,teacher_assistant']);
$routes->get('teacher/progress/submit', 'ClassProgressController::create', ['filter' => 'auth:teacher,teacher_assistant']);
$routes->post('teacher/progress/store', 'ClassProgressController::store', ['filter' => 'auth:teacher,teacher_assistant']);
$routes->get('teacher/progress/history', 'ClassProgressController::history', ['filter' => 'auth:teacher,teacher_assistant']);
@@ -415,18 +456,18 @@ $routes->get('parent/progress/view/(:num)', 'ParentProgressController::view/$1',
$routes->get('parent/progress/attachment/(:num)', 'ParentProgressController::attachment/$1', ['filter' => 'auth:parent']);
$routes->get('parent/progress/attachment-file/(:num)', 'ParentProgressController::attachmentFile/$1', ['filter' => 'auth:parent']);
$routes->get('admin/progress', 'AdminProgressController::index', ['filter' => 'auth:admin']);
$routes->get('admin/progress/view/(:num)', 'AdminProgressController::view/$1', ['filter' => 'auth:admin']);
$routes->get('admin/progress/attachment/(:num)', 'AdminProgressController::attachment/$1', ['filter' => 'auth:admin']);
$routes->get('admin/progress/attachment-file/(:num)', 'AdminProgressController::attachmentFile/$1', ['filter' => 'auth:admin']);
$routes->get('admin/progress', 'AdminProgressController::index', ['filter' => 'auth:admin|principal']);
$routes->get('admin/progress/view/(:num)', 'AdminProgressController::view/$1', ['filter' => 'auth:admin|principal']);
$routes->get('admin/progress/attachment/(:num)', 'AdminProgressController::attachment/$1', ['filter' => 'auth:admin|principal']);
$routes->get('admin/progress/attachment-file/(:num)', 'AdminProgressController::attachmentFile/$1', ['filter' => 'auth:admin|principal']);
$routes->get('/teacher/calendar', 'View\SchoolCalendarController::calendarTeacherView');
$routes->get('/teacher/absence', 'View\TeacherController::absenceForm', ['filter' => 'auth:teacher,teacher_assistant']);
$routes->post('/teacher/absence', 'View\TeacherController::submitAbsence', ['filter' => 'auth:teacher,teacher_assistant']);
// Admin self-service staff absence (same features as teacher page)
$routes->get('/administrator/absence', 'View\AdministratorController::absenceFormAdmin', ['filter' => 'auth:admin']);
$routes->post('/administrator/absence', 'View\AdministratorController::submitAbsenceAdmin', ['filter' => 'auth:admin']);
$routes->get('/administrator/absence', 'View\AdministratorController::absenceFormAdmin', ['filter' => 'auth:admin|principal']);
$routes->post('/administrator/absence', 'View\AdministratorController::submitAbsenceAdmin', ['filter' => 'auth:admin|principal']);
$routes->get('/timeoff/notify/(:segment)', 'TimeOffNotificationController::notify/$1');
$routes->get('/teacher/showupdate_attendance', 'View\AttendanceController::showUpdateAttendanceForm');
$routes->post('/teacher/update_attendance', 'View\AttendanceController::updateAttendance');
@@ -511,83 +552,81 @@ $routes->post('grading/below-60/email', 'View\GradingController::sendBelowSixtyE
$routes->post('grading/below-60/status', 'View\GradingController::updateBelowSixtyStatus', ['filter' => 'auth:read']);
$routes->get('grading/below-60/schedule', 'View\GradingController::scheduleBelowSixty', ['filter' => 'auth:read']);
$routes->post('grading/below-60/schedule', 'View\GradingController::saveBelowSixtyMeeting', ['filter' => 'auth:read']);
$routes->get('grading/decisions', 'View\GradingController::allDecisions', ['filter' => 'auth:read']);
$routes->post('grading/decisions/generate', 'View\GradingController::generateAllDecisions', ['filter' => 'auth:read']);
$routes->get('grading/below-60/decisions', 'View\GradingController::belowSixtyDecisions', ['filter' => 'auth:read']);
$routes->post('grading/below-60/decisions/save', 'View\GradingController::saveBelowSixtyDecision', ['filter' => 'auth:read']);
$routes->get('grading/below-60/decisions/student-details', 'View\GradingController::studentDecisionDetails', ['filter' => 'auth:read']);
$routes->get(
'grading/below-60/decisions/email/preview',
'View\GradingController::previewBelowSixtyDecisionEmail',
['filter' => 'auth:read']
);
$routes->post(
'grading/below-60/decisions/email',
'View\GradingController::sendBelowSixtyDecisionEmail',
['filter' => 'auth:read']
);
// Final part
$routes->get('grading/(:segment)/(:num)/(:num)', 'View\GradingController::show/$1/$2/$3');
$routes->post('grading/update', 'View\GradingController::update');
$routes->get('grading', 'View\GradingController::grading');
$routes->post('grading/toggle-score-lock', 'View\GradingController::toggleScoreLock', ['filter' => 'auth:read']);
$routes->get('grading/toggle-score-lock', static function () {
return 'GET route hit. Something is calling this route incorrectly.';
});
$routes->post('grading/lock-all-scores', 'View\GradingController::lockAllScores', ['filter' => 'auth:read']);
$routes->post('grading/release-scores', 'View\GradingController::toggleParentScoresRelease', ['filter' => 'auth:read']);
$routes->post('grading/refresh-semester-scores', 'View\GradingController::refreshSemesterScores', ['filter' => 'auth:read']);
// Event admin routes
$routes->get('administrator/events', 'View\EventController::index');
$routes->get('administrator/events/create', 'View\EventController::create'); // show create form
$routes->post('administrator/events/create', 'View\EventController::create'); // handle event create submission
// Route to show form and handle POST in same controller method
$routes->match(['get', 'post'], 'administrator/events/edit/(:num)', 'View\EventController::edit/$1');
$routes->post('administrator/events/delete/(:num)', 'View\EventController::delete/$1'); // handle delete
$routes->post('payment/event_charges', 'View\EventController::eventUpdate');
$routes->get('administrator/events', 'View\EventController::index', ['filter' => 'auth:admin|principal']);
$routes->get('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin|principal']);
$routes->post('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin|principal']);
$routes->match(['get', 'post'], 'administrator/events/edit/(:num)', 'View\EventController::edit/$1', ['filter' => 'auth:admin|principal']);
$routes->post('administrator/events/delete/(:num)', 'View\EventController::delete/$1', ['filter' => 'auth:admin|principal']);
$routes->post('payment/event_charges', 'View\EventController::eventUpdate', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
// Parent event participation
$routes->get('administrator/event-charges', 'View\EventController::eventShow');
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1');
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1');
$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1');
$routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges');
$routes->get('administrator/event-charges', 'View\EventController::eventShow', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('administrator/event-charges/pdf', 'View\EventController::eventChargesPdf', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('parent/events', 'View\ParentController::parentEventPage', ['filter' => 'auth:parent']);
// parent event participation page
$routes->post('parent/updateParticipation', 'View\ParentController::updateParticipation'); // handle parent participation updates
$routes->post('parent/updateParticipation', 'View\ParentController::updateParticipation', ['filter' => 'auth:parent']);
// Web View Routes for Invoices
$routes->get('invoices/getByParent/(:num)', 'View\InvoiceController::getByParent/$1');
$routes->get('invoices/create', 'View\InvoiceController::create');
$routes->post('invoices/updateStatus/(:num)', 'View\InvoiceController::updateStatus/$1');
$routes->get('invoices/getByParent/(:num)', 'View\InvoiceController::getByParent/$1', ['filter' => 'auth:parent|view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('invoices/create', 'View\InvoiceController::create', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
$routes->post('invoices/updateStatus/(:num)', 'View\InvoiceController::updateStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
// app/Config/Routes.php
// app/Config/Routes.php
$routes->group('payment', ['filter' => 'auth'], static function ($routes) {
// Read
$routes->get('manual_pay', 'View\PaymentController::manualPaySearch', ['filter' => 'auth:read']);
$routes->get('manual_pay_suggest', 'View\PaymentController::manualPaySuggest', ['filter' => 'auth:read']);
$routes->get('manual_pay', 'View\PaymentController::manualPaySearch', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('manual_pay_suggest', 'View\PaymentController::manualPaySuggest', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
// Create
$routes->post('manual_pay', 'View\PaymentController::manualPayUpdate', ['filter' => 'auth:create']);
$routes->post('manual_pay', 'View\PaymentController::manualPayUpdate', ['filter' => 'auth:create_payment|create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
// Update
$routes->post('manual_pay_edit', 'View\PaymentController::manualPayEdit', ['filter' => 'auth:update']);
$routes->post('manual_pay_update', 'View\PaymentController::manualPayUpdate', ['filter' => 'auth:update']);
$routes->post('manual_pay_edit', 'View\PaymentController::manualPayEdit', ['filter' => 'auth:update_payment|update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('manual_pay_update', 'View\PaymentController::manualPayUpdate', ['filter' => 'auth:create_payment|create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
// Read (serve files)
$routes->get('serveCheckFile/(:any)/(:any)', 'View\PaymentController::serveCheckFile/$1/$2', ['filter' => 'auth:read']);
$routes->get('serveCheckFile/(:any)', 'View\PaymentController::serveCheckFile/$1', ['filter' => 'auth:read']);
$routes->get('file/(:num)/(:segment)', 'View\PaymentController::servePaymentFile/$1/$2', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('file/(:num)', 'View\PaymentController::servePaymentFile/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
});
// Optional: if someone opens it in the browser via GET, redirect to the search page:
$routes->get('payment/manual_pay_update', function () {
return redirect()->to(site_url('payment/manual_pay'));
});
/*
$routes->group('admin/charges', ['filter' => 'auth'], static function($routes) {
$routes->get('/', 'ExtraChargesController::index');
$routes->get('parents', 'ExtraChargesController::parentOptions'); // <-- for Select2
$routes->post('store', 'ExtraChargesController::store');
$routes->post('update/(:num)', 'ExtraChargesController::update/$1');
$routes->post('void/(:num)', 'ExtraChargesController::void/$1');
});
*/
$routes->group('admin', ['filter' => 'auth'], static function ($routes) {
$routes->group('charges', static function ($routes) {
// Serve HTML page from ::page, keep ::index for API JSON
@@ -595,10 +634,12 @@ $routes->group('admin', ['filter' => 'auth'], static function ($routes) {
$routes->get('parents', [ExtraChargesController::class, 'parentOptions']);
$routes->get('invoices', [ExtraChargesController::class, 'invoicesForParent']); // <-- AJAX endpoint
$routes->get('list', [ExtraChargesController::class, 'apiList']);
$routes->post('store', [ExtraChargesController::class, 'store']);
$routes->post('update/(:num)', [ExtraChargesController::class, 'update/$1']);
$routes->post('void/(:num)', [ExtraChargesController::class, 'void/$1']);
$routes->post('reverse/(:num)', [ExtraChargesController::class, 'reverse/$1']);
$routes->post('store', [ExtraChargesController::class, 'store'], ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
$routes->post('update/(:num)', [ExtraChargesController::class, 'update/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('approve/(:num)', [ExtraChargesController::class, 'approve/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('apply/(:num)', [ExtraChargesController::class, 'apply/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('void/(:num)', [ExtraChargesController::class, 'void/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('reverse/(:num)', [ExtraChargesController::class, 'reverse/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
});
});
@@ -606,14 +647,19 @@ $routes->group('admin', ['filter' => 'auth'], static function ($routes) {
$routes->group('admin', ['filter' => 'auth'], static function ($routes) {
$routes->get('broadcast-email', 'View\BroadcastEmailController::index');
$routes->post('broadcast-email/send', 'View\BroadcastEmailController::send');
$routes->post('broadcast-email/upload-image', 'View\BroadcastEmailController::uploadImage');
});
$routes->post('admin/broadcast-email/upload-image', 'View\BroadcastEmailController::uploadImage');
$routes->get('payment/financial_report', 'View\FinancialController::financialReport');
$routes->get('financial-report/financialReportSummary', 'View\FinancialController::financialReportSummary');
$routes->get('payment/download_csv', 'View\FinancialController::downloadCsv');
$routes->get('reports/downloadFinancialReport', 'View\FinancialController::downloadFinancialReport');
$routes->get('payment/financial_report', 'View\FinancialController::financialReport', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('financial-report/financialReportSummary', 'View\FinancialController::financialReportSummary', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('financial-report/downloadSummaryCsv', 'View\FinancialController::downloadSummaryCsv', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('financial-report/downloadSummaryAllDetailsCsv', 'View\FinancialController::downloadSummaryAllDetailsCsv', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('payment/download_csv', 'View\FinancialController::downloadCsv', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('administrator/tuition-forecast', 'View\TuitionForecastController::index', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->post('administrator/tuition-forecast/calculate', 'View\TuitionForecastController::calculate', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('administrator/tuition-forecast/export', 'View\TuitionForecastController::exportCsv', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('reports/downloadFinancialReport', 'View\FinancialController::downloadFinancialReport', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
// Financial APIs (JSON)
$routes->get('api/financial/report', 'View\FinancialController::financialReportData', ['filter' => 'auth']);
$routes->get('api/financial/summary', 'View\FinancialController::financialReportSummaryData', ['filter' => 'auth']);
@@ -623,43 +669,44 @@ $routes->get('payment/unpaid-parents', 'View\FinancialController::unpaidParents'
$routes->get('api/financial/unpaid-parents', 'View\FinancialController::unpaidParents', ['filter' => 'auth:view_invoice']);
$routes->get('expenses/index', 'View\ExpenseController::index');
$routes->get('expenses/create', 'View\ExpenseController::create');
$routes->post('expenses/store', 'View\ExpenseController::store');
$routes->post('expenses/updateStatus', 'View\ExpenseController::updateStatus');
$routes->get('expenses/index', 'View\ExpenseController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->get('expenses/create', 'View\ExpenseController::create', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('expenses/store', 'View\ExpenseController::store', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,create']);
$routes->post('expenses/updateStatus', 'View\ExpenseController::updateStatus', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('reimbursements/index', 'View\ReimbursementController::index');
$routes->get('reimbursements/create', 'View\ReimbursementController::create');
$routes->post('reimbursements/store', 'View\ReimbursementController::store');
$routes->get('reimbursements/under-processing', 'View\ReimbursementController::underProcessing');
$routes->post('reimbursements/mark-donation', 'View\ReimbursementController::markDonation');
$routes->post('reimbursements/batch/create', 'View\ReimbursementController::createBatch');
$routes->post('reimbursements/batch/update', 'View\ReimbursementController::updateBatchAssignment');
$routes->post('reimbursements/batch/lock', 'View\ReimbursementController::lockBatch');
$routes->post('reimbursements/batch/admin-file/upload', 'View\ReimbursementController::uploadBatchAdminFile');
$routes->get('reimbursements/batch/admin-file/(:segment)', 'View\ReimbursementController::serveAdminCheckFile/$1');
$routes->get('reimbursements/batch/admin-file/(:segment)/(:segment)', 'View\ReimbursementController::serveAdminCheckFile/$1/$2');
$routes->post('reimbursements/batch/send', 'View\ReimbursementController::sendBatchEmail');
$routes->get('reimbursements/batch/export', 'View\ReimbursementController::exportBatch');
$routes->post('reimbursements/process', 'View\ReimbursementController::process');
$routes->get('reimbursements/export', 'View\ReimbursementController::export');
$routes->get('reimbursements', 'View\ReimbursementController::index');
$routes->get('reimbursements/index', 'View\ReimbursementController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->get('reimbursements/create', 'View\ReimbursementController::create', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('reimbursements/store', 'View\ReimbursementController::store', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,create']);
$routes->get('reimbursements/under-processing', 'View\ReimbursementController::underProcessing', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('reimbursements/mark-donation', 'View\ReimbursementController::markDonation', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('reimbursements/batch/create', 'View\ReimbursementController::createBatch', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,create']);
$routes->post('reimbursements/batch/update', 'View\ReimbursementController::updateBatchAssignment', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('reimbursements/batch/lock', 'View\ReimbursementController::lockBatch', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('reimbursements/batch/admin-file/upload', 'View\ReimbursementController::uploadBatchAdminFile', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('reimbursements/batch/admin-file/(:segment)', 'View\ReimbursementController::serveAdminCheckFile/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->get('reimbursements/batch/admin-file/(:segment)/(:segment)', 'View\ReimbursementController::serveAdminCheckFile/$1/$2', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('reimbursements/batch/send', 'View\ReimbursementController::sendBatchEmail', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('reimbursements/batch/export', 'View\ReimbursementController::exportBatch', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('reimbursements/process', 'View\ReimbursementController::process', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('reimbursements/reverse/(:num)', 'View\ReimbursementController::reverse/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('reimbursements/export', 'View\ReimbursementController::export', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->get('reimbursements', 'View\ReimbursementController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
// Health check (upload dirs + DB timezone columns)
$routes->get('admin/health', 'View\HealthController::index');
$routes->get('admin/health', 'View\HealthController::index', ['filter' => 'auth:admin|principal']);
//Notifications
$routes->get('notifications/active', 'View\NotificationsController::listActive');
$routes->get('api/notifications/active', 'View\NotificationsController::activeNotificationsData');
$routes->get('notifications/deleted', 'View\NotificationsController::listDeleted');
$routes->get('api/notifications/deleted', 'View\NotificationsController::deletedNotificationsData');
$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1');
$routes->get('notifications/active', 'View\NotificationsController::listActive', ['filter' => 'auth:admin|principal']);
$routes->get('api/notifications/active', 'View\NotificationsController::activeNotificationsData', ['filter' => 'auth:admin|principal']);
$routes->get('notifications/deleted', 'View\NotificationsController::listDeleted', ['filter' => 'auth:admin|principal']);
$routes->get('api/notifications/deleted', 'View\NotificationsController::deletedNotificationsData', ['filter' => 'auth:admin|principal']);
$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1', ['filter' => 'auth:admin|principal']);
//$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1');
//$routes->get('notifications/mark-read/(:num)', 'View\NotificationsController::markAsRead/$1');
$routes->get('/administrator/notifications_alerts', 'View\AdministratorController::notificationsAlerts');
$routes->post('/administrator/notifications_alerts/save', 'View\AdministratorController::saveNotificationSubjects');
$routes->get('/administrator/print-notifications', 'View\AdministratorController::printNotificationRecipients');
$routes->post('/administrator/print-notifications/save', 'View\AdministratorController::savePrintNotificationRecipients');
$routes->get('/administrator/notifications_alerts', 'View\AdministratorController::notificationsAlerts', ['filter' => 'auth:admin|principal']);
$routes->post('/administrator/notifications_alerts/save', 'View\AdministratorController::saveNotificationSubjects', ['filter' => 'auth:admin|principal']);
$routes->get('/administrator/print-notifications', 'View\AdministratorController::printNotificationRecipients', ['filter' => 'auth:admin|principal']);
$routes->post('/administrator/print-notifications/save', 'View\AdministratorController::savePrintNotificationRecipients', ['filter' => 'auth:admin|principal']);
$routes->get(
@@ -692,32 +739,24 @@ $routes->post(
'View\InvoiceController::generateInvoice',
['filter' => 'auth:create_invoice']
);
$routes->get('/parent/invoice_payment', 'View\InvoiceController::invoicePayment'); // Route for invoice payment page
$routes->get('invoice/pay/(:num)', 'View\InvoiceController::payInvoice/$1'); // Route for paying the invoice
$routes->get('invoices', 'View\InvoiceController::index'); // Default route for invoices
$routes->get('invoices/(:any)', 'View\InvoiceController::index/$1'); // Route with school year filter
$routes->get('invoices/recalculate/(:segment)', 'View\PaymentController::recalculateInvoiceByNumber/$1', ['filter' => 'auth:update_invoice']); // Recalculate invoice totals/balance
$routes->get('/parent/invoice_payment', 'View\InvoiceController::invoicePayment', ['filter' => 'auth:parent']); // Route for invoice payment page
$routes->get('/parent/financial-aid', 'View\ParentFinancialAidController::index', ['filter' => 'auth:parent']);
$routes->post('/parent/financial-aid', 'View\ParentFinancialAidController::submit', ['filter' => 'auth:parent']);
$routes->get('invoice/pay/(:num)', 'View\InvoiceController::payInvoice/$1', ['filter' => 'auth:view_invoice|view_payment|administrator|administrative staff|principal|parent']); // Route for paying the invoice
$routes->get('invoices', 'View\InvoiceController::index', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); // Default route for invoices
$routes->get('invoices/(:any)', 'View\InvoiceController::index/$1', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); // Route with school year filter
$routes->post('invoices/recalculate/(:segment)', 'View\PaymentController::recalculateInvoiceByNumber/$1', ['filter' => 'auth:update_invoice']); // Recalculate invoice totals/balance
$routes->get('invoice/pdf/(:num)', 'View\InvoiceController::generatePdfInvoice/$1', ['filter' => 'auth:invoice_pdf,read']);
// Web View Routes for Payments
$routes->get('payments/getByParent/(:num)', 'View\PaymentController::getByParent/$1');
$routes->get('payments/create', 'View\PaymentController::create');
$routes->post('payments/updateBalance/(:num)', 'View\PaymentController::updateBalance/$1');
$routes->get('payments/getByParent/(:num)', 'View\PaymentController::getByParent/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('payments/create', 'View\PaymentController::create', ['filter' => 'auth:create_payment|create_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('payments/updateBalance/(:num)', 'View\PaymentController::updateBalance/$1', ['filter' => 'auth:update_payment|update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
// Web View Routes for Payment Transactions
$routes->get('payment_transactions/getByPayment/(:num)', 'View\PaymentTransactionController::getByPayment/$1');
$routes->get('payment_transactions/create', 'View\PaymentTransactionController::create');
$routes->post('payment_transactions/updateStatus/(:num)', 'View\PaymentTransactionController::updateStatus/$1');
// Routes for PayPal integration
$routes->get('payments/createPaypalPayment/(:num)', 'View\PaymentController::createPaypalPayment/$1');
$routes->get('payments/executePaypalPayment', 'View\PaymentController::executePaypalPayment');
$routes->get('payments/cancelPaypalPayment', 'View\PaymentController::cancelPaypalPayment');
$routes->get('payments/view/(:num)', 'View\PaymentController::viewPayment/$1');
$routes->post('payments/createPaypalPayment/(:num)', 'View\PaymentController::createPaypalPayment/$1');
$routes->get('payment/get_enrolled_students/(:num)', 'View\PaymentController::getEnrolledStudents/$1');
// Routes for payment pages
$routes->get('payments/view/(:num)', 'View\PaymentController::viewPayment/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('payment/get_enrolled_students/(:num)', 'View\PaymentController::getEnrolledStudents/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
// Payment Notification Management
$routes->get('payment/notification_management', 'View\PaymentNotificationController::index', ['filter' => 'auth:view_invoice']);
@@ -739,35 +778,34 @@ $routes->get('administrator/userSearch', 'View\AdministratorController::userSear
$routes->get('/administrator/student_profiles', 'View\AdministratorController::studentProfiles');
$routes->get('/administrator/parent_profiles', 'View\AdministratorController::parentProfiles');
$routes->get('/administrator/removed_students', 'View\StudentController::removedStudents');
$routes->get('/administrator/teacher-submissions', 'View\AdministratorController::teacherSubmissionsReport', ['filter' => 'auth:admin']);
$routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorController::sendTeacherSubmissionNotifications', ['filter' => 'auth:admin']);
$routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin']);
$routes->post('/administrator/exam-drafts/review', 'View\ExamDraftController::adminReview', ['filter' => 'auth:admin']);
$routes->post('/administrator/exam-drafts/upload-legacy', 'View\ExamDraftController::adminUploadLegacy', ['filter' => 'auth:admin']);
$routes->get('/principal/exam-drafts', 'View\ExamDraftController::principalIndex', ['filter' => 'auth:admin']);
$routes->post('/principal/exam-drafts/review', 'View\ExamDraftController::principalReview', ['filter' => 'auth:admin']);
$routes->post('/principal/exam-drafts/upload-legacy', 'View\ExamDraftController::principalUploadLegacy', ['filter' => 'auth:admin']);
$routes->get('/administrator/teacher-submissions', 'View\AdministratorController::teacherSubmissionsReport', ['filter' => 'auth:admin|principal']);
$routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorController::sendTeacherSubmissionNotifications', ['filter' => 'auth:admin|principal']);
$routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin|principal']);
$routes->post('/administrator/exam-drafts/review', 'View\ExamDraftController::adminReview', ['filter' => 'auth:admin|principal']);
$routes->post('/administrator/exam-drafts/upload-legacy', 'View\ExamDraftController::adminUploadLegacy', ['filter' => 'auth:admin|principal']);
$routes->get('/principal/exam-drafts', 'View\ExamDraftController::principalIndex', ['filter' => 'auth:admin|principal']);
$routes->post('/principal/exam-drafts/review', 'View\ExamDraftController::principalReview', ['filter' => 'auth:admin|principal']);
$routes->post('/principal/exam-drafts/upload-legacy', 'View\ExamDraftController::principalUploadLegacy', ['filter' => 'auth:admin|principal']);
/*
* --------------------------------------------------------------------
* routes for View\MessagesController
* --------------------------------------------------------------------
*/
$routes->get('messages', 'View\MessagesController::index');
$routes->post('messages/send', 'View\MessagesController::send');
$routes->get('/messages/inbox', 'View\MessagesController::inbox');
$routes->get('/messages/sent', 'View\MessagesController::sent');
$routes->get('/messages/drafts', 'View\MessagesController::drafts');
$routes->get('/messages/trash', 'View\MessagesController::trash');
$routes->get('messages', 'View\MessagesController::index', ['filter' => 'auth']);
$routes->post('messages/send', 'View\MessagesController::send', ['filter' => 'auth']);
$routes->get('/messages/inbox', 'View\MessagesController::inbox', ['filter' => 'auth']);
$routes->get('/messages/sent', 'View\MessagesController::sent', ['filter' => 'auth']);
$routes->get('/messages/drafts', 'View\MessagesController::drafts', ['filter' => 'auth']);
$routes->get('/messages/trash', 'View\MessagesController::trash', ['filter' => 'auth']);
// Additional routes for handling actions like viewing, sending, deleting, etc.
$routes->get('/messages/view/(:num)', 'View\MessagesController::view/$1');
$routes->post('/messages/send', 'View\MessagesController::send');
$routes->get('/messages/delete/(:num)', 'View\MessagesController::delete/$1');
$routes->get('/messages/deletePermanent/(:num)', 'View\MessagesController::deletePermanent/$1');
$routes->get('/messages/restore/(:num)', 'View\MessagesController::restore/$1');
$routes->get('/messages/getRecipients/(:any)', 'View\MessagesController::getRecipients/$1');
$routes->get('/messages/view/(:num)', 'View\MessagesController::view/$1', ['filter' => 'auth']);
$routes->post('/messages/send', 'View\MessagesController::send', ['filter' => 'auth']);
$routes->get('/messages/delete/(:num)', 'View\MessagesController::delete/$1', ['filter' => 'auth']);
$routes->get('/messages/deletePermanent/(:num)', 'View\MessagesController::deletePermanent/$1', ['filter' => 'auth']);
$routes->get('/messages/restore/(:num)', 'View\MessagesController::restore/$1', ['filter' => 'auth']);
$routes->get('/messages/getRecipients/(:any)', 'View\MessagesController::getRecipients/$1', ['filter' => 'auth']);
/*
* --------------------------------------------------------------------
@@ -812,7 +850,7 @@ $routes->group('api/time', ['namespace' => 'App\Controllers\Api'], static functi
* routes for View\SchoolCalendarController
* --------------------------------------------------------------------
*/
$routes->group('administrator', ['namespace' => 'App\Controllers'], function ($routes) {
$routes->group('administrator', ['namespace' => 'App\Controllers', 'filter' => 'auth:admin|administrator|principal|vice_principal|vice principal'], function ($routes) {
$routes->get('calendar_view', 'View\SchoolCalendarController::index');
$routes->get('calendar_edit/(:num)', 'View\SchoolCalendarController::edit/$1');
$routes->get('calendar_delete/(:num)', 'View\SchoolCalendarController::delete/$1');
@@ -829,52 +867,53 @@ $routes->group('administrator', ['namespace' => 'App\Controllers'], function ($r
* routes for Parent Pages
* --------------------------------------------------------------------
*/
$routes->get('/parent/add_second_parent', 'View\ParentController::addSecondParent');
$routes->get('/parent/viewSecondParent', 'View\ParentController::viewSecondParent');
$routes->post('/parent/saveSecondParent', 'View\ParentController::saveSecondParent');
$routes->get('/parent/add_second_parent', 'View\ParentController::addSecondParent', ['filter' => 'auth:parent']);
$routes->get('/parent/viewSecondParent', 'View\ParentController::viewSecondParent', ['filter' => 'auth:parent']);
$routes->post('/parent/saveSecondParent', 'View\ParentController::saveSecondParent', ['filter' => 'auth:parent']);
$routes->get('/parent/enroll_classes', 'View\ParentController::enrollClasses');
$routes->post('/parent/enroll_classes_handler', 'View\ParentController::enrollClassesHandler');
$routes->get('/parent/enroll_success', 'View\ParentController::enrollSuccess');
$routes->get('/parent/enroll_failure', 'View\ParentController::enrollFailure');
$routes->get('/parent/payment', 'View\ParentController::viewPayments');
$routes->get('/parent/calendar', 'View\SchoolCalendarController::calendarParentView');
$routes->get('/parent/attendance', 'View\ParentController::attendance');
$routes->get('/parent/enroll_classes', 'View\ParentController::enrollClasses', ['filter' => 'auth:parent']);
$routes->post('/parent/enroll_classes_handler', 'View\ParentController::enrollClassesHandler', ['filter' => 'auth:parent']);
$routes->get('/parent/enrollment_eligibility_refresh', 'View\ParentController::enrollmentEligibilityRefresh', ['filter' => 'auth:parent']);
$routes->get('/parent/enroll_success', 'View\ParentController::enrollSuccess', ['filter' => 'auth:parent']);
$routes->get('/parent/enroll_failure', 'View\ParentController::enrollFailure', ['filter' => 'auth:parent']);
$routes->get('/parent/payment', 'View\ParentController::viewPayments', ['filter' => 'auth:parent']);
$routes->get('/parent/calendar', 'View\SchoolCalendarController::calendarParentView', ['filter' => 'auth:parent']);
$routes->get('/parent/attendance', 'View\ParentController::attendance', ['filter' => 'auth:parent']);
// Parent reporting: absence/late/early dismissal
$routes->get('parent/report-attendance', 'View\ParentAttendanceReportController::form');
$routes->post('parent/report-attendance', 'View\ParentAttendanceReportController::submit');
$routes->post('parent/report-attendance/update', 'View\ParentAttendanceReportController::update');
$routes->get('attendance/parent-reports', 'View\ParentAttendanceReportController::list');
$routes->get('parent/report-attendance', 'View\ParentAttendanceReportController::form', ['filter' => 'auth:parent']);
$routes->post('parent/report-attendance', 'View\ParentAttendanceReportController::submit', ['filter' => 'auth:parent']);
$routes->post('parent/report-attendance/update', 'View\ParentAttendanceReportController::update', ['filter' => 'auth:parent']);
$routes->get('attendance/parent-reports', 'View\ParentAttendanceReportController::list', ['filter' => 'auth:admin|principal']);
// Admin/Teacher: early dismissal page by month
$routes->get('attendance/early-dismissals', 'View\ParentAttendanceReportController::earlyDismissals');
$routes->get('attendance/early-dismissals', 'View\ParentAttendanceReportController::earlyDismissals', ['filter' => 'auth:admin|principal']);
// Admin/Teacher: add early dismissal
$routes->get('attendance/early-dismissals/new', 'View\ParentAttendanceReportController::addEarlyDismissalForm');
$routes->post('attendance/early-dismissals', 'View\ParentAttendanceReportController::saveEarlyDismissal');
$routes->post('attendance/early-dismissals/signature', 'View\ParentAttendanceReportController::uploadEarlyDismissalSignature');
$routes->get('attendance/early-dismissals/new', 'View\ParentAttendanceReportController::addEarlyDismissalForm', ['filter' => 'auth:admin|principal']);
$routes->post('attendance/early-dismissals', 'View\ParentAttendanceReportController::saveEarlyDismissal', ['filter' => 'auth:admin|principal']);
$routes->post('attendance/early-dismissals/signature', 'View\ParentAttendanceReportController::uploadEarlyDismissalSignature', ['filter' => 'auth:admin|principal']);
// Parent report: client-side check API
$routes->post('api/parent/report-attendance/check', 'View\ParentAttendanceReportController::checkExisting');
$routes->post('api/parent/report-attendance/check', 'View\ParentAttendanceReportController::checkExisting', ['filter' => 'auth:parent']);
$routes->get('/support', 'View\StatsController::support');
$routes->get('no-kids', 'View\ParentController::noKids');
$routes->match(['get', 'post'], 'parent/edit-student/(:num)', 'View\ParentController::editStudent/$1');
$routes->get('parent/edit-all-students', 'View\ParentController::showEditAllStudentsModal');
$routes->post('parent/edit-all-students', 'View\ParentController::updateAllStudents');
$routes->get('no-kids', 'View\ParentController::noKids', ['filter' => 'auth:parent']);
$routes->match(['get', 'post'], 'parent/edit-student/(:num)', 'View\ParentController::editStudent/$1', ['filter' => 'auth:parent']);
$routes->get('parent/edit-all-students', 'View\ParentController::showEditAllStudentsModal', ['filter' => 'auth:parent']);
$routes->post('parent/edit-all-students', 'View\ParentController::updateAllStudents', ['filter' => 'auth:parent']);
// Show registration form with dynamic student and emergency contact blocks
$routes->get('/parent/register_student', 'View\ParentController::registerStudentForm');
$routes->get('/parent/child_register', 'View\ParentController::registerKidCheck');
$routes->get('/parent/register_student', 'View\ParentController::registerStudentForm', ['filter' => 'auth:parent']);
$routes->get('/parent/child_register', 'View\ParentController::registerKidCheck', ['filter' => 'auth:parent']);
// Handle form submission (students + emergency contacts)
$routes->post('/parent/register_student/save', 'View\ParentController::saveStudentRegistration');
$routes->post('/parent/update_student/(:num)', 'View\ParentController::updateStudent/$1');
$routes->get('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1');
$routes->post('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1');
$routes->post('/parent/delete_student/(:num)', 'View\ParentController::deleteStudent/$1');
$routes->post('/parent/register_student/save', 'View\ParentController::saveStudentRegistration', ['filter' => 'auth:parent']);
$routes->post('/parent/update_student/(:num)', 'View\ParentController::updateStudent/$1', ['filter' => 'auth:parent']);
$routes->get('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1', ['filter' => 'auth:parent']);
$routes->post('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1', ['filter' => 'auth:parent']);
$routes->post('/parent/delete_student/(:num)', 'View\ParentController::deleteStudent/$1', ['filter' => 'auth:parent']);
$routes->get('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1');
$routes->post('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1');
$routes->get('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1', ['filter' => 'auth:parent']);
$routes->post('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1', ['filter' => 'auth:parent']);
/*management navigation bar*/
@@ -885,8 +924,12 @@ $routes->get('nav-builder/delete/(:num)', 'View\NavBuilderController::delete/$1'
$routes->post('nav-builder/reorder', 'View\NavBuilderController::reorder', ['filter' => 'auth']);
// Teacher book distribution is the only inventory path teachers may access directly.
$routes->get('inventory/books/distribute', 'View\InventoryController::teacherDistributeForm', ['filter' => 'auth:view_inventory|update_inventory|administrator|administrative staff|principal|teacher|teacher_assistant']);
$routes->post('inventory/books/distribute', 'View\InventoryController::teacherDistributeStore', ['filter' => 'auth:view_inventory|update_inventory|administrator|administrative staff|principal|teacher|teacher_assistant,update']);
// Inventory routes
$routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
$routes->group('inventory', ['filter' => 'auth:view_inventory|administrator|administrative staff|principal'], static function ($routes) {
/** ---------------------------
* Movements (put FIRST)
@@ -894,11 +937,11 @@ $routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
$routes->group('movements', static function ($routes) {
$routes->get('/', 'View\InventoryController::movementsIndex'); // /inventory/movements
$routes->get('create', 'View\InventoryController::movementsCreate'); // /inventory/movements/create
$routes->post('store', 'View\InventoryController::movementsStore'); // /inventory/movements/store
$routes->post('store', 'View\InventoryController::movementsStore', ['filter' => 'auth:create_inventory|administrator|administrative staff|principal,create']); // /inventory/movements/store
$routes->get('edit/(:num)', 'View\InventoryController::movementsEdit/$1'); // /inventory/movements/edit/123
$routes->post('update/(:num)', 'View\InventoryController::movementsUpdate/$1'); // /inventory/movements/update/123
$routes->post('delete/(:num)', 'View\InventoryController::movementsDelete/$1'); // /inventory/movements/delete/123
$routes->post('bulk-delete', 'View\InventoryController::movementsBulkDelete');
$routes->post('update/(:num)', 'View\InventoryController::movementsUpdate/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']); // /inventory/movements/update/123
$routes->post('delete/(:num)', 'View\InventoryController::movementsDelete/$1', ['filter' => 'auth:delete_inventory|administrator|administrative staff|principal,delete']); // /inventory/movements/delete/123
$routes->post('bulk-delete', 'View\InventoryController::movementsBulkDelete', ['filter' => 'auth:delete_inventory|administrator|administrative staff|principal,delete']);
});
/** ---------------------------
@@ -907,19 +950,21 @@ $routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
// Summary & actions (keep these BEFORE the wildcard too)
$routes->get('summary-all', 'View\InventoryController::summaryAll');
$routes->get('adjust/(:num)', 'View\InventoryController::adjustForm/$1');
$routes->post('adjust/(:num)', 'View\InventoryController::adjustStore/$1');
$routes->get('books/distribute', 'View\InventoryController::teacherDistributeForm');
$routes->post('books/distribute', 'View\InventoryController::teacherDistributeStore');
$routes->post('adjust/(:num)', 'View\InventoryController::adjustStore/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
$routes->get('book-prices', 'View\InventoryController::bookPrices');
$routes->post('book-prices', 'View\InventoryController::updateBookPrices', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
$routes->get('classroom/audit/(:num)', 'View\InventoryController::auditClassroomForm/$1');
$routes->post('classroom/audit/(:num)', 'View\InventoryController::auditClassroomStore/$1');
$routes->post('classroom/audit/(:num)', 'View\InventoryController::auditClassroomStore/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
// CRUD for items/categories
$routes->get('create/(:alpha)', 'View\InventoryController::create/$1');
$routes->post('store', 'View\InventoryController::store');
$routes->post('store', 'View\InventoryController::store', ['filter' => 'auth:create_inventory|administrator|administrative staff|principal,create']);
$routes->get('edit/(:num)', 'View\InventoryController::edit/$1');
$routes->post('update/(:num)', 'View\InventoryController::update/$1');
$routes->post('delete/(:num)', 'View\InventoryController::delete/$1');
$routes->post('category/save', 'View\InventoryController::saveCategory');
$routes->post('update/(:num)', 'View\InventoryController::update/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
$routes->post('delete/(:num)', 'View\InventoryController::delete/$1', ['filter' => 'auth:delete_inventory|administrator|administrative staff|principal,delete']);
$routes->post('category/save', 'View\InventoryController::saveCategory', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
$routes->post('po/receive/(:num)', 'View\PurchaseOrderController::receive/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
$routes->post('po/receipt/reverse/(:num)', 'View\PurchaseOrderController::reverseReceipt/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
/** ---------------------------
* Index & section switcher
@@ -931,21 +976,9 @@ $routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
// $routes->get('(:alpha)', 'View\InventoryController::index/$1');
});
$routes->get('admin/enrollment/new-students', 'View\AdministratorController::showNewStudents', ['filter' => 'auth:view_new_students']);
//Paypal transactions
$routes->post('api/paypal-webhook', 'Api\PaypalWebhook::handle');
$routes->get('administrator/paypal_transactions', 'View\PaypalTransactionsController::index');
$routes->get('administrator/paypal_transactions/export', 'View\PaypalTransactionsController::exportCsv');
$routes->get('admin/paypal-transactions', 'View\PaypalTransactionsController::index');
$routes->get('admin/paypal-transactions/export', 'View\PaypalTransactionsController::exportCsv');
//Emergency Contact
$routes->get('administrator/emergency_contact', 'View\EmergencyContactController::index');
$routes->get('administrator/emergency_contact/edit/(:num)', 'View\EmergencyContactController::edit/$1');
@@ -991,28 +1024,29 @@ $routes->group('communications', static function ($routes) {
////////////////////////////////////////////////
// app/Config/Routes.php
$routes->group('api', static function ($routes) {
$routes->group('api', ['filter' => 'auth:admin|principal'], static function ($routes) {
// Families & Guardians API
$routes->get('students/(:num)/families', 'FamilyController::familiesByStudent/$1');
$routes->get('families/(:num)/guardians', 'FamilyController::guardiansByFamily/$1');
$routes->get('students/(:num)/families', 'View\FamilyController::familiesByStudent/$1');
$routes->get('families/(:num)/guardians', 'View\FamilyController::guardiansByFamily/$1');
});
$routes->group('families', static function ($routes) {
$routes->group('families', ['filter' => 'auth:admin|principal'], static function ($routes) {
// Allow GET for convenience in browser; keep POST for API/automation
$routes->match(['get', 'post'], 'bootstrap', 'FamilyController::bootstrap'); // protect with auth in production
$routes->post('attach-second-by-user', 'FamilyController::attachSecondByUser');
$routes->post('attach-second-by-email', 'FamilyController::attachSecondByEmail');
$routes->match(['get', 'post'], 'bootstrap', 'View\FamilyController::bootstrap'); // protect with auth in production
$routes->post('attach-second-by-user', 'View\FamilyController::attachSecondByUser');
$routes->post('attach-second-by-email', 'View\FamilyController::attachSecondByEmail');
$routes->post('set-primary-home', 'FamilyController::setPrimaryHome');
$routes->post('set-guardian-flags', 'FamilyController::setGuardianFlags');
$routes->post('unlink-guardian', 'FamilyController::unlinkGuardian');
$routes->post('unlink-student', 'FamilyController::unlinkStudent');
$routes->post('set-primary-home', 'View\FamilyController::setPrimaryHome');
$routes->post('set-guardian-flags', 'View\FamilyController::setGuardianFlags');
$routes->post('unlink-guardian', 'View\FamilyController::unlinkGuardian');
$routes->post('unlink-student', 'View\FamilyController::unlinkStudent');
});
// Allow GET for manual triggering from browser
$routes->match(['get', 'post'], 'families/import-legacy', 'FamilyController::importSecondParentsFromLegacy');
$routes->match(['get', 'post'], 'families/import-legacy', 'View\FamilyController::importSecondParentsFromLegacy', ['filter' => 'auth:admin|principal']);
// Admin page (protect with your auth/permission)
$routes->group('family', static function ($routes) {
$routes->group('family', ['filter' => 'auth:admin|principal'], static function ($routes) {
$routes->get('', 'View\FamilyAdminController::index');
$routes->get('index', 'View\FamilyAdminController::index');
$routes->get('search', 'View\FamilyAdminController::search');
$routes->get('card', 'View\FamilyAdminController::card');
@@ -1020,42 +1054,42 @@ $routes->group('family', static function ($routes) {
$routes->post('compose-email/send', 'View\FamilyAdminController::sendComposeEmail');
});
// Convenience alias
$routes->get('family', 'View\FamilyAdminController::index');
$routes->get('family', 'View\FamilyAdminController::index', ['filter' => 'auth:admin|principal']);
//////////////////////////////////////////////////////////
//upload files
$routes->get('receipts/(:any)', 'View\FilesController::receipt/$1');
$routes->get('reimbreceipts/(:any)', 'View\FilesController::reimb/$1'); // serves from writable/uploads/reimbursements
$routes->get('early-dismissal-signatures/(:any)', 'View\FilesController::earlyDismissalSignature/$1');
$routes->get('receipts/(:any)', 'View\FilesController::receipt/$1', ['filter' => 'auth']);
$routes->get('reimbreceipts/(:any)', 'View\FilesController::reimb/$1', ['filter' => 'auth']); // serves from writable/uploads/reimbursements
$routes->get('early-dismissal-signatures/(:any)', 'View\FilesController::earlyDismissalSignature/$1', ['filter' => 'auth']);
// Expenses
$routes->get('expenses/edit/(:num)', 'View\ExpenseController::edit/$1');
$routes->post('expenses/update/(:num)', 'View\ExpenseController::update/$1');
$routes->get('expenses/edit/(:num)', 'View\ExpenseController::edit/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('expenses/update/(:num)', 'View\ExpenseController::update/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
// Reimbursements
$routes->get('reimbursements/edit/(:num)', 'View\ReimbursementController::edit/$1');
$routes->post('reimbursements/update/(:num)', 'View\ReimbursementController::update/$1');
$routes->get('reimbursements/edit/(:num)', 'View\ReimbursementController::edit/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('reimbursements/update/(:num)', 'View\ReimbursementController::update/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
// app/Config/Routes.php
$routes->get('whatsapp/', 'View\WhatsappController::index');
$routes->post('whatsapp/sendInvites', 'View\WhatsappController::sendInvites');
$routes->post('whatsapp/saveLink', 'View\WhatsappController::saveLink');
$routes->get('whatsapp/parent-contacts', 'View\WhatsappController::parentContacts');
$routes->get('whatsapp/parent-contacts-by-class', 'View\WhatsappController::parentContactsByClass');
$routes->get('whatsapp/', 'View\WhatsappController::index', ['filter' => 'auth:admin|principal']);
$routes->post('whatsapp/sendInvites', 'View\WhatsappController::sendInvites', ['filter' => 'auth:admin|principal']);
$routes->post('whatsapp/saveLink', 'View\WhatsappController::saveLink', ['filter' => 'auth:admin|principal']);
$routes->get('whatsapp/parent-contacts', 'View\WhatsappController::parentContacts', ['filter' => 'auth:admin|principal']);
$routes->get('whatsapp/parent-contacts-by-class', 'View\WhatsappController::parentContactsByClass', ['filter' => 'auth:admin|principal']);
// Track WhatsApp group membership per class/parent
$routes->match(['get', 'post'], 'whatsapp/update-membership', 'View\WhatsappController::updateMembership');
$routes->match(['get', 'post'], 'whatsapp/update-membership', 'View\WhatsappController::updateMembership', ['filter' => 'auth:admin|principal']);
//Attendance
$routes->get('admin/teacher-attendance', 'View\AttendanceController::index', ['filter' => 'auth:admin']);
$routes->post('admin/teacher-attendance/save', 'View\AttendanceController::save', ['filter' => 'auth:admin']);
$routes->get('admin/teacher-attendance', 'View\AttendanceController::index', ['filter' => 'auth:admin|principal']);
$routes->post('admin/teacher-attendance/save', 'View\AttendanceController::save', ['filter' => 'auth:admin|principal']);
// NEW: monthly overview (all sections)
$routes->get('admin/teacher-attendance/month', 'View\AttendanceController::month', ['filter' => 'auth:admin']);
$routes->get('admin/teacher-attendance/month.csv', 'View\AttendanceController::monthCsv', ['filter' => 'auth:admin']);
$routes->get('api/admin/teacher-attendance/month', 'View\AttendanceController::monthData', ['filter' => 'auth:admin']);
$routes->get('admin/teacher-attendance/month', 'View\AttendanceController::month', ['filter' => 'auth:admin|principal']);
$routes->get('admin/teacher-attendance/month.csv', 'View\AttendanceController::monthCsv', ['filter' => 'auth:admin|principal']);
$routes->get('api/admin/teacher-attendance/month', 'View\AttendanceController::monthData', ['filter' => 'auth:admin|principal']);
$routes->post('attendance/record', 'View\AttendanceTrackingController::record');
@@ -1084,15 +1118,15 @@ $routes->post('admin/teacher-attendance/save-cell', 'View\AttendanceController::
$routes->post('admin/admins-attendance/save-cell', 'View\AttendanceController::saveCell');
$routes->get('parent/edit-emergency-contact', 'View\ParentController::editEmergencyContact');
$routes->match(['get', 'post'], 'parent/edit-emergency-contact/(:num)', 'View\ParentController::editEmergencyContact/$1');
$routes->get('parent/edit-emergency-contact', 'View\ParentController::editEmergencyContact', ['filter' => 'auth:parent']);
$routes->match(['get', 'post'], 'parent/edit-emergency-contact/(:num)', 'View\ParentController::editEmergencyContact/$1', ['filter' => 'auth:parent']);
$routes->get('rfid_coming_soon', 'View\RFIDController::rfidComingSoon');
$routes->post('/parent/save-emergency-contact', 'View\ParentController::saveEmergencyContact');
$routes->get('/parent/add-emergency-form', 'View\ParentController::addEmergencyForm');
$routes->post('/parent/save-emergency-contact', 'View\ParentController::saveEmergencyContact', ['filter' => 'auth:parent']);
$routes->get('/parent/add-emergency-form', 'View\ParentController::addEmergencyForm', ['filter' => 'auth:parent']);
$routes->get('report/combined', 'View\ScorePredictor::combinedReport');
@@ -1100,11 +1134,11 @@ $routes->get('report/combined', 'View\ScorePredictor::combinedReport');
//$routes->get('/parent/withdraw_success', 'View\ParentController::withdraw_success');
$routes->get('/profile/(:num)', 'View\ParentController::profile/$1');
$routes->post('/parent/updateProfile/(:num)', 'View\ParentController::updateProfile/$1');
$routes->get('/profile/(:num)', 'View\ParentController::profile/$1', ['filter' => 'auth']);
$routes->post('/parent/updateProfile/(:num)', 'View\ParentController::updateProfile/$1', ['filter' => 'auth']);
$routes->get('/preferences/(:num)', 'View\PreferencesController::index/$1');
$routes->post('/preferences/update/(:num)', 'View\PreferencesController::updatePreferences/$1');
$routes->get('/preferences/(:num)', 'View\PreferencesController::index/$1', ['filter' => 'auth']);
$routes->post('/preferences/update/(:num)', 'View\PreferencesController::updatePreferences/$1', ['filter' => 'auth']);
// Route to view_add_edit the configuration page
$routes->get('/configuration/configuration_view', 'View\ConfigurationController::index');
@@ -1113,6 +1147,23 @@ $routes->post('/configuration/addConfig', 'View\ConfigurationController::addConf
$routes->match(['get', 'post'], '/configuration/editConfig/(:num)', 'View\ConfigurationController::editConfig/$1');
$routes->post('/configuration/deleteConfig/(:num)', 'View\ConfigurationController::deleteConfig/$1');
// School-year management
$routes->group('administrator/school-years', ['filter' => 'auth:admin|administrator|principal|vice_principal'], static function ($routes) {
$routes->get('', 'Administrator\SchoolYearController::index');
$routes->post('store', 'Administrator\SchoolYearController::store');
$routes->post('(:num)/update', 'Administrator\SchoolYearController::update/$1');
$routes->post('(:num)/activate', 'Administrator\SchoolYearController::activate/$1');
$routes->post('(:num)/delete-draft', 'Administrator\SchoolYearController::deleteDraft/$1');
$routes->post('(:num)/archive', 'Administrator\SchoolYearController::archive/$1');
$routes->post('(:num)/reopen', 'Administrator\SchoolYearController::reopen/$1');
$routes->get('(:num)/closing/preview', 'Administrator\SchoolYearClosingController::preview/$1');
$routes->get('(:segment)/closing/preview', 'Administrator\SchoolYearClosingController::previewByName/$1');
$routes->post('(:num)/closing/start', 'Administrator\SchoolYearClosingController::start/$1');
$routes->post('(:num)/closing/execute', 'Administrator\SchoolYearClosingController::execute/$1');
$routes->post('(:num)/closing/complete', 'Administrator\SchoolYearClosingController::complete/$1');
$routes->post('(:num)/closing/cancel', 'Administrator\SchoolYearClosingController::cancel/$1');
});
// Route for Attendance Comment Templates
$routes->get('/administrator/attendance-templates', 'View\AttendanceCommentTemplateController::index');
$routes->get('/api/attendance-templates', 'View\AttendanceCommentTemplateController::listData');
@@ -1178,16 +1229,19 @@ $routes->get('/terms_of_service', 'View\PageController::termsOfService');
$routes->get('/help_center', 'View\PageController::helpCenter');
//payment
$routes->get('/payment', 'View\PaymentController::redirectPage');
$routes->get('/payment/paypal', 'View\PaymentController::paypal');
$routes->get('/payment/manual', 'View\PaymentController::manual');
$routes->post('/payment/manual', 'View\PaymentController::manual');
$routes->get('/payment', 'View\PaymentController::redirectPage', ['filter' => 'auth:parent']);
$routes->get('/payment/manual', 'View\PaymentController::manual', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('/payment/manual', 'View\PaymentController::manual', ['filter' => 'auth:create_payment|create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
// Voucher management
$routes->get('discounts/list', 'View\DiscountController::listVouchers');
$routes->match(['get', 'post'], 'discount/create', 'View\DiscountController::createVoucher');
$routes->match(['get', 'post'], 'discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1');
$routes->match(['get', 'post'], 'discount/apply', 'View\DiscountController::applyVoucher');
$routes->get('discounts/list', 'View\DiscountController::listVouchers', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('discount/create', 'View\DiscountController::createVoucher', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('discount/create', 'View\DiscountController::createVoucher', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
$routes->get('discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('discount/voucher-form', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('discount/voucher-form', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('discount/apply', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
@@ -1206,7 +1260,7 @@ $routes->post('/register', 'View\RegisterController::register');
$routes->get('/register/success', 'View\RegisterController::success');
$routes->get('/parent/add-student-form', 'View\ParentController::addStudentForm');
$routes->get('/parent/add-student-form', 'View\ParentController::addStudentForm', ['filter' => 'auth:parent']);
$routes->get('/landing_page/admin_dashboard', 'View\LandingPageController::admin', ['filter' => 'auth:admin_dashboard,read']);
@@ -1219,7 +1273,6 @@ $routes->get('/access_denied', 'ErrorController::accessDenied');
$routes->post('administrator/students/update', 'View\StudentController::editStudentData', ['filter' => 'auth:edit_student,update']);
$routes->post('administrator/students/set-active', 'View\StudentController::setStudentActive', ['filter' => 'auth:edit_student,update']);
/*
* --------------------------------------------------------------------
@@ -1247,7 +1300,6 @@ $routes->get('flags/flags_management', 'View\FlagController::index');
$routes->get('flags/processed_flags', 'View\FlagController::processedFlags');
$routes->get('flags/incident_analysis', 'View\FlagController::incidentAnalysis');
$routes->post('flags/add', 'View\FlagController::addFlag');
$routes->get('/flags/update_state/(:num)', 'View\FlagController::updateState/$1');
$routes->post('/flags/update_state/(:num)', 'View\FlagController::updateState/$1');
$routes->get('flags/getStudentsByGrade/(:num)', 'View\FlagController::getStudentsByGrade/$1');
$routes->get('flags/history', 'View\FlagController::history');
@@ -1331,14 +1383,11 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
$routes->get('payments', 'View\UserController::index');
$routes->get('payments/(:num)', 'View\SupportController::show/$1');
$routes->get('payments/parent/(:num)', 'View\SupportController::getByParent/$1');
$routes->post('payments', 'View\TeacherController::create');
$routes->put('payments/(:num)', 'View\TeacherController::update/$1');
// Invoices
$routes->get('invoices', 'View\UserController::index');
$routes->get('invoices/(:num)', 'View\SupportController::show/$1');
$routes->get('invoices/parent/(:num)', 'View\SupportController::getByParent/$1');
$routes->put('invoices/(:num)', 'View\TeacherController::update/$1');
// Notifications
$routes->get('notifications', 'View\UserController::index');
@@ -1359,14 +1408,10 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
// Expenses
$routes->get('expenses', 'View\UserController::index');
$routes->get('expenses/(:num)', 'View\SupportController::show/$1');
$routes->post('expenses', 'View\TeacherController::create');
$routes->put('expenses/(:num)', 'View\TeacherController::update/$1');
// Reimbursements
$routes->get('reimbursements', 'View\UserController::index');
$routes->get('reimbursements/(:num)', 'View\SupportController::show/$1');
$routes->post('reimbursements', 'View\TeacherController::create');
$routes->put('reimbursements/(:num)', 'View\TeacherController::update/$1');
// Dashboard Data
$routes->get('dashboard', 'DashboardController::index');
@@ -1432,19 +1477,10 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
// Refunds
$routes->get('refunds', 'View\UserController::index');
$routes->get('refunds/(:num)', 'View\SupportController::show/$1');
$routes->post('refunds', 'View\TeacherController::create');
$routes->put('refunds/(:num)', 'View\TeacherController::update/$1');
// Discounts
$routes->get('discounts', 'View\UserController::index');
$routes->get('discounts/code/(:segment)', 'View\SupportController::getByCode/$1');
$routes->post('discounts/apply', 'View\SupportController::apply');
// Payment Transactions
$routes->get('payment-transactions', 'View\UserController::index');
$routes->get('payment-transactions/(:num)', 'View\SupportController::show/$1');
$routes->get('payment-transactions/payment/(:num)', 'View\SupportController::getByPayment/$1');
$routes->post('payment-transactions', 'View\TeacherController::create');
// Emergency Contacts
$routes->get('emergency-contacts', 'View\UserController::index');
@@ -1490,9 +1526,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
// Extra Charges
$routes->get('extra-charges', 'View\UserController::index');
$routes->get('extra-charges/(:num)', 'View\SupportController::show/$1');
$routes->post('extra-charges', 'View\TeacherController::create');
$routes->put('extra-charges/(:num)', 'View\TeacherController::update/$1');
$routes->post('extra-charges/(:num)/void', 'View\SupportController::void/$1');
$routes->get('extra-charges/parents', 'View\SupportController::parentOptions');
// Flags
@@ -1531,11 +1564,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
$routes->get('class-preparation/(:num)', 'View\SupportController::show/$1');
$routes->post('class-preparation/(:num)/mark-printed', 'View\SupportController::markPrinted/$1');
// PayPal Transactions
$routes->get('paypal-transactions', 'View\UserController::index');
$routes->get('paypal-transactions/(:num)', 'View\SupportController::show/$1');
$routes->get('paypal-transactions/transaction/(:segment)', 'View\SupportController::getByTransactionId/$1');
// Stats
$routes->get('stats', 'View\UserController::index');
@@ -1548,7 +1576,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
// Payment Notifications
$routes->get('payment-notifications', 'View\UserController::index');
$routes->post('payment-notifications/send', 'View\SupportController::send');
// RFID
$routes->post('rfid/process', 'View\SupportController::process');
@@ -1565,9 +1592,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
// Inventory
$routes->get('inventory', 'View\UserController::index');
$routes->get('inventory/(:num)', 'View\SupportController::show/$1');
$routes->post('inventory', 'View\TeacherController::create');
$routes->put('inventory/(:num)', 'View\TeacherController::update/$1');
$routes->delete('inventory/(:num)', 'View\TeacherController::delete/$1');
$routes->get('inventory/movements', 'View\SupportController::movements');
// Preferences
@@ -1619,8 +1643,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
// Purchase Orders
$routes->get('purchase-orders', 'View\UserController::index');
$routes->get('purchase-orders/(:num)', 'View\SupportController::show/$1');
$routes->post('purchase-orders', 'View\TeacherController::create');
$routes->put('purchase-orders/(:num)', 'View\TeacherController::update/$1');
// Grading
$routes->get('grading/(:segment)/(:num)/(:num)', 'View\SupportController::show/$1/$2/$3');
@@ -1756,13 +1778,13 @@ if (file_exists(APPPATH . 'Config/' . ENVIRONMENT . '/Routes.php')) {
// ---- Administrator API endpoints (added by Codex) ----
$routes->group('api/administrator', ['namespace' => 'App\\Controllers\\Api'], static function ($routes) {
$routes->get('dashboard', 'AdministratorController::dashboard');
$routes->get('absence', 'AdministratorController::absenceInfo', ['filter' => 'auth:admin']);
$routes->post('absence', 'AdministratorController::submitAbsence', ['filter' => 'auth:admin']);
$routes->get('search', 'AdministratorController::search', ['filter' => 'auth:admin']);
$routes->get('enrollment-withdrawal/data', 'AdministratorController::enrollmentWithdrawalData', ['filter' => 'auth:admin']);
$routes->post('enrollment-withdrawal/update', 'AdministratorController::updateEnrollmentStatuses', ['filter' => 'auth:admin']);
$routes->get('students', 'AdministratorController::studentProfiles', ['filter' => 'auth:admin']);
$routes->get('parents', 'AdministratorController::parentProfiles', ['filter' => 'auth:admin']);
$routes->get('absence', 'AdministratorController::absenceInfo', ['filter' => 'auth:admin|principal']);
$routes->post('absence', 'AdministratorController::submitAbsence', ['filter' => 'auth:admin|principal']);
$routes->get('search', 'AdministratorController::search', ['filter' => 'auth:admin|principal']);
$routes->get('enrollment-withdrawal/data', 'AdministratorController::enrollmentWithdrawalData', ['filter' => 'auth:admin|principal']);
$routes->post('enrollment-withdrawal/update', 'AdministratorController::updateEnrollmentStatuses', ['filter' => 'auth:admin|principal']);
$routes->get('students', 'AdministratorController::studentProfiles', ['filter' => 'auth:admin|principal']);
$routes->get('parents', 'AdministratorController::parentProfiles', ['filter' => 'auth:admin|principal']);
});
// ---- Parent API additions (parity with View) ----
$routes->group('api/parents', ['namespace' => 'App\\Controllers\\Api'], static function ($routes) {
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
+355 -13
View File
@@ -36,7 +36,7 @@ class Services extends BaseService
/**
* Override CI Email service to enforce a global Reply-To.
*/
public static function email(array $config = null, bool $getShared = true)
public static function email(?array $config = null, bool $getShared = true)
{
if ($getShared) {
return static::getSharedInstance('email', $config);
@@ -70,21 +70,26 @@ class Services extends BaseService
$mail = new PHPMailer(true);
// Set up your PHPMailer configuration
$host = (string) env('MAIL_DEFAULT_HOST', env('SMTP_HOST', 'smtp.gmail.com'));
$user = (string) env('MAIL_DEFAULT_USER', env('SMTP_USER', ''));
$pass = (string) env('MAIL_DEFAULT_PASS', env('SMTP_PASS', ''));
$port = (int) env('MAIL_DEFAULT_PORT', env('SMTP_PORT', 465));
$encryption = strtolower((string) env('MAIL_DEFAULT_ENCRYPTION', env('SMTP_ENCRYPTION', 'ssl')));
$mail->isSMTP();
$mail->Host = 'smtp.gmail.com'; // Your SMTP host
$mail->Host = $host;
$mail->SMTPAuth = true;
$mail->Username = 'alrahma.sunday.school@gmail.com'; // Your email
$mail->Password = 'psnp emdq dykw ypul'; // Your email password
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
$mail->Port = 465;
$mail->Username = $user;
$mail->Password = $pass;
$mail->SMTPSecure = in_array($encryption, ['tls', 'starttls'], true)
? PHPMailer::ENCRYPTION_STARTTLS
: PHPMailer::ENCRYPTION_SMTPS;
$mail->Port = $port > 0 ? $port : 465;
$mail->Timeout = 10; // ⏱ 10-second timeout max
$mail->SMTPKeepAlive = false; // Prevent hanging connections
$mail->SMTPDebug = 0; // Set to 2 temporarily for debugging
$mail->SMTPDebug = 2; // You can set it to 1, 2, or 3 for increasing verbosity
$mail->Debugoutput = 'html'; // You can output it to 'html' or 'error_log'
$mail->Timeout = 10;
$mail->SMTPKeepAlive = false;
$mail->SMTPDebug = 0;
$mail->Debugoutput = 'error_log';
return $mail;
}
@@ -180,4 +185,341 @@ class Services extends BaseService
$http = static::curlrequest($options);
return new \App\Services\ApiClient($http, $apiConfig);
}
public static function schoolYearContext(bool $getShared = true): \App\Services\SchoolYearContextService
{
if ($getShared) {
return static::getSharedInstance('schoolYearContext');
}
return new \App\Services\SchoolYearContextService(
model(\App\Models\SchoolYearModel::class),
static::schoolYearAccessPolicy()
);
}
public static function schoolYearAccessPolicy(bool $getShared = true): \App\Services\SchoolYearAccessPolicy
{
if ($getShared) {
return static::getSharedInstance('schoolYearAccessPolicy');
}
return new \App\Services\SchoolYearAccessPolicy();
}
public static function schoolYearViewData(bool $getShared = true): \App\Services\SchoolYearViewDataService
{
if ($getShared) {
return static::getSharedInstance('schoolYearViewData');
}
return new \App\Services\SchoolYearViewDataService(
static::schoolYearContext(),
static::schoolYearAccessPolicy()
);
}
public static function schoolYearWriteGuard(bool $getShared = true): \App\Services\SchoolYearWriteGuard
{
if ($getShared) {
return static::getSharedInstance('schoolYearWriteGuard');
}
return new \App\Services\SchoolYearWriteGuard();
}
public static function schoolYearValidation(bool $getShared = true): \App\Services\SchoolYearValidationService
{
if ($getShared) {
return static::getSharedInstance('schoolYearValidation');
}
return new \App\Services\SchoolYearValidationService(
model(\App\Models\SchoolYearModel::class)
);
}
public static function staffDirectorySync(bool $getShared = true): \App\Services\StaffDirectorySyncService
{
if ($getShared) {
return static::getSharedInstance('staffDirectorySync');
}
return new \App\Services\StaffDirectorySyncService(
model(\App\Models\StaffModel::class),
model(\App\Models\UserModel::class),
model(\App\Models\ConfigurationModel::class),
\Config\Database::connect()
);
}
public static function schoolYearManagement(bool $getShared = true): \App\Services\SchoolYearManagementService
{
if ($getShared) {
return static::getSharedInstance('schoolYearManagement');
}
return new \App\Services\SchoolYearManagementService(
model(\App\Models\SchoolYearModel::class),
model(\App\Models\ConfigurationModel::class),
model(\App\Models\SchoolYearTransitionLogModel::class),
model(\App\Models\SchoolYearClosingBatchModel::class),
static::schoolYearValidation(),
\Config\Database::connect()
);
}
public static function schoolYearClosing(bool $getShared = true): \App\Services\SchoolYearClosingService
{
if ($getShared) {
return static::getSharedInstance('schoolYearClosing');
}
return new \App\Services\SchoolYearClosingService(
model(\App\Models\SchoolYearModel::class),
model(\App\Models\SchoolYearClosingBatchModel::class),
model(\App\Models\SchoolYearClosingItemModel::class),
static::schoolYearManagement(),
\Config\Database::connect()
);
}
public static function enrollmentTransition(bool $getShared = true): \App\Services\EnrollmentTransitionService
{
if ($getShared) {
return static::getSharedInstance('enrollmentTransition');
}
return new \App\Services\EnrollmentTransitionService(\Config\Database::connect());
}
public static function enrollmentStatus(bool $getShared = true): \App\Services\EnrollmentStatusService
{
if ($getShared) {
return static::getSharedInstance('enrollmentStatus');
}
return new \App\Services\EnrollmentStatusService(\Config\Database::connect());
}
public static function enrollmentRegistrationEmail(bool $getShared = true): \App\Services\EnrollmentRegistrationEmailService
{
if ($getShared) {
return static::getSharedInstance('enrollmentRegistrationEmail');
}
return new \App\Services\EnrollmentRegistrationEmailService(
\Config\Database::connect(),
static::enrollmentTransition(),
static::emailService(),
model(\App\Models\ConfigurationModel::class)
);
}
public static function financialAid(bool $getShared = true): \App\Services\FinancialAidService
{
if ($getShared) {
return static::getSharedInstance('financialAid');
}
return new \App\Services\FinancialAidService(
model(\App\Models\FinancialAidRequestModel::class),
model(\App\Models\InvoiceModel::class),
model(\App\Models\DiscountVoucherModel::class),
model(\App\Models\DiscountUsageModel::class),
new \App\Libraries\InvoiceLedgerService()
);
}
public static function enrollmentWithdrawal(bool $getShared = true): \App\Services\EnrollmentWithdrawalService
{
if ($getShared) {
return static::getSharedInstance('enrollmentWithdrawal');
}
$db = \Config\Database::connect();
return new \App\Services\EnrollmentWithdrawalService(
$db,
model(\App\Models\StudentModel::class),
model(\App\Models\EnrollmentModel::class),
model(\App\Models\StudentClassModel::class),
model(\App\Models\ClassSectionModel::class),
model(\App\Models\UserModel::class),
model(\App\Models\InvoiceModel::class),
model(\App\Models\RefundModel::class)
);
}
public static function teacherSubmissionReport(bool $getShared = true): \App\Services\TeacherSubmissionReportService
{
if ($getShared) {
return static::getSharedInstance('teacherSubmissionReport');
}
return new \App\Services\TeacherSubmissionReportService(
\Config\Database::connect(),
model(\App\Models\ConfigurationModel::class),
model(\App\Models\StudentClassModel::class),
model(\App\Models\ClassSectionModel::class),
model(\App\Models\UserModel::class)
);
}
public static function administratorDashboard(bool $getShared = true): \App\Services\AdministratorDashboardService
{
if ($getShared) {
return static::getSharedInstance('administratorDashboard');
}
return new \App\Services\AdministratorDashboardService(
\Config\Database::connect(),
model(\App\Models\UserModel::class),
model(\App\Models\LoginActivityModel::class)
);
}
public static function adminNotificationSettings(bool $getShared = true): \App\Services\AdminNotificationSettingsService
{
if ($getShared) {
return static::getSharedInstance('adminNotificationSettings');
}
return new \App\Services\AdminNotificationSettingsService(
\Config\Database::connect(),
model(\App\Models\AdminNotificationSubjectModel::class)
);
}
public static function administratorDirectory(bool $getShared = true): \App\Services\AdministratorDirectoryService
{
if ($getShared) {
return static::getSharedInstance('administratorDirectory');
}
return new \App\Services\AdministratorDirectoryService(
\Config\Database::connect(),
model(\App\Models\StudentClassModel::class),
model(\App\Models\UserModel::class),
model(\App\Models\UserRoleModel::class),
model(\App\Models\InvoiceModel::class),
model(\App\Models\StudentModel::class)
);
}
public static function gradingScore(bool $getShared = true): \App\Services\GradingScoreService
{
if ($getShared) {
return static::getSharedInstance('gradingScore');
}
$configModel = model(\App\Models\ConfigurationModel::class);
$attendanceCalculator = new \App\Services\Calculators\AttendanceCalculator(
model(\App\Models\AttendanceRecordModel::class),
$configModel,
model(\App\Models\CalendarModel::class)
);
return new \App\Services\GradingScoreService(
\Config\Database::connect(),
$configModel,
model(\App\Models\HomeworkModel::class),
model(\App\Models\UserModel::class),
model(\App\Models\StudentClassModel::class),
model(\App\Models\StudentModel::class),
model(\App\Models\TeacherClassModel::class),
model(\App\Models\ClassSectionModel::class),
$attendanceCalculator,
model(\App\Models\GradingLockModel::class),
static::semesterScoreService(),
(string) $configModel->getConfig('school_year'),
(string) getSemester()
);
}
public static function placementGrading(bool $getShared = true): \App\Services\PlacementGradingService
{
if ($getShared) {
return static::getSharedInstance('placementGrading');
}
$configModel = model(\App\Models\ConfigurationModel::class);
return new \App\Services\PlacementGradingService(
\Config\Database::connect(),
model(\App\Models\StudentModel::class),
model(\App\Models\PlacementLevelModel::class),
model(\App\Models\PlacementBatchModel::class),
model(\App\Models\PlacementScoreModel::class),
(string) $configModel->getConfig('school_year')
);
}
public static function belowSixty(bool $getShared = true): \App\Services\BelowSixtyService
{
if ($getShared) {
return static::getSharedInstance('belowSixty');
}
$configModel = model(\App\Models\ConfigurationModel::class);
return new \App\Services\BelowSixtyService(
\Config\Database::connect(),
$configModel,
model(\App\Models\StudentModel::class),
model(\App\Models\StudentClassModel::class),
model(\App\Models\ParentMeetingScheduleModel::class),
model(\App\Models\UserModel::class),
(string) $configModel->getConfig('school_year'),
(string) getSemester()
);
}
public static function studentDecision(bool $getShared = true): \App\Services\StudentDecisionService
{
if ($getShared) {
return static::getSharedInstance('studentDecision');
}
$configModel = model(\App\Models\ConfigurationModel::class);
return new \App\Services\StudentDecisionService(
\Config\Database::connect(),
$configModel,
model(\App\Models\StudentModel::class),
model(\App\Models\StudentClassModel::class),
model(\App\Models\UserModel::class),
(string) $configModel->getConfig('school_year'),
(string) getSemester()
);
}
public static function studentYearStatus(bool $getShared = true): \App\Services\StudentYearStatusService
{
if ($getShared) {
return static::getSharedInstance('studentYearStatus');
}
return new \App\Services\StudentYearStatusService(
\Config\Database::connect(),
model(\App\Models\StudentYearStatusModel::class)
);
}
public static function withdrawalFinancial(bool $getShared = true): \App\Services\WithdrawalFinancialService
{
if ($getShared) {
return static::getSharedInstance('withdrawalFinancial');
}
$db = \Config\Database::connect();
return new \App\Services\WithdrawalFinancialService(
$db,
new \App\Services\WithdrawalRefundCalculator(),
new \App\Services\StudentBookIssueService($db),
new \App\Libraries\InvoiceLedgerService(),
new \App\Libraries\RefundEligibilityService()
);
}
}
Executable → Regular
+9 -1
View File
@@ -32,7 +32,7 @@ class Session extends BaseConfig
*/
public string $cookieName = 'ci_session';
public string $cookieDomain = ''; // Leave blank for localhost
public bool $cookieSecure = false; // Set to false if not using HTTPS
public bool $cookieSecure = false; // Forced on in production via constructor
/**
* --------------------------------------------------------------------------
@@ -101,4 +101,12 @@ class Session extends BaseConfig
* DB Group for the database session.
*/
public ?string $DBGroup = null;
public function __construct()
{
parent::__construct();
$isProduction = ENVIRONMENT === 'production';
$this->cookieSecure = $isProduction;
$this->cookieName = $isProduction ? '__Host-ci_session' : 'ci_session';
}
}
Executable → Regular
+13 -10
View File
@@ -4,15 +4,18 @@ namespace Config;
class SessionTimeout
{
// Session timeout in seconds (12 hours)
public const TIMEOUT_DURATION = 43200;
// Warning threshold in seconds (5 minutes before timeout)
public const WARNING_THRESHOLD = 42900;
// Session timeout in seconds (30 minutes)
public const TIMEOUT_DURATION = 1800;
// Show warning during the last 30 seconds before timeout.
public const WARNING_THRESHOLD = 1770;
// Server-side check interval (in seconds)
public const CHECK_INTERVAL = 300; // 5 minutes
// Client-side check interval (in milliseconds)
public const CLIENT_CHECK_INTERVAL = 60000; // 1 minute
public const CHECK_INTERVAL = 30;
// Client-side session status check interval (in milliseconds)
public const CLIENT_CHECK_INTERVAL = 30000;
// Minimum interval between successful activity pings (in milliseconds)
public const CLIENT_PING_INTERVAL = 60000;
}
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
+15 -4
View File
@@ -54,6 +54,11 @@ class CompetitionWinnersController extends BaseController
public function index()
{
$competitionModel = new CompetitionModel();
$schoolYear = $this->currentCompetitionSchoolYear();
if ($schoolYear !== '') {
$competitionModel->where('school_year', $schoolYear);
}
$competitions = $competitionModel
->orderBy('id', 'DESC')
@@ -62,12 +67,13 @@ class CompetitionWinnersController extends BaseController
return view('admin/competition_winners/index', [
'competitions' => $competitions,
'sectionMap' => $this->getClassSectionMap(),
'schoolYear' => $schoolYear,
]);
}
public function create()
{
$schoolYear = $this->configModel->getConfig('school_year');
$schoolYear = $this->currentCompetitionSchoolYear();
$classCounts = $this->getClassStudentCounts($schoolYear);
$classSections = $this->classSectionModel
->orderBy('class_section_name', 'ASC')
@@ -81,7 +87,7 @@ class CompetitionWinnersController extends BaseController
'errors' => session('errors'),
'classSections' => $classSections,
'classRows' => $classRows,
'defaultSemester' => $this->configModel->getConfig('semester'),
'defaultSemester' => getSemester(),
'defaultSchoolYear'=> $schoolYear,
]);
}
@@ -113,7 +119,7 @@ class CompetitionWinnersController extends BaseController
'errors' => session('errors'),
'classSections' => $classSections,
'classRows' => $classRows,
'defaultSemester' => $this->configModel->getConfig('semester'),
'defaultSemester' => getSemester(),
'defaultSchoolYear'=> $schoolYear,
]);
}
@@ -385,7 +391,7 @@ class CompetitionWinnersController extends BaseController
$semester = session('semester');
$schoolYear = session('school_year');
if ($semester === null || $semester === '') {
$semester = $this->configModel->getConfig('semester');
$semester = getSemester();
}
if ($schoolYear === null || $schoolYear === '') {
$schoolYear = $this->configModel->getConfig('school_year');
@@ -1088,6 +1094,11 @@ class CompetitionWinnersController extends BaseController
return $questionCounts;
}
private function currentCompetitionSchoolYear(): string
{
return $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
}
private function getClassSectionMap(): array
{
$sections = $this->classSectionModel
+180 -9
View File
@@ -39,7 +39,13 @@ class AdminProgressController extends BaseController
public function index()
{
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
if ($schoolYear === '') {
$schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
}
$filters = [
'school_year' => $schoolYear,
'from' => (string) $this->request->getGet('from'),
'to' => (string) $this->request->getGet('to'),
'class_section_id' => (string) $this->request->getGet('class_section_id'),
@@ -51,6 +57,8 @@ class AdminProgressController extends BaseController
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left');
$this->applyProgressSchoolYearScope($builder, $schoolYear);
if ($filters['from']) {
$builder->where('week_start >=', $filters['from']);
}
@@ -91,16 +99,17 @@ class AdminProgressController extends BaseController
$reportGroupsBySection[$sectionId] = $groups;
}
$classSections = $this->classSectionModel->getClassSections();
$studentCounts = $this->studentClassModel->getStudentCountsBySection();
$filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts) {
$classSections = $this->classSectionsForYear($schoolYear, $rows);
$studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null);
$filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts, $reportGroupsBySection) {
$sectionId = (int) ($section['class_section_id'] ?? 0);
return isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0;
return (isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0)
|| isset($reportGroupsBySection[$sectionId]);
}));
$filterStart = $this->normalizeDate($filters['from'] ?? '');
$filterEnd = $this->normalizeDate($filters['to'] ?? '');
[$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet] = $this->buildSemesterDates();
[$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet] = $this->buildSemesterDates($schoolYear);
[$expectedDays, $activeDatesSet] = $this->resolveExpectedDays(
$dateList,
$noSchoolDays,
@@ -132,6 +141,8 @@ class AdminProgressController extends BaseController
return view('admin/class_progress_list', [
'reportGroupsBySection' => $reportGroupsBySection,
'filters' => $filters,
'schoolYear' => $schoolYear,
'schoolYears' => $this->availableSchoolYears($schoolYear),
'classSections' => $filteredSections,
'statusOptions' => ClassProgressController::STATUS_OPTIONS,
'subjectSections' => ClassProgressController::SUBJECT_SECTIONS,
@@ -142,6 +153,162 @@ class AdminProgressController extends BaseController
]);
}
private function classSectionsForYear(string $schoolYear, array $reportRows = []): array
{
$db = db_connect();
if ($schoolYear !== '' && $db->fieldExists('school_year', 'classSection')) {
$sections = $db->table('classSection')
->select('classSection.class_section_id, classSection.class_section_name, classes.class_name')
->join('classes', 'classSection.class_id = classes.id', 'left')
->where('classSection.school_year', $schoolYear)
->orderBy('classSection.class_section_name', 'ASC')
->get()
->getResultArray();
} else {
$sections = $this->classSectionModel->getClassSections();
}
$sectionsById = [];
foreach ($sections as $section) {
$sectionId = (int) ($section['class_section_id'] ?? 0);
if ($sectionId > 0) {
$sectionsById[$sectionId] = $section;
}
}
foreach ($reportRows as $row) {
$sectionId = (int) ($row['class_section_id'] ?? 0);
if ($sectionId <= 0 || isset($sectionsById[$sectionId])) {
continue;
}
$sectionsById[$sectionId] = [
'class_section_id' => $sectionId,
'class_section_name' => (string) ($row['class_section_name'] ?? ('Section #' . $sectionId)),
'class_name' => '',
];
}
uasort($sectionsById, static function (array $a, array $b): int {
return strnatcasecmp((string) ($a['class_section_name'] ?? ''), (string) ($b['class_section_name'] ?? ''));
});
return array_values($sectionsById);
}
private function applyProgressSchoolYearScope($builder, string $schoolYear): void
{
if ($schoolYear === '') {
return;
}
$db = db_connect();
$hasReportYear = $db->fieldExists('school_year', 'class_progress_reports');
$hasSectionYear = $db->fieldExists('school_year', 'classSection');
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYear);
if ($hasReportYear) {
if ($this->hasAnyExplicitProgressSchoolYearRows()) {
$builder->where('class_progress_reports.school_year', $schoolYear);
return;
}
if ($rangeStart !== '' && $rangeEnd !== '') {
$builder
->groupStart()
->groupStart()
->where('class_progress_reports.school_year IS NULL', null, false)
->orWhere('class_progress_reports.school_year', '')
->groupEnd()
->where('class_progress_reports.week_start >=', $rangeStart)
->where('class_progress_reports.week_start <=', $rangeEnd)
->groupEnd();
return;
}
$builder->where('class_progress_reports.school_year', $schoolYear);
return;
}
if ($hasSectionYear) {
$builder->where('cs.school_year', $schoolYear);
return;
}
if ($rangeStart !== '' && $rangeEnd !== '') {
$builder
->where('class_progress_reports.week_start >=', $rangeStart)
->where('class_progress_reports.week_start <=', $rangeEnd);
}
}
private function hasAnyExplicitProgressSchoolYearRows(): bool
{
try {
return db_connect()
->table('class_progress_reports')
->where('school_year IS NOT NULL', null, false)
->where('school_year !=', '')
->limit(1)
->get()
->getRowArray() !== null;
} catch (\Throwable $e) {
log_message('warning', 'Unable to check explicit progress school-year rows: {message}', [
'message' => $e->getMessage(),
]);
return true;
}
}
private function availableSchoolYears(string $selectedYear): array
{
$years = [];
$addYear = static function (mixed $value) use (&$years): void {
$year = trim((string) $value);
if ($year !== '' && ! in_array($year, $years, true)) {
$years[] = $year;
}
};
$addYear($selectedYear);
$addYear($this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? '')));
$db = db_connect();
foreach ([
['table' => 'school_years', 'column' => 'name'],
['table' => 'class_progress_reports', 'column' => 'school_year'],
['table' => 'classSection', 'column' => 'school_year'],
['table' => 'student_class', 'column' => 'school_year'],
] as $source) {
try {
if (! $db->tableExists($source['table']) || ! $db->fieldExists($source['column'], $source['table'])) {
continue;
}
$rows = $db->table($source['table'])
->select($source['column'])
->distinct()
->where($source['column'] . ' IS NOT NULL', null, false)
->orderBy($source['column'], 'DESC')
->get()
->getResultArray();
foreach ($rows as $row) {
$addYear($row[$source['column']] ?? '');
}
} catch (\Throwable $e) {
log_message('warning', 'Unable to load school-year options for admin progress: {message}', [
'message' => $e->getMessage(),
]);
}
}
rsort($years, SORT_NATURAL);
return $years;
}
public function view($id)
{
$row = $this->reportModel
@@ -277,11 +444,15 @@ class AdminProgressController extends BaseController
return checkdate($m, $d, $y) ? $value : '';
}
protected function buildSemesterDates(): array
protected function buildSemesterDates(?string $schoolYear = null): array
{
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
$schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string) ($this->configModel->getConfig('school_year') ?? '');
$schoolYear = trim((string) ($schoolYear ?? ''));
if ($schoolYear === '') {
$schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
}
$semester = (string) (getSemester() ?? '');
$schoolYearForRange = $schoolYear !== '' ? $schoolYear : $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYearForRange);
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
if ($semesterNorm !== '' && $schoolYearForRange !== '') {
@@ -0,0 +1,193 @@
<?php
namespace App\Controllers\Administrator;
use App\Controllers\BaseController;
use App\Models\FinancialAidEstimateModel;
use App\Models\FinancialAidRequestModel;
use App\Models\InvoiceModel;
use App\Models\StudentModel;
use App\Models\UserModel;
use InvalidArgumentException;
use Throwable;
class FinancialAidController extends BaseController
{
public function index()
{
$schoolYear = $this->request->getGet('school_year');
$model = new FinancialAidRequestModel();
$builder = $model->orderBy('created_at', 'DESC');
if (is_string($schoolYear) && trim($schoolYear) !== '') {
$builder->where('school_year', trim($schoolYear));
}
$requests = $builder->findAll();
$parentIds = array_values(array_unique(array_filter(array_map('intval', array_column($requests, 'parent_id')))));
$parents = [];
if ($parentIds !== []) {
foreach ((new UserModel())->select('id, firstname, lastname, email')->whereIn('id', $parentIds)->findAll() as $parent) {
$parents[(int) $parent['id']] = $parent;
}
}
return view('administrator/financial_aid_queue', [
'requests' => $requests,
'parents' => $parents,
'schoolYear' => is_string($schoolYear) ? trim($schoolYear) : '',
]);
}
public function show(int $id)
{
$request = (new FinancialAidRequestModel())->find($id);
if ($request === null) {
return redirect()->to('/administrator/financial-aid')->with('error', 'Financial aid request was not found.');
}
$parent = (new UserModel())->find((int) $request['parent_id']);
$studentIds = json_decode((string) ($request['student_ids_json'] ?? '[]'), true);
$studentIds = is_array($studentIds) ? array_map('intval', $studentIds) : [];
$students = $studentIds !== []
? (new StudentModel())->whereIn('id', $studentIds)->findAll()
: [];
$schoolYear = (string) ($request['school_year'] ?? '');
$invoiceBalance = $this->invoiceBalanceForParent((int) ($request['parent_id'] ?? 0), $schoolYear);
$householdSize = max(1, (int) ($request['household_size'] ?? 1));
$householdIncome = (float) ($request['household_income'] ?? 0);
$requestedAmount = (float) ($request['requested_amount'] ?? 0);
$estimateModel = new FinancialAidEstimateModel();
$estimatedAid = $estimateModel->estimatedAid($invoiceBalance, $householdSize, $householdIncome);
$finalRebate = $estimateModel->finalRebate($invoiceBalance, $householdSize, $householdIncome, $requestedAmount);
return view('administrator/financial_aid_review', [
'requestRow' => $request,
'parent' => $parent,
'students' => $students,
'schoolYear' => $schoolYear,
'invoiceBalance' => $invoiceBalance,
'estimatedAid' => $estimatedAid,
'finalRebate' => $finalRebate,
]);
}
public function estimate(int $id)
{
$request = (new FinancialAidRequestModel())->find($id);
if ($request === null) {
return $this->response->setStatusCode(404)->setJSON(['error' => 'Financial aid request was not found.']);
}
$householdIncomeRaw = trim((string) $this->request->getPost('household_income'));
$householdSizeRaw = (int) $this->request->getPost('household_size');
$schoolYear = (string) ($request['school_year'] ?? '');
$invoiceBalance = $this->invoiceBalanceForParent((int) ($request['parent_id'] ?? 0), $schoolYear);
if ($householdIncomeRaw === '' || ! is_numeric($householdIncomeRaw) || (float) $householdIncomeRaw < 0) {
return $this->response->setStatusCode(422)->setJSON(['error' => 'Enter a valid household income.']);
}
if ($householdSizeRaw < 1) {
return $this->response->setStatusCode(422)->setJSON(['error' => 'Household size must be at least 1.']);
}
try {
$estimateModel = new FinancialAidEstimateModel();
$householdIncome = round((float) $householdIncomeRaw, 2);
$estimatedAid = $estimateModel->estimatedAid($invoiceBalance, $householdSizeRaw, $householdIncome);
$finalRebate = $estimateModel->finalRebate(
$invoiceBalance,
$householdSizeRaw,
$householdIncome,
(float) ($request['requested_amount'] ?? 0)
);
return $this->response->setJSON([
'invoice_balance' => $invoiceBalance,
'estimated_aid' => $estimatedAid,
'final_rebate' => $finalRebate,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
} catch (InvalidArgumentException $e) {
return $this->response->setStatusCode(422)->setJSON(['error' => $e->getMessage()]);
}
}
public function approve(int $id)
{
try {
$model = new FinancialAidRequestModel();
$request = $model->find($id);
if ($request === null) {
return redirect()->to('/administrator/financial-aid')->with('error', 'Financial aid request was not found.');
}
if (! in_array((string) ($request['status'] ?? ''), ['submitted', 'under_review'], true)) {
return redirect()->back()->with('error', 'Only open requests can be approved.');
}
$amount = $this->approvalAmount($request);
$note = trim((string) $this->request->getPost('admin_note'));
service('financialAid')->applyApprovedAmount($request, $amount, (int) session()->get('user_id'), $note);
return redirect()->to('/administrator/financial-aid')->with('success', 'Financial aid was approved and applied to the invoice.');
} catch (Throwable $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
}
public function deny(int $id)
{
$model = new FinancialAidRequestModel();
$request = $model->find($id);
if ($request === null) {
return redirect()->to('/administrator/financial-aid')->with('error', 'Financial aid request was not found.');
}
if (! in_array((string) ($request['status'] ?? ''), ['submitted', 'under_review'], true)) {
return redirect()->back()->with('error', 'Only open requests can be denied.');
}
$note = trim((string) $this->request->getPost('admin_note'));
if ($note === '') {
return redirect()->back()->with('error', 'A denial note is required.');
}
$model->update($id, [
'status' => 'denied',
'admin_note' => $note,
'reviewed_by' => (int) session()->get('user_id'),
'reviewed_at' => date('Y-m-d H:i:s'),
]);
return redirect()->to('/administrator/financial-aid')->with('success', 'Financial aid request was denied.');
}
private function approvalAmount(array $request): float
{
$postedAmount = trim((string) $this->request->getPost('admin_amount'));
if ($postedAmount !== '') {
return (float) $postedAmount;
}
return (float) ($request['requested_amount'] ?? 0);
}
private function invoiceBalanceForParent(int $parentId, string $schoolYear): float
{
if ($parentId <= 0 || $schoolYear === '') {
return 0.0;
}
$invoice = (new InvoiceModel())
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->first();
if ($invoice === null) {
return 0.0;
}
return round((float) ($invoice['balance'] ?? 0), 2);
}
}
@@ -0,0 +1,261 @@
<?php
namespace App\Controllers\Administrator;
use App\Controllers\BaseController;
use App\Models\SchoolYearModel;
use App\Support\SchoolYear\SchoolYearStatus;
use Throwable;
class SchoolYearClosingController extends BaseController
{
public function preview(int $id)
{
try {
$targetId = $this->normalizeInt($this->request->getGet('target_school_year_id'));
$source = (new SchoolYearModel())->find($id);
if ($targetId === null && (string) ($source['status'] ?? '') === SchoolYearStatus::ACTIVE) {
$target = service('schoolYearManagement')->ensureNextDraftForClosing($id, $this->userId());
$targetId = (int) ($target['id'] ?? 0) ?: null;
}
$preview = service('schoolYearClosing')->preview($id, $targetId);
$promotionTable = $this->promotionTablePayload($preview['promotion']['rows'] ?? []);
$carryForwardTable = $this->carryForwardTablePayload($preview['carry_forward'] ?? []);
$latestBatch = service('schoolYearClosing')->latestBatch($id);
return view('school_years/closing_preview', [
'preview' => $preview,
'promotionTable' => $promotionTable,
'carryForwardTable' => $carryForwardTable,
'latestBatch' => $latestBatch,
'missingCarryForwardInvoices' => $this->missingCarryForwardInvoiceCount($latestBatch),
'schoolYears' => (new SchoolYearModel())->orderBy('name', 'DESC')->findAll(),
]);
} catch (Throwable $e) {
return redirect()->to('/administrator/school-years')->with('error', $e->getMessage());
}
}
public function previewByName(string $name)
{
try {
$year = (new SchoolYearModel())
->where('name', rawurldecode($name))
->first();
if ($year === null) {
return redirect()->to('/administrator/school-years')->with('error', 'School year was not found.');
}
return $this->preview((int) $year['id']);
} catch (Throwable $e) {
return redirect()->to('/administrator/school-years')->with('error', $e->getMessage());
}
}
public function start(int $id)
{
try {
$targetId = $this->normalizeInt($this->request->getPost('target_school_year_id'));
if ($targetId === null) {
return redirect()->back()->with('error', 'Select a target school year.');
}
service('schoolYearClosing')->start($id, $targetId, $this->userId());
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'End-year process started.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function execute(int $id)
{
try {
service('schoolYearClosing')->execute($id, $this->userId());
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'Carry-forward confirmed.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function complete(int $id)
{
try {
service('schoolYearClosing')->complete($id, $this->userId());
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'School year ended and closed.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function cancel(int $id)
{
try {
service('schoolYearClosing')->cancel($id, $this->userId());
return redirect()->to('/administrator/school-years')->with('success', 'End-year process cancelled.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
private function normalizeInt(mixed $value): ?int
{
return is_numeric($value) && (int) $value > 0 ? (int) $value : null;
}
private function missingCarryForwardInvoiceCount(?array $batch): int
{
if ($batch === null || ! in_array((string) ($batch['status'] ?? ''), ['executed', 'completed'], true)) {
return 0;
}
$db = \Config\Database::connect();
if (! $db->tableExists('school_year_closing_items')) {
return 0;
}
return $db->table('school_year_closing_items')
->where('closing_batch_id', (int) $batch['id'])
->groupStart()
->where('target_invoice_id', null)
->orWhere('target_invoice_id', 0)
->groupEnd()
->countAllResults();
}
private function promotionTablePayload(array $rows): array
{
$allowedSorts = ['student', 'school_id', 'class', 'year_score', 'decision', 'source', 'queue', 'target', 'status'];
$sort = 'class';
$order = 'asc';
$perPage = 25;
$allowedPerPage = [10, 25, 50, 100];
usort($rows, function (array $a, array $b) use ($sort, $order): int {
$comparison = $this->comparePromotionRows($a, $b, $sort);
if ($comparison === 0 && $sort !== 'class') {
$comparison = $this->comparePromotionRows($a, $b, 'class');
}
if ($comparison === 0 && $sort !== 'student') {
$comparison = $this->comparePromotionRows($a, $b, 'student');
}
if ($comparison === 0) {
$comparison = ((int) ($a['student_id'] ?? 0)) <=> ((int) ($b['student_id'] ?? 0));
}
return $order === 'desc' ? -$comparison : $comparison;
});
$total = count($rows);
return [
'rows' => $rows,
'sort' => $sort,
'order' => $order,
'page' => 1,
'perPage' => $perPage,
'total' => $total,
'pageCount' => 1,
'allowedPerPage' => $allowedPerPage,
'from' => $total === 0 ? 0 : 1,
'to' => $total,
];
}
private function comparePromotionRows(array $a, array $b, string $sort): int
{
$aValue = $this->promotionSortValue($a, $sort);
$bValue = $this->promotionSortValue($b, $sort);
if ($sort === 'year_score') {
if ($aValue === null && $bValue === null) {
return 0;
}
if ($aValue === null) {
return 1;
}
if ($bValue === null) {
return -1;
}
return $aValue <=> $bValue;
}
return strnatcasecmp((string) $aValue, (string) $bValue);
}
private function promotionSortValue(array $row, string $sort): mixed
{
return match ($sort) {
'student' => (string) ($row['student_name'] ?? ''),
'school_id' => (string) ($row['school_id'] ?? ''),
'class' => (string) ($row['class_section_name'] ?? ''),
'year_score' => is_numeric($row['year_score'] ?? null) ? (float) $row['year_score'] : null,
'decision' => (string) ($row['decision'] ?? ''),
'source' => (string) ($row['source'] ?? ''),
'queue' => (string) ($row['queue_status'] ?? ''),
'target' => trim((string) ($row['target_section'] ?? '') . ' ' . (string) ($row['target_class'] ?? '')),
'status' => (string) ($row['status'] ?? ''),
default => '',
};
}
private function carryForwardTablePayload(array $rows): array
{
$allowedSorts = ['family', 'parent', 'source_balance', 'credits', 'adjustments', 'net'];
$sort = (string) ($this->request->getGet('cf_sort') ?? 'family');
$sort = in_array($sort, $allowedSorts, true) ? $sort : 'family';
$order = strtolower((string) ($this->request->getGet('cf_order') ?? 'asc')) === 'desc' ? 'desc' : 'asc';
usort($rows, function (array $a, array $b) use ($sort, $order): int {
$comparison = $this->compareCarryForwardRows($a, $b, $sort);
if ($comparison === 0 && $sort !== 'family') {
$comparison = $this->compareCarryForwardRows($a, $b, 'family');
}
if ($comparison === 0) {
$comparison = ((int) ($a['family_id'] ?? 0)) <=> ((int) ($b['family_id'] ?? 0));
}
return $order === 'desc' ? -$comparison : $comparison;
});
return [
'rows' => $rows,
'sort' => $sort,
'order' => $order,
];
}
private function compareCarryForwardRows(array $a, array $b, string $sort): int
{
$numericSorts = ['source_balance', 'credits', 'adjustments', 'net'];
$aValue = $this->carryForwardSortValue($a, $sort);
$bValue = $this->carryForwardSortValue($b, $sort);
if (in_array($sort, $numericSorts, true)) {
return (float) $aValue <=> (float) $bValue;
}
return strnatcasecmp((string) $aValue, (string) $bValue);
}
private function carryForwardSortValue(array $row, string $sort): mixed
{
return match ($sort) {
'family' => (string) ($row['family'] ?? ''),
'parent' => trim((string) ($row['parent'] ?? '') . ' ' . (string) ($row['parent_email'] ?? '')),
'source_balance' => (float) ($row['source_balance'] ?? 0),
'credits' => (float) ($row['credit_amount'] ?? 0),
'adjustments' => (float) ($row['adjustment_amount'] ?? 0),
'net' => (float) ($row['carry_forward_amount'] ?? 0),
default => '',
};
}
private function userId(): ?int
{
$id = session('user_id') ?? session('id');
return is_numeric($id) ? (int) $id : null;
}
}
@@ -0,0 +1,221 @@
<?php
namespace App\Controllers\Administrator;
use App\Controllers\BaseController;
use App\Models\ConfigurationModel;
use App\Models\SchoolYearModel;
use App\Support\SchoolYear\SchoolYearStatus;
use Throwable;
class SchoolYearController extends BaseController
{
private SchoolYearModel $schoolYearModel;
public function __construct()
{
$this->schoolYearModel = new SchoolYearModel();
}
public function index()
{
$schoolYears = $this->schoolYearModel
->orderBy('name', 'DESC')
->findAll();
$activeYear = null;
$nextDraftYear = null;
$closingYear = null;
$archivedCount = 0;
foreach ($schoolYears as $year) {
$status = (string) ($year['status'] ?? '');
if ($status === SchoolYearStatus::ACTIVE && $activeYear === null) {
$activeYear = $year;
}
if ($status === SchoolYearStatus::DRAFT && $nextDraftYear === null) {
$nextDraftYear = $year;
}
if ($status === SchoolYearStatus::CLOSING && $closingYear === null) {
$closingYear = $year;
}
if ($status === SchoolYearStatus::ARCHIVED) {
$archivedCount++;
}
}
$configurationModel = new ConfigurationModel();
return view('school_years/index', [
'schoolYears' => $schoolYears,
'statuses' => SchoolYearStatus::ALL,
'activeYear' => $activeYear,
'nextDraftYear' => $nextDraftYear,
'nextDraftDefaults' => service('schoolYearManagement')->nextDraftDefaults(),
'closingYear' => $closingYear,
'archivedCount' => $archivedCount,
'latestTransitions' => service('schoolYearManagement')->latestTransitionByYear(),
'yearVerification' => $this->verificationByYear($schoolYears),
'schoolYearNamesById' => array_column($schoolYears, 'name', 'id'),
'totalInstructionalWeeks' => $configurationModel->getConfigValueByKey('total_instructional_weeks'),
]);
}
public function store()
{
try {
service('schoolYearManagement')->createDraft($this->request->getPost(), $this->userId());
return redirect()->to('/administrator/school-years')->with('success', 'Draft school year created.');
} catch (Throwable $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
}
public function update(int $id)
{
try {
service('schoolYearManagement')->updateMetadata($id, $this->request->getPost(), $this->userId());
return redirect()->to('/administrator/school-years')->with('success', 'School year metadata updated.');
} catch (Throwable $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
}
public function activate(int $id)
{
try {
if ($this->request->getPost('confirm_activation') !== '1') {
return redirect()->to('/administrator/school-years')->with('error', 'Activation confirmation is required.');
}
service('schoolYearManagement')->activate($id, $this->userId());
return redirect()->to('/administrator/school-years')->with('success', 'School year activated. Any previous active year is now in closing.');
} catch (Throwable $e) {
return redirect()->to('/administrator/school-years')->with('error', $e->getMessage());
}
}
public function deleteDraft(int $id)
{
try {
service('schoolYearManagement')->deleteDraft($id, $this->userId());
return redirect()->to('/administrator/school-years')->with('success', 'Draft school year deleted.');
} catch (Throwable $e) {
return redirect()->to('/administrator/school-years')->with('error', $e->getMessage());
}
}
public function archive(int $id)
{
try {
service('schoolYearManagement')->archive($id, $this->userId());
return redirect()->to('/administrator/school-years')->with('success', 'School year archived.');
} catch (Throwable $e) {
return redirect()->to('/administrator/school-years')->with('error', $e->getMessage());
}
}
public function reopen(int $id)
{
try {
service('schoolYearManagement')->reopen($id, (string) $this->request->getPost('reason'), $this->userId());
return redirect()->to('/administrator/school-years')->with('success', 'School year reopened.');
} catch (Throwable $e) {
return redirect()->to('/administrator/school-years')->with('error', $e->getMessage());
}
}
private function userId(): ?int
{
$id = session('user_id') ?? session('id');
return is_numeric($id) ? (int) $id : null;
}
private function verificationByYear(array $schoolYears): array
{
$db = db_connect();
$verification = [];
$hasStudentClass = $db->tableExists('student_class');
$hasStudentDecisions = $db->tableExists('student_decisions');
$hasPromotionQueue = $db->tableExists('promotion_queue');
$hasInvoices = $db->tableExists('invoices');
$hasClosingBatches = $db->tableExists('school_year_closing_batches');
foreach ($schoolYears as $year) {
$id = (int) ($year['id'] ?? 0);
$name = (string) ($year['name'] ?? '');
if ($id <= 0 || $name === '') {
continue;
}
$summary = [
'students' => 0,
'decisions' => 0,
'promoted' => 0,
'queued' => 0,
'carry_forward_balance' => 0.0,
'positive_balance' => 0.0,
'credit_balance' => 0.0,
'closing_status' => '',
];
if ($hasStudentClass && $db->fieldExists('school_year', 'student_class')) {
$row = $db->table('student_class')
->select('COUNT(DISTINCT student_id) AS total', false)
->where('school_year', $name)
->get()
->getRowArray();
$summary['students'] = (int) ($row['total'] ?? 0);
}
if ($hasStudentDecisions && $db->fieldExists('school_year', 'student_decisions')) {
$row = $db->table('student_decisions')
->select('COUNT(DISTINCT student_id) AS decisions', false)
->select("COUNT(DISTINCT CASE WHEN LOWER(decision) = 'pass' THEN student_id END) AS promoted", false)
->where('school_year', $name)
->get()
->getRowArray();
$summary['decisions'] = (int) ($row['decisions'] ?? 0);
$summary['promoted'] = (int) ($row['promoted'] ?? 0);
}
if ($hasPromotionQueue && $db->fieldExists('school_year_from', 'promotion_queue')) {
$row = $db->table('promotion_queue')
->select('COUNT(DISTINCT student_id) AS total', false)
->where('school_year_from', $name)
->get()
->getRowArray();
$summary['queued'] = (int) ($row['total'] ?? 0);
}
if ($hasInvoices && $db->fieldExists('school_year', 'invoices')) {
$row = $db->table('invoices')
->select('COALESCE(SUM(balance), 0) AS carry_forward_balance')
->select('COALESCE(SUM(CASE WHEN balance > 0 THEN balance ELSE 0 END), 0) AS positive_balance', false)
->select('COALESCE(SUM(CASE WHEN balance < 0 THEN ABS(balance) ELSE 0 END), 0) AS credit_balance', false)
->where('school_year', $name)
->get()
->getRowArray();
$summary['carry_forward_balance'] = round((float) ($row['carry_forward_balance'] ?? 0), 2);
$summary['positive_balance'] = round((float) ($row['positive_balance'] ?? 0), 2);
$summary['credit_balance'] = round((float) ($row['credit_balance'] ?? 0), 2);
}
if ($hasClosingBatches) {
$batch = $db->table('school_year_closing_batches')
->select('status')
->where('source_school_year_id', $id)
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
$summary['closing_status'] = (string) ($batch['status'] ?? '');
}
$verification[$id] = $summary;
}
return $verification;
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Controllers\Administrator;
use App\Controllers\BaseController;
use Throwable;
class WithdrawalFinancialController extends BaseController
{
public function review(int $enrollmentId)
{
try {
$calculation = service('withdrawalFinancial')->latestForEnrollment($enrollmentId);
if ($calculation === null || ($calculation['status'] ?? '') === 'superseded') {
$calculation = service('withdrawalFinancial')->preview($enrollmentId, $this->userId());
} elseif ($this->hasStaleInstructionalWeeksBlocker($calculation)) {
$calculation = service('withdrawalFinancial')->preview($enrollmentId, $this->userId());
}
return view('withdrawals/review', [
'calculation' => $this->withPostingGateBlockers($calculation),
]);
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function recalculate(int $enrollmentId)
{
try {
$calculation = service('withdrawalFinancial')->preview($enrollmentId, $this->userId(), [
'enrollment_date' => (string) $this->request->getPost('enrollment_date'),
'withdrawal_request_date' => (string) $this->request->getPost('withdrawal_request_date'),
'override_reason' => (string) $this->request->getPost('override_reason'),
'legacy_invoice_confirmed' => (string) $this->request->getPost('legacy_invoice_confirmed') === '1',
]);
return redirect()->to('/administrator/withdrawals/' . $enrollmentId . '/review')
->with('success', 'Calculation preview version ' . (int) $calculation['version'] . ' saved.');
} catch (Throwable $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
}
public function confirm(int $calculationId)
{
try {
$calculation = service('withdrawalFinancial')->post($calculationId, $this->userId());
return redirect()->to('/administrator/withdrawal-calculations/' . $calculationId)
->with('success', ((int) ($calculation['new_refund_request_cents'] ?? 0)) > 0
? 'Withdrawal posted and the refund request was created.'
: 'Withdrawal posted. No refund is due; the invoice balance was recalculated.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function calculation(int $calculationId)
{
try {
$calculation = service('withdrawalFinancial')->details($calculationId);
if ($this->hasStaleInstructionalWeeksBlocker($calculation)) {
$calculation = service('withdrawalFinancial')->preview((int) $calculation['enrollment_id'], $this->userId());
return redirect()->to('/administrator/withdrawal-calculations/' . (int) $calculation['id'])
->with('success', 'Calculation preview refreshed with the configured total instructional weeks.');
}
return view('withdrawals/calculation', [
'calculation' => $calculation,
]);
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function invoiceSummary(int $invoiceId)
{
try {
return view('withdrawals/invoice_summary', [
'calculations' => service('withdrawalFinancial')->calculationsForInvoice($invoiceId),
'invoiceId' => $invoiceId,
]);
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
private function hasStaleInstructionalWeeksBlocker(array $calculation): bool
{
if (($calculation['status'] ?? '') !== 'requires_review' || ! empty($calculation['posted_at'])) {
return false;
}
$blockers = array_map('strval', (array) ($calculation['blockers'] ?? []));
return in_array('Set a positive total_instructional_weeks value for this school year.', $blockers, true)
|| in_array('Set a positive total_instructional_weeks value in configuration.', $blockers, true);
}
/** @return array<string,mixed> */
private function withPostingGateBlockers(array $calculation): array
{
$blockers = array_values(array_filter(array_map('strval', (array) ($calculation['blockers'] ?? []))));
foreach (service('withdrawalFinancial')->postingGateBlockers($calculation) as $blocker) {
if (! in_array($blocker, $blockers, true)) {
$blockers[] = $blocker;
}
}
$calculation['blockers'] = $blockers;
if (isset($calculation['explanation']) && is_array($calculation['explanation'])) {
$calculation['explanation']['blockers'] = $blockers;
}
return $calculation;
}
private function userId(): ?int
{
$id = session()->get('user_id');
return $id === null || $id === '' ? null : (int) $id;
}
}
View File
+92 -40
View File
@@ -5,7 +5,6 @@ namespace App\Controllers;
use App\Models\LoginActivityModel;
use App\Models\UserModel;
use App\Models\UserRoleModel;
use CodeIgniter\Controller;
use CodeIgniter\Events\Events;
use App\Models\IpAttemptModel;
use App\Models\PasswordResetModel;
@@ -19,7 +18,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php';
require_once APPPATH . 'Helpers/jwt_helper.php';
class AuthController extends Controller
class AuthController extends BaseController
{
protected $configModel;
protected $userModel;
@@ -39,7 +38,7 @@ class AuthController extends Controller
$this->loginActivityModel = new LoginActivityModel();
$this->preferencesModel = new PreferencesModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
}
@@ -83,9 +82,23 @@ class AuthController extends Controller
log_message('info', 'Processing login form submission.');
$redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? $this->request->getGet('redirect_to') ?? ''));
$validator = \Config\Services::validation();
$requestData = sanitize_request_value($this->request->getPost(['email', 'password']));
$validator->setRules([
'email' => 'required|valid_email|max_length[254]',
'password' => 'required|max_length[255]',
]);
if (! $validator->run($requestData)) {
return redirect()
->back()
->with('error', 'Please enter a valid email and password.')
->withInput();
}
// Step 1: Get email, password, and IP from the request
$email = $this->request->getPost('email');
$password = $this->request->getPost('password');
$email = strtolower((string) $requestData['email']);
$password = (string) $requestData['password'];
$ip = $this->request->getIPAddress();
log_message('info', 'Login attempt from IP: ' . $ip . ' for email: ' . $email);
@@ -139,26 +152,29 @@ class AuthController extends Controller
// JSON API: POST /api/login
public function apiLogin()
{
$requestData = $this->request->getJSON(true);
if (!$requestData) {
// fallback to form vars
$requestData = [
'email' => $this->request->getPost('email'),
'password' => $this->request->getPost('password'),
];
}
$requestData = sanitize_request_value($this->request->getJSON(true) ?: [
'email' => $this->request->getPost('email'),
'password' => $this->request->getPost('password'),
]);
$email = $requestData['email'] ?? '';
$password = $requestData['password'] ?? '';
$ip = $this->request->getIPAddress();
$validation = \Config\Services::validation();
$validation->setRules([
'email' => 'required|valid_email|max_length[254]',
'password' => 'required|max_length[255]',
]);
if (!$email || !$password) {
return $this->response->setStatusCode(400)->setJSON([
if (! $validation->run($requestData)) {
return $this->response->setStatusCode(422)->setJSON([
'status' => false,
'message' => 'Email and password are required.'
'message' => 'Validation failed.',
'errors' => $validation->getErrors(),
]);
}
$email = strtolower((string) ($requestData['email'] ?? ''));
$password = (string) ($requestData['password'] ?? '');
$ip = $this->request->getIPAddress();
if ($this->isIpBlocked($ip)) {
return $this->response->setStatusCode(429)->setJSON([
'status' => false,
@@ -195,13 +211,7 @@ class AuthController extends Controller
$this->logLoginAttempt($user['id'], $user['email'], $ip, $this->request->getUserAgent());
// Fetch roles
$userRoleModel = new UserRoleModel();
$rolesRows = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
->where('user_roles.user_id', $user['id'])
->get()
->getResultArray();
$roleNames = array_column($rolesRows, 'name');
$roleNames = $this->getUserRoleNames((int) $user['id']);
// Build roles map (object with keys per example)
$rolesMap = [];
@@ -220,7 +230,15 @@ class AuthController extends Controller
'exp' => $exp,
];
$secret = env('JWT_SECRET', 'change-me-in-env');
try {
$secret = require_env('JWT_SECRET');
} catch (\RuntimeException $e) {
return $this->response->setStatusCode(500)->setJSON([
'status' => false,
'message' => 'JWT configuration is missing.',
]);
}
$token = jwt_encode($payload, $secret, 'HS256');
return $this->response->setJSON([
@@ -234,24 +252,46 @@ class AuthController extends Controller
]);
}
public function adminAuthMe()
{
if (! session()->get('is_logged_in')) {
return $this->response->setStatusCode(401)->setJSON([
'status' => false,
'message' => 'Unauthenticated.',
]);
}
$userId = (int) session()->get('user_id');
$roles = array_values(array_filter((array) (session()->get('roles') ?? [])));
$activeRole = session()->get('role');
return $this->response->setJSON([
'status' => true,
'user' => [
'id' => $userId,
'email' => session()->get('user_email'),
'name' => session()->get('user_name'),
'type' => session()->get('user_type'),
'roles' => $roles,
'role' => $activeRole,
],
]);
}
/**
* API Registration endpoint
* POST /api/v1/register
*/
public function apiRegister()
{
$requestData = $this->request->getJSON(true);
if (!$requestData) {
// fallback to form vars
$requestData = $this->request->getPost();
}
$requestData = sanitize_request_value($this->request->getJSON(true) ?: $this->request->getPost());
// Basic validation
$rules = [
'firstname' => 'required|min_length[2]|max_length[30]',
'lastname' => 'required|min_length[2]|max_length[30]',
'email' => 'required|valid_email|is_unique[users.email]',
'password' => 'required|min_length[8]',
'firstname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
'lastname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
'email' => 'required|valid_email|max_length[254]|is_unique[users.email]',
'password' => 'required|min_length[8]|max_length[255]',
'cellphone' => 'required|min_length[10]|max_length[20]',
];
@@ -292,7 +332,7 @@ class AuthController extends Controller
'state' => $requestData['state'] ?? null,
'zip' => $requestData['zip'] ?? null,
'school_year' => $this->configModel->getConfig('school_year'),
'semester' => $this->configModel->getConfig('semester'),
'semester' => getSemester(),
'status' => 'active',
'is_verified' => 0, // Require email verification
];
@@ -332,7 +372,7 @@ class AuthController extends Controller
'exp' => $exp,
];
$secret = env('JWT_SECRET', 'change-me-in-env');
$secret = require_env('JWT_SECRET');
$token = jwt_encode($payload, $secret, 'HS256');
return $this->response->setStatusCode(201)->setJSON([
@@ -434,6 +474,18 @@ class AuthController extends Controller
$this->userModel->update($userId, ['failed_attempts' => 0, 'last_failed_at' => null]);
}
protected function getUserRoleNames(int $userId): array
{
$userRoleModel = new UserRoleModel();
$rolesRows = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
->where('user_roles.user_id', $userId)
->get()
->getResultArray();
return array_column($rolesRows, 'name');
}
public function logout()
{
// Get user ID and email from session
@@ -526,6 +578,7 @@ class AuthController extends Controller
$roleNames = array_column($roles, 'name');
session()->regenerate(true);
session()->set([
'user_id' => $user['id'],
'user_email' => $user['email'],
@@ -533,13 +586,12 @@ class AuthController extends Controller
'user_type' => $user['user_type'],
'is_logged_in' => true,
'login_time' => time(),
'last_activity' => time(),
'roles' => $roleNames,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
]);
$this->applyStylePreferences((int) $user['id']);
log_message('debug', 'Session after login: ' . print_r(session()->get(), true));
//dd('Login successful. Roles:', $roleNames, session()->get());
if (count($roleNames) === 1) {
// One role → set and redirect directly
+155 -2
View File
@@ -9,6 +9,9 @@ use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
use App\Services\ApiClient;
use App\Exceptions\SchoolYear\SchoolYearConfigurationException;
use App\Support\SchoolYear\SchoolYearContext;
use Throwable;
/**
* Class BaseController
@@ -36,7 +39,7 @@ abstract class BaseController extends Controller
*
* @var list<string>
*/
protected $helpers = [];
protected $helpers = ['security'];
/** @var ApiClient */
protected ApiClient $api;
@@ -58,6 +61,8 @@ abstract class BaseController extends Controller
// Preload any models, libraries, etc, here.
// E.g.: $this->session = \Config\Services::session();
session();
$this->syncSchoolYearPropertyFromContext();
$this->injectSchoolYearViewData();
// Shared API client available to all controllers
$this->api = service('apiClient');
@@ -73,6 +78,154 @@ abstract class BaseController extends Controller
// Assuming the user role is stored in the session
return session()->get('role');
}
protected function validated(array $rules, ?array $data = null): array
{
$validation = service('validation');
$payload = $data ?? ($this->request->getJSON(true) ?: $this->request->getPost());
$payload = sanitize_request_value($payload);
$validation->setRules($rules);
if (! $validation->run($payload)) {
throw new \InvalidArgumentException(json_encode($validation->getErrors(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: 'Validation failed');
}
return $payload;
}
protected function resolveSchoolYearContext(?int $routeSchoolYearId = null): SchoolYearContext
{
try {
return service('schoolYearContext')->resolve($this->request, $routeSchoolYearId);
} catch (Throwable $e) {
log_message('warning', 'School-year resolution failed: {message}', [
'message' => $e->getMessage(),
]);
throw $e;
}
}
protected function currentSchoolYearName(?string $fallback = null): string
{
try {
return $this->resolveSchoolYearContext()->yearName();
} catch (Throwable $e) {
if ($fallback !== null && $fallback !== '') {
return $fallback;
}
try {
return (string) ((new \App\Models\ConfigurationModel())->getConfig('school_year') ?? '');
} catch (Throwable) {
return '';
}
}
}
protected function assertSchoolYearWritable(
SchoolYearContext $context,
bool $allowDraftForAdmin = false,
bool $isAdmin = false
): void {
service('schoolYearWriteGuard')->assertWritable($context, $allowDraftForAdmin, $isAdmin);
}
protected function assertSchoolYearNameWritable(
string $schoolYear,
bool $allowDraftForAdmin = false,
bool $isAdmin = false
): void {
$context = service('schoolYearContext')->forYearName($schoolYear);
$this->assertSchoolYearWritable($context, $allowDraftForAdmin, $isAdmin);
}
private function syncSchoolYearPropertyFromContext(): void
{
if (! property_exists($this, 'schoolYear')) {
return;
}
if (! $this->request instanceof IncomingRequest || is_cli()) {
return;
}
if (! session()->get('user_id')) {
return;
}
try {
$this->schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
} catch (Throwable $e) {
log_message('warning', 'Unable to sync controller school-year property: {message}', [
'message' => $e->getMessage(),
]);
}
}
private function injectSchoolYearViewData(): void
{
if (! config('Feature')->globalSchoolYearSelector) {
return;
}
if (! $this->request instanceof IncomingRequest || is_cli()) {
return;
}
if (! session()->get('user_id')) {
return;
}
if (! $this->shouldShowSchoolYearSelector()) {
return;
}
$accept = strtolower($this->request->getHeaderLine('Accept'));
if ($this->request->isAJAX() || str_contains($accept, 'application/json')) {
return;
}
try {
service('renderer')->setData(service('schoolYearViewData')->forCurrentRequest($this->request));
} catch (SchoolYearConfigurationException $e) {
log_message('warning', 'Unable to inject school-year view data: {message}', [
'message' => $e->getMessage(),
]);
} catch (Throwable $e) {
log_message('warning', 'Unable to inject school-year view data: {message}', [
'message' => $e->getMessage(),
]);
}
}
private function shouldShowSchoolYearSelector(): bool
{
$roles = (array) session()->get('roles');
$singleRole = session()->get('role');
if ($singleRole !== null && $singleRole !== '') {
$roles[] = $singleRole;
}
$roles = array_map(
static fn ($role): string => strtolower(trim((string) $role)),
$roles
);
return (bool) array_intersect($roles, [
'admin',
'administrator',
'administrative staff',
'parent',
'principal',
'teacher',
'teacher assistant',
'teacher_assistant',
'vice principal',
]);
}
}
?>
?>

Some files were not shown because too many files have changed in this diff Show More