diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..1267ea5 Binary files /dev/null and b/.DS_Store differ diff --git a/.env b/.env new file mode 100644 index 0000000..d47d416 --- /dev/null +++ b/.env @@ -0,0 +1,180 @@ +#-------------------------------------------------------------------- +# 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 = '' +# honeypot.container = '
' + +#-------------------------------------------------------------------- +# 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 diff --git a/app/Commands/AttendanceAutoPublishCommand.php b/app/Commands/AttendanceAutoPublishCommand.php old mode 100644 new mode 100755 diff --git a/app/Commands/CheckMissedPayments.php b/app/Commands/CheckMissedPayments.php old mode 100644 new mode 100755 diff --git a/app/Commands/CleanupExpiredNotifications.php b/app/Commands/CleanupExpiredNotifications.php old mode 100644 new mode 100755 diff --git a/app/Commands/CleanupPasswordResets.php b/app/Commands/CleanupPasswordResets.php old mode 100644 new mode 100755 diff --git a/app/Commands/ConfigUpdate.php b/app/Commands/ConfigUpdate.php old mode 100644 new mode 100755 diff --git a/app/Commands/DeleteInactiveUsers.php b/app/Commands/DeleteInactiveUsers.php old mode 100644 new mode 100755 diff --git a/app/Commands/RecalculateAttendance.php b/app/Commands/RecalculateAttendance.php old mode 100644 new mode 100755 diff --git a/app/Commands/SendAbsenteesSummary.php b/app/Commands/SendAbsenteesSummary.php old mode 100644 new mode 100755 diff --git a/app/Commands/SendExamDraftDeadlineReminders.php b/app/Commands/SendExamDraftDeadlineReminders.php new file mode 100755 index 0000000..7adfe80 --- /dev/null +++ b/app/Commands/SendExamDraftDeadlineReminders.php @@ -0,0 +1,197 @@ +getConfig('school_year') ?? ''); + $semester = (string) ($configModel->getConfig('semester') ?? ''); + $deadlineValue = trim((string) ($configModel->getConfig('exam_draft_deadline') ?? '')); + if ($deadlineValue === '') { + CLI::write('exam_draft_deadline is not configured.', 'yellow'); + return; + } + + $tz = new \DateTimeZone(config('App')->appTimezone ?? 'UTC'); + try { + $deadline = new \DateTimeImmutable($deadlineValue, $tz); + } catch (\Throwable $e) { + CLI::write('Invalid exam_draft_deadline value.', 'red'); + return; + } + $deadline = $deadline->setTime(0, 0, 0); + $today = new \DateTimeImmutable('now', $tz); + $today = $today->setTime(0, 0, 0); + + if ($today > $deadline) { + CLI::write('Deadline has passed. No reminders sent.', 'yellow'); + return; + } + + $daysToDeadline = (int) $today->diff($deadline)->format('%r%a'); + if (!$this->shouldSendOnDay($daysToDeadline)) { + CLI::write('No reminder scheduled for today.', 'yellow'); + return; + } + + $db = Database::connect(); + $teacherClassRows = $db->table('teacher_class') + ->select('teacher_id, class_section_id, school_year, semester') + ->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(); + + if (empty($teacherClassRows)) { + CLI::write('No teacher assignments found.', 'yellow'); + return; + } + + $draftTable = 'exam_drafts'; + $fields = $db->getFieldNames($draftTable); + $teacherColumn = in_array('teacher_id', $fields, true) ? 'teacher_id' : 'author_id'; + $hasStatusColumn = in_array('status', $fields, true); + $hasIsLegacyColumn = in_array('is_legacy', $fields, true); + + $draftsQuery = $db->table($draftTable) + ->select("{$teacherColumn} AS teacher_id, class_section_id") + ->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); + }); + + if ($hasStatusColumn) { + $draftsQuery = $draftsQuery->where('status !=', 'legacy'); + } + if ($hasIsLegacyColumn) { + $draftsQuery = $draftsQuery->where('is_legacy', 0); + } + + $draftRows = $draftsQuery->get()->getResultArray(); + $submittedMap = []; + foreach ($draftRows as $row) { + $key = (int) ($row['teacher_id'] ?? 0) . '|' . (int) ($row['class_section_id'] ?? 0); + $submittedMap[$key] = true; + } + + $missingByTeacher = []; + foreach ($teacherClassRows as $row) { + $teacherId = (int) ($row['teacher_id'] ?? 0); + $classSectionId = (int) ($row['class_section_id'] ?? 0); + if ($teacherId <= 0 || $classSectionId <= 0) { + continue; + } + $key = $teacherId . '|' . $classSectionId; + if (!isset($submittedMap[$key])) { + $missingByTeacher[$teacherId][] = $classSectionId; + } + } + + if (empty($missingByTeacher)) { + CLI::write('All teachers have submitted drafts.', 'green'); + return; + } + + $userModel = new UserModel(); + $classSectionModel = new ClassSectionModel(); + $historyModel = new TeacherSubmissionNotificationHistoryModel(); + $mailer = new EmailController(); + + $classSections = $classSectionModel + ->select('class_section_id, class_section_name') + ->findAll(); + $classLookup = []; + foreach ($classSections as $section) { + $classLookup[(int) ($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? ''; + } + + $todayStamp = $today->format('Y-m-d'); + foreach ($missingByTeacher as $teacherId => $sectionIds) { + $teacher = $userModel->find($teacherId); + $email = $teacher['email'] ?? ''; + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + continue; + } + + $alreadySent = $historyModel + ->where('teacher_id', $teacherId) + ->where('notification_category', 'exam_draft_deadline') + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->like('sent_at', $todayStamp) + ->countAllResults() > 0; + if ($alreadySent) { + continue; + } + + $classNames = array_map(static function ($id) use ($classLookup) { + return $classLookup[(int) $id] ?? "Class {$id}"; + }, $sectionIds); + $classList = implode(', ', $classNames); + $teacherName = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')); + if ($teacherName === '') { + $teacherName = 'Teacher'; + } + + $subject = 'Reminder: Exam draft submission deadline'; + $body = 'Dear ' . esc($teacherName) . ',
' + . 'This is a reminder to submit your exam draft(s) for: ' . esc($classList) . '.
' + . 'Deadline: ' . esc($deadline->format('Y-m-d')) . '
' + . 'Please submit your draft at Teacher Exam Drafts.
' + . 'Thank you.
'; + + $status = 'failed'; + if ($mailer->sendEmail($email, $subject, $body, 'notifications')) { + $status = 'sent'; + CLI::write("Sent reminder to {$email}", 'green'); + } + + $historyModel->insert([ + 'teacher_id' => $teacherId, + 'class_section_id' => 0, + 'admin_id' => null, + 'notification_category' => 'exam_draft_deadline', + 'message' => strip_tags($body), + 'status' => $status, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'sent_at' => utc_now(), + ]); + } + } + + private function shouldSendOnDay(int $daysToDeadline): bool + { + if ($daysToDeadline < 0) { + return false; + } + if ($daysToDeadline <= 4) { + return true; + } + return in_array($daysToDeadline, [28, 21, 14, 7], true); + } +} diff --git a/app/Commands/SendLatesSummary.php b/app/Commands/SendLatesSummary.php old mode 100644 new mode 100755 diff --git a/app/Commands/SendMonthlyPaymentNotifications.php b/app/Commands/SendMonthlyPaymentNotifications.php old mode 100644 new mode 100755 diff --git a/app/Commands/SendTestPaymentNotification.php b/app/Commands/SendTestPaymentNotification.php old mode 100644 new mode 100755 diff --git a/app/Commands/SyncPaypalPayments.php b/app/Commands/SyncPaypalPayments.php old mode 100644 new mode 100755 diff --git a/app/Common.php b/app/Common.php old mode 100644 new mode 100755 diff --git a/app/Config/Api.php b/app/Config/Api.php old mode 100644 new mode 100755 diff --git a/app/Config/App.php b/app/Config/App.php old mode 100644 new mode 100755 index 4423270..58df5ce --- a/app/Config/App.php +++ b/app/Config/App.php @@ -82,7 +82,7 @@ class App extends BaseConfig | DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!! | */ - public string $permittedURIChars = 'a-z 0-9~%.:_\-'; + public string $permittedURIChars = 'a-z 0-9~%.:_\-,'; /** * -------------------------------------------------------------------------- diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php old mode 100644 new mode 100755 diff --git a/app/Config/Boot/development.php b/app/Config/Boot/development.php old mode 100644 new mode 100755 diff --git a/app/Config/Boot/production.php b/app/Config/Boot/production.php old mode 100644 new mode 100755 diff --git a/app/Config/Boot/testing.php b/app/Config/Boot/testing.php old mode 100644 new mode 100755 diff --git a/app/Config/CURLRequest.php b/app/Config/CURLRequest.php old mode 100644 new mode 100755 diff --git a/app/Config/Cache.php b/app/Config/Cache.php old mode 100644 new mode 100755 diff --git a/app/Config/Commands.php b/app/Config/Commands.php old mode 100644 new mode 100755 diff --git a/app/Config/Constants.php b/app/Config/Constants.php old mode 100644 new mode 100755 diff --git a/app/Config/ContentSecurityPolicy.php b/app/Config/ContentSecurityPolicy.php old mode 100644 new mode 100755 diff --git a/app/Config/Cookie.php b/app/Config/Cookie.php old mode 100644 new mode 100755 diff --git a/app/Config/Cors.php b/app/Config/Cors.php old mode 100644 new mode 100755 diff --git a/app/Config/Database.php b/app/Config/Database.php old mode 100644 new mode 100755 index 78561d0..0af3e90 --- a/app/Config/Database.php +++ b/app/Config/Database.php @@ -9,43 +9,51 @@ class Database extends Config public string $filesPath = APPPATH . 'Database' . DIRECTORY_SEPARATOR; public string $defaultGroup = 'default'; - public array $default = [ - 'DSN' => '', - 'hostname' => 'localhost', - 'username' => 'u280815660_melabidi', - 'password' => '>tNxlRzP/W8', - 'database' => 'u280815660_school', - 'DBDriver' => 'MySQLi', - 'DBPrefix' => '', - 'pConnect' => false, - 'DBDebug' => (ENVIRONMENT !== 'development'), - 'charset' => 'utf8', - 'DBCollat' => 'utf8_general_ci', - 'swapPre' => '', - 'encrypt' => false, - 'compress' => false, - 'strictOn' => false, - 'failover' => [], - 'port' => 3306, - ]; + public array $default = []; + public array $tests = []; - public array $tests = [ - 'DSN' => '', - 'hostname' => 'localhost', - 'username' => 'u280815660_melabidi', - 'password' => '>tNxlRzP/W8', - 'database' => 'u280815660_school', - 'DBDriver' => 'MySQLi', - 'DBPrefix' => 'db_', - 'pConnect' => false, - 'DBDebug' => true, - 'charset' => 'utf8', - 'DBCollat' => 'utf8_general_ci', - 'swapPre' => '', - 'encrypt' => false, - 'compress' => false, - 'strictOn' => false, - 'failover' => [], - 'port' => 3306, - ]; + public function __construct() + { + parent::__construct(); + + $this->default = [ + 'DSN' => '', + 'hostname' => env('database.default.hostname'), + 'username' => env('database.default.username'), + 'password' => env('database.default.password'), + 'database' => env('database.default.database'), + 'DBDriver' => env('database.default.DBDriver', 'MySQLi'), + 'DBPrefix' => '', + 'pConnect' => false, + 'DBDebug' => (ENVIRONMENT !== 'development'), + 'charset' => 'utf8', + 'DBCollat' => 'utf8_general_ci', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + 'port' => (int) env('database.default.port', 3306), + ]; + + $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')), + 'DBDriver' => env('database.tests.DBDriver', env('database.default.DBDriver', 'MySQLi')), + 'DBPrefix' => env('database.tests.DBPrefix', 'db_'), + 'pConnect' => false, + 'DBDebug' => true, + 'charset' => 'utf8', + 'DBCollat' => 'utf8_general_ci', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + 'port' => (int) env('database.tests.port', env('database.default.port', 3306)), + ]; + } } diff --git a/app/Config/DocTypes.php b/app/Config/DocTypes.php old mode 100644 new mode 100755 diff --git a/app/Config/Email.php b/app/Config/Email.php old mode 100644 new mode 100755 diff --git a/app/Config/Encryption.php b/app/Config/Encryption.php old mode 100644 new mode 100755 diff --git a/app/Config/Events.php b/app/Config/Events.php old mode 100644 new mode 100755 diff --git a/app/Config/Exceptions.php b/app/Config/Exceptions.php old mode 100644 new mode 100755 diff --git a/app/Config/Feature.php b/app/Config/Feature.php old mode 100644 new mode 100755 diff --git a/app/Config/Filters.php b/app/Config/Filters.php old mode 100644 new mode 100755 diff --git a/app/Config/ForeignCharacters.php b/app/Config/ForeignCharacters.php old mode 100644 new mode 100755 diff --git a/app/Config/Format.php b/app/Config/Format.php old mode 100644 new mode 100755 diff --git a/app/Config/Generators.php b/app/Config/Generators.php old mode 100644 new mode 100755 diff --git a/app/Config/Honeypot.php b/app/Config/Honeypot.php old mode 100644 new mode 100755 diff --git a/app/Config/Images.php b/app/Config/Images.php old mode 100644 new mode 100755 diff --git a/app/Config/Kint.php b/app/Config/Kint.php old mode 100644 new mode 100755 diff --git a/app/Config/Logger.php b/app/Config/Logger.php old mode 100644 new mode 100755 diff --git a/app/Config/Migrations.php b/app/Config/Migrations.php old mode 100644 new mode 100755 diff --git a/app/Config/Mimes.php b/app/Config/Mimes.php old mode 100644 new mode 100755 diff --git a/app/Config/Modules.php b/app/Config/Modules.php old mode 100644 new mode 100755 diff --git a/app/Config/Optimize.php b/app/Config/Optimize.php old mode 100644 new mode 100755 diff --git a/app/Config/Pager.php b/app/Config/Pager.php old mode 100644 new mode 100755 diff --git a/app/Config/Paths.php b/app/Config/Paths.php old mode 100644 new mode 100755 diff --git a/app/Config/PaypalConfig.php b/app/Config/PaypalConfig.php old mode 100644 new mode 100755 diff --git a/app/Config/Publisher.php b/app/Config/Publisher.php old mode 100644 new mode 100755 diff --git a/app/Config/Roles.php b/app/Config/Roles.php old mode 100644 new mode 100755 diff --git a/app/Config/Routes.php b/app/Config/Routes.php old mode 100644 new mode 100755 index c722b7c..1ec333d --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -108,6 +108,7 @@ $routes->get('administrator/student-score-card', 'View\StudentController::scoreC // 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']); +$routes->get('api/printables/report-card/ack', 'View\ReportCardsController::reportCardAcknowledgement', ['filter' => 'auth']); //Badges $routes->get('printables_reports/badge_form', 'View\BadgesController::badgeForm'); @@ -229,6 +230,10 @@ $routes->get('reset_password', 'View\UserController::resetPassword'); //$routes->get('/blocked', 'View\UserController::blocked'); +$routes->get('confirm_authorized_user', 'View\AuthorizedUsersController::confirm'); +$routes->get('set_authorized_user_password/(:num)', 'View\AuthorizedUsersController::setPassword/$1'); +$routes->post('set_authorized_user_password/(:num)', 'View\AuthorizedUsersController::savePassword/$1'); + $routes->post('assign_class_student', 'View\StudentController::assignClassStudent'); $routes->post('remove_class_student', 'View\StudentController::removeClassStudent'); $routes->post('administrator/remove_class_student', 'View\StudentController::removeClassStudent'); // alias to avoid 404s @@ -330,6 +335,9 @@ $routes->post('/administrator/scores/update/{id}', 'View\ScoreController::update // Route to delete a score record $routes->get('/administrator/scores/delete/{id}', 'View\ScoreController::destroy'); $routes->get('/parent/scores', 'View\ScoreController::viewStudentScore'); +$routes->get('parent/report-cards', 'ParentReportCardController::index', ['filter' => 'auth:parent']); +$routes->get('parent/report-cards/view/(:num)', 'ParentReportCardController::view/$1', ['filter' => 'auth:parent']); +$routes->post('parent/report-cards/sign/(:num)', 'ParentReportCardController::sign/$1', ['filter' => 'auth:parent']); @@ -355,6 +363,7 @@ $routes->get('/teacher/teacher_contactus', 'View\TeacherController::contactusTea $routes->get('/teacher/exam-drafts', 'View\ExamDraftController::teacherIndex', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']); $routes->post('/teacher/exam-drafts', 'View\ExamDraftController::teacherStore', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']); +$routes->get('/teacher/exam-drafts/status', 'View\ExamDraftController::teacherStatusFeed', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']); @@ -384,8 +393,8 @@ $routes->get('print-requests/file/(:segment)', 'PrintRequests::serveFile/$1', [' $routes->get('print-requests/file/(:segment)/(:alpha)', 'PrintRequests::serveFile/$1/$2', ['filter' => 'auth:teacher,teacher_assistant,admin']); $routes->get('uploads/print_requests/(:segment)', 'PrintRequests::serveFile/$1', ['filter' => 'auth:teacher,teacher_assistant,admin']); -$routes->get('exam-drafts/files/teacher/(:segment)', 'View\FilesController::examDraftTeacher/$1', ['filter' => 'auth:teacher,teacher_assistant,admin']); -$routes->get('exam-drafts/files/final/(:segment)', 'View\FilesController::examDraftFinal/$1', ['filter' => 'auth:teacher,teacher_assistant,admin']); +$routes->get('exam-drafts/files/teacher/(:segment)', 'View\FilesController::examDraftTeacher/$1', ['filter' => 'auth:teacher,teacher_assistant,admin,administrator,principal']); +$routes->get('exam-drafts/files/final/(:segment)', 'View\FilesController::examDraftFinal/$1', ['filter' => 'auth:teacher,teacher_assistant,admin,administrator,principal']); @@ -397,6 +406,8 @@ $routes->get('teacher/progress/submit', 'ClassProgressController::create', ['fil $routes->post('teacher/progress/store', 'ClassProgressController::store', ['filter' => 'auth:teacher,teacher_assistant']); $routes->get('teacher/progress/history', 'ClassProgressController::history', ['filter' => 'auth:teacher,teacher_assistant']); $routes->get('teacher/progress/view/(:num)', 'ClassProgressController::view/$1', ['filter' => 'auth:teacher,teacher_assistant']); +$routes->get('teacher/progress/edit/(:num)', 'ClassProgressController::edit/$1', ['filter' => 'auth:teacher,teacher_assistant']); +$routes->post('teacher/progress/update/(:num)', 'ClassProgressController::update/$1', ['filter' => 'auth:teacher,teacher_assistant']); $routes->get('teacher/progress/attachment/(:num)', 'ClassProgressController::attachment/$1', ['filter' => 'auth:teacher,teacher_assistant']); $routes->get('teacher/progress/attachment-file/(:num)', 'ClassProgressController::attachmentFile/$1', ['filter' => 'auth:teacher,teacher_assistant']); $routes->get('parent/progress', 'ParentProgressController::index', ['filter' => 'auth:parent']); @@ -495,6 +506,7 @@ $routes->get('grading/project/(:num)', 'View\ProjectController::showProjectMngt/ $routes->post('grading/updateProject', 'View\ProjectController::updateProject'); $routes->get('grading/below-60', 'View\GradingController::belowSixty', ['filter' => 'auth:read']); +$routes->get('grading/below-60/email/edit', 'View\GradingController::editBelowSixtyEmail', ['filter' => 'auth:read']); $routes->post('grading/below-60/email', 'View\GradingController::sendBelowSixtyEmail', ['filter' => 'auth:read']); $routes->post('grading/below-60/status', 'View\GradingController::updateBelowSixtyStatus', ['filter' => 'auth:read']); $routes->get('grading/below-60/schedule', 'View\GradingController::scheduleBelowSixty', ['filter' => 'auth:read']); @@ -523,9 +535,12 @@ $routes->post('payment/event_charges', 'View\EventController::eventUpdate'); // 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('parent/events', 'View\ParentController::parentEventPage'); +$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 @@ -730,6 +745,9 @@ $routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorCo $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']); /* * -------------------------------------------------------------------- @@ -998,6 +1016,8 @@ $routes->group('family', static function ($routes) { $routes->get('index', 'View\FamilyAdminController::index'); $routes->get('search', 'View\FamilyAdminController::search'); $routes->get('card', 'View\FamilyAdminController::card'); + $routes->get('compose-email', 'View\FamilyAdminController::composeEmail'); + $routes->post('compose-email/send', 'View\FamilyAdminController::sendComposeEmail'); }); // Convenience alias $routes->get('family', 'View\FamilyAdminController::index'); diff --git a/app/Config/Routing.php b/app/Config/Routing.php old mode 100644 new mode 100755 diff --git a/app/Config/School.php b/app/Config/School.php old mode 100644 new mode 100755 diff --git a/app/Config/Security.php b/app/Config/Security.php old mode 100644 new mode 100755 diff --git a/app/Config/Services.php b/app/Config/Services.php old mode 100644 new mode 100755 diff --git a/app/Config/Session.php b/app/Config/Session.php old mode 100644 new mode 100755 diff --git a/app/Config/SessionTimeout.php b/app/Config/SessionTimeout.php old mode 100644 new mode 100755 diff --git a/app/Config/Style.php b/app/Config/Style.php old mode 100644 new mode 100755 diff --git a/app/Config/Toolbar.php b/app/Config/Toolbar.php old mode 100644 new mode 100755 diff --git a/app/Config/UserAgents.php b/app/Config/UserAgents.php old mode 100644 new mode 100755 diff --git a/app/Config/Validation.php b/app/Config/Validation.php old mode 100644 new mode 100755 diff --git a/app/Config/View.php b/app/Config/View.php old mode 100644 new mode 100755 diff --git a/app/Controllers/Admin/CompetitionWinnersController.php b/app/Controllers/Admin/CompetitionWinnersController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/AdminProgressController.php b/app/Controllers/AdminProgressController.php old mode 100644 new mode 100755 index 5f323ad..3ef8c63 --- a/app/Controllers/AdminProgressController.php +++ b/app/Controllers/AdminProgressController.php @@ -5,7 +5,11 @@ namespace App\Controllers; use App\Models\ClassProgressReportModel; use App\Models\ClassProgressAttachmentModel; use App\Models\ClassSectionModel; +use App\Models\CalendarModel; +use App\Models\ConfigurationModel; use App\Models\StudentClassModel; +use App\Models\SubjectCurriculumModel; +use App\Services\SemesterRangeService; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\ResponseInterface; @@ -15,6 +19,10 @@ class AdminProgressController extends BaseController protected ClassProgressAttachmentModel $attachmentModel; protected ClassSectionModel $classSectionModel; protected StudentClassModel $studentClassModel; + protected CalendarModel $calendarModel; + protected ConfigurationModel $configModel; + protected SemesterRangeService $semesterRangeService; + protected SubjectCurriculumModel $curriculumModel; public function __construct() { @@ -23,6 +31,10 @@ class AdminProgressController extends BaseController $this->attachmentModel = new ClassProgressAttachmentModel(); $this->classSectionModel = new ClassSectionModel(); $this->studentClassModel = new StudentClassModel(); + $this->calendarModel = new CalendarModel(); + $this->configModel = new ConfigurationModel(); + $this->semesterRangeService = new SemesterRangeService($this->configModel); + $this->curriculumModel = new SubjectCurriculumModel(); } public function index() @@ -53,22 +65,30 @@ class AdminProgressController extends BaseController } $rows = $builder->orderBy('week_start', 'DESC')->get()->getResultArray(); - $reportGroups = []; + $reportGroupsBySection = []; foreach ($rows as $row) { $row['status_label'] = ClassProgressController::STATUS_OPTIONS[$row['status']] ?? 'Unknown'; - $key = ($row['week_start'] ?? '') . '_' . ($row['class_section_id'] ?? ''); - if ($key === '_') { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $weekKey = (string) ($row['week_start'] ?? ''); + if ($sectionId === 0 || $weekKey === '') { continue; } - if (! isset($reportGroups[$key])) { - $reportGroups[$key] = [ + if (! isset($reportGroupsBySection[$sectionId])) { + $reportGroupsBySection[$sectionId] = []; + } + if (! isset($reportGroupsBySection[$sectionId][$weekKey])) { + $reportGroupsBySection[$sectionId][$weekKey] = [ 'week_start' => $row['week_start'], 'week_end' => $row['week_end'], 'class_section_name' => $row['class_section_name'] ?? '', 'reports' => [], ]; } - $reportGroups[$key]['reports'][$row['subject']] = $row; + $reportGroupsBySection[$sectionId][$weekKey]['reports'][$row['subject']] = $row; + } + foreach ($reportGroupsBySection as $sectionId => $groups) { + krsort($groups); + $reportGroupsBySection[$sectionId] = $groups; } $classSections = $this->classSectionModel->getClassSections(); @@ -78,12 +98,47 @@ class AdminProgressController extends BaseController return isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0; })); + $filterStart = $this->normalizeDate($filters['from'] ?? ''); + $filterEnd = $this->normalizeDate($filters['to'] ?? ''); + [$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet] = $this->buildSemesterDates(); + [$expectedDays, $activeDatesSet] = $this->resolveExpectedDays( + $dateList, + $noSchoolDays, + $totalPassedDays, + $passedDatesSet, + $filterStart, + $filterEnd + ); + $sectionStats = $this->buildSectionSubmissionStats($rows, $activeDatesSet, $expectedDays); + $sectionSubjectCounts = $this->buildSectionSubjectCounts($rows); + $lowProgressSectionIds = []; + if ($expectedDays > 0) { + foreach ($filteredSections as $section) { + $sectionId = (int) ($section['class_section_id'] ?? 0); + if ($sectionId === 0) { + continue; + } + $stat = $sectionStats[$sectionId] ?? null; + $percent = $stat['percent'] ?? 0; + if ($stat === null) { + $percent = 0; + } + if ($percent < 50) { + $lowProgressSectionIds[] = $sectionId; + } + } + } + return view('admin/class_progress_list', [ - 'reportGroups' => $reportGroups, + 'reportGroupsBySection' => $reportGroupsBySection, 'filters' => $filters, 'classSections' => $filteredSections, 'statusOptions' => ClassProgressController::STATUS_OPTIONS, 'subjectSections' => ClassProgressController::SUBJECT_SECTIONS, + 'sectionStats' => $sectionStats, + 'sectionSubjectCounts' => $sectionSubjectCounts, + 'expectedDays' => $expectedDays, + 'lowProgressSectionIds' => $lowProgressSectionIds, ]); } @@ -211,4 +266,376 @@ class AdminProgressController extends BaseController $flags = json_decode($json, true); return is_array($flags) ? $flags : []; } + + protected function normalizeDate(string $value): string + { + $value = trim($value); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + return ''; + } + [$y, $m, $d] = array_map('intval', explode('-', $value)); + return checkdate($m, $d, $y) ? $value : ''; + } + + protected function buildSemesterDates(): array + { + $schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); + $semester = (string) ($this->configModel->getConfig('semester') ?? ''); + $schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string) ($this->configModel->getConfig('school_year') ?? ''); + [$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYearForRange); + $semesterNorm = $this->semesterRangeService->normalizeSemester($semester); + if ($semesterNorm !== '' && $schoolYearForRange !== '') { + $semRange = $this->semesterRangeService->getSemesterRange($schoolYearForRange, $semesterNorm); + if ($semRange) { + [$rangeStart, $rangeEnd] = $semRange; + } + } + + $dateList = []; + try { + $start = new \DateTimeImmutable($rangeStart); + $end = new \DateTimeImmutable($rangeEnd); + $cursor = $start; + $w = (int) $cursor->format('w'); + if ($w !== 0) { + $cursor = $cursor->modify('next sunday'); + } + while ($cursor <= $end) { + $dateList[] = $cursor->format('Y-m-d'); + $cursor = $cursor->modify('+7 days'); + } + } catch (\Throwable $e) { + $dateList = []; + } + + $noSchoolDays = []; + $events = []; + try { + $events = $this->calendarModel->getEvents(); + } catch (\Throwable $e) { + $events = []; + } + foreach ($events as $event) { + $d = substr((string) ($event['date'] ?? ''), 0, 10); + if ($d === '' || empty($event['no_school'])) { + continue; + } + if ($d < $rangeStart || $d > $rangeEnd) { + continue; + } + $eventYear = trim((string) ($event['school_year'] ?? '')); + if ($schoolYearForRange !== '' && $eventYear !== '' && $eventYear !== $schoolYearForRange) { + continue; + } + $noSchoolDays[$d] = true; + } + + $anchorSundayYmd = ''; + try { + $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); + $tzObj = new \DateTimeZone($tzName ?: 'UTC'); + } catch (\Throwable $e) { + try { + $tzObj = new \DateTimeZone(user_timezone() ?: 'UTC'); + } catch (\Throwable $e2) { + $tzObj = new \DateTimeZone('UTC'); + } + } + try { + $nowDate = new \DateTime('now', $tzObj); + } catch (\Throwable $e) { + $nowDate = new \DateTime('now'); + } + $weekday = (int) $nowDate->format('w'); + $anchorSundayYmd = $weekday === 0 + ? $nowDate->format('Y-m-d') + : $nowDate->modify('next sunday')->format('Y-m-d'); + + $passedDatesSet = []; + if (! empty($dateList) && $anchorSundayYmd !== '') { + foreach ($dateList as $d) { + if ($d <= $anchorSundayYmd && empty($noSchoolDays[$d])) { + $passedDatesSet[$d] = true; + } + } + } + $totalPassedDays = count($passedDatesSet); + + return [$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet]; + } + + protected function resolveExpectedDays( + array $dateList, + array $noSchoolDays, + int $totalPassedDays, + array $passedDatesSet, + string $filterStart, + string $filterEnd + ): array { + $activeDatesSet = []; + if ($filterStart === '' && $filterEnd === '') { + $expectedDays = $totalPassedDays; + if ($expectedDays > 0) { + $activeDatesSet = $passedDatesSet; + } + return [$expectedDays, $activeDatesSet]; + } + + $expectedDays = 0; + foreach ($dateList as $d) { + if ($d === '') { + continue; + } + if ($filterStart !== '' && $d < $filterStart) { + continue; + } + if ($filterEnd !== '' && $d > $filterEnd) { + continue; + } + if (! empty($noSchoolDays[$d])) { + continue; + } + $activeDatesSet[$d] = true; + $expectedDays++; + } + + return [$expectedDays, $activeDatesSet]; + } + + protected function buildSectionSubmissionStats(array $rows, array $activeDatesSet, int $expectedDays): array + { + $submittedBySection = []; + foreach ($rows as $row) { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $weekStart = (string) ($row['week_start'] ?? ''); + if ($sectionId === 0 || $weekStart === '') { + continue; + } + $submittedBySection[$sectionId][$weekStart] = true; + } + + $stats = []; + foreach ($submittedBySection as $sectionId => $weeks) { + $submitted = count($weeks); + $stats[$sectionId] = $this->buildSectionStat($submitted, $expectedDays); + } + + return $stats; + } + + protected function buildSectionSubjectCounts(array $rows): array + { + $allowedSubjects = []; + foreach (ClassProgressController::SUBJECT_SECTIONS as $section) { + $allowedSubjects[] = $section['db_subject'] ?? $section['label'] ?? ''; + } + $allowedSubjects = array_values(array_filter($allowedSubjects)); + + $counts = []; + $sectionClassMap = []; + foreach ($rows as $row) { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $subject = (string) ($row['subject'] ?? ''); + if ($sectionId === 0 || $subject === '') { + continue; + } + if (! empty($allowedSubjects) && ! in_array($subject, $allowedSubjects, true)) { + continue; + } + if (! isset($sectionClassMap[$sectionId])) { + $sectionClassMap[$sectionId] = $this->classSectionModel->getClassId($sectionId); + } + } + + $curriculumUnits = $this->buildCurriculumUnitMap(array_values(array_filter($sectionClassMap))); + + foreach ($rows as $row) { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $subject = (string) ($row['subject'] ?? ''); + if ($sectionId === 0 || $subject === '') { + continue; + } + if (! empty($allowedSubjects) && ! in_array($subject, $allowedSubjects, true)) { + continue; + } + $subjectSlug = $this->resolveSubjectSlug($subject); + $classId = $sectionClassMap[$sectionId] ?? null; + $chapterToUnit = []; + if ($classId && $subjectSlug && ! empty($curriculumUnits[$classId][$subjectSlug]['chapter_to_unit'])) { + $chapterToUnit = $curriculumUnits[$classId][$subjectSlug]['chapter_to_unit']; + } + $unitKeys = $this->extractUnitKeys((string) ($row['unit_title'] ?? ''), $chapterToUnit); + foreach ($unitKeys as $unitKey) { + $key = $subjectSlug . '|' . $unitKey; + $counts[$sectionId][$key] = true; + } + } + + $totals = []; + foreach ($counts as $sectionId => $unitSet) { + $totals[$sectionId] = count($unitSet); + } + + return $totals; + } + + protected function buildCurriculumUnitMap(array $classIds): array + { + $classIds = array_values(array_filter(array_map('intval', $classIds))); + if (empty($classIds)) { + return []; + } + + $rows = $this->curriculumModel + ->whereIn('class_id', $classIds) + ->findAll(); + + $map = []; + foreach ($rows as $row) { + $classId = (int) ($row['class_id'] ?? 0); + $subject = (string) ($row['subject'] ?? ''); + $chapter = trim((string) ($row['chapter_name'] ?? '')); + $unitNumber = $row['unit_number'] ?? null; + if ($classId === 0 || $subject === '' || $chapter === '') { + continue; + } + if ($unitNumber === null || $unitNumber === '') { + continue; + } + $unitKey = (string) $unitNumber; + $map[$classId][$subject]['chapter_to_unit'][$chapter] = $unitKey; + } + + return $map; + } + + protected function resolveSubjectSlug(string $subject): ?string + { + foreach (ClassProgressController::SUBJECT_SECTIONS as $slug => $section) { + $dbSubject = (string) ($section['db_subject'] ?? ''); + $label = (string) ($section['label'] ?? ''); + if ($subject === $dbSubject || $subject === $label) { + return $slug; + } + } + return null; + } + + protected function countUnitSegments(string $unitTitle, array $chapterToUnit): int + { + $unitTitle = trim($unitTitle); + if ($unitTitle === '') { + return 0; + } + $parts = array_filter(array_map('trim', explode(';', $unitTitle)), static fn ($part) => $part !== ''); + if (! $parts) { + return 0; + } + + $seen = []; + foreach ($parts as $part) { + [$unitPart, $chapterPart] = $this->splitUnitChapterSegment($part); + $key = $this->resolveUnitKey($unitPart, $chapterPart, $chapterToUnit); + if ($key === '') { + $key = $part; + } + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + } + + return count($seen); + } + + protected function splitUnitChapterSegment(string $segment): array + { + $segment = trim($segment); + if ($segment === '') { + return ['', '']; + } + $pos = strrpos($segment, '/'); + if ($pos === false) { + return [$segment, '']; + } + $unitPart = trim(substr($segment, 0, $pos)); + $chapterPart = trim(substr($segment, $pos + 1)); + return [$unitPart, $chapterPart]; + } + + protected function extractUnitKeys(string $unitTitle, array $chapterToUnit): array + { + $unitTitle = trim($unitTitle); + if ($unitTitle === '') { + return []; + } + $parts = array_filter(array_map('trim', explode(';', $unitTitle)), static fn ($part) => $part !== ''); + if (! $parts) { + return []; + } + + $keys = []; + foreach ($parts as $part) { + [$unitPart, $chapterPart] = $this->splitUnitChapterSegment($part); + $key = $this->resolveUnitKey($unitPart, $chapterPart, $chapterToUnit); + if ($key === '') { + continue; + } + $keys[$key] = true; + } + + return array_keys($keys); + } + + protected function resolveUnitKey(string $unitPart, string $chapterPart, array $chapterToUnit): string + { + if ($chapterPart !== '' && ! empty($chapterToUnit[$chapterPart])) { + return (string) $chapterToUnit[$chapterPart]; + } + if (empty($chapterToUnit)) { + if (preg_match('/\bunit\s*(\d+)\b/i', $unitPart, $matches)) { + return (string) $matches[1]; + } + return ''; + } + if (preg_match('/\bunit\s*(\d+)\b/i', $unitPart, $matches)) { + return (string) $matches[1]; + } + if ($unitPart !== '') { + return $unitPart; + } + if ($chapterPart !== '') { + return $chapterPart; + } + return ''; + } + + protected function buildSectionStat(int $submitted, int $expectedDays): array + { + $percent = $expectedDays > 0 ? round(($submitted * 100) / $expectedDays, 1) : 0.0; + if ($expectedDays === 0) { + $badgeClass = 'bg-secondary'; + $labelClass = 'text-muted'; + } elseif ($percent >= 100) { + $badgeClass = 'bg-success'; + $labelClass = 'text-success'; + } elseif ($percent >= 60) { + $badgeClass = 'bg-warning text-dark'; + $labelClass = 'text-warning'; + } elseif ($percent >= 40) { + $badgeClass = 'bg-orange text-dark'; + $labelClass = 'text-warning'; + } else { + $badgeClass = 'bg-danger'; + $labelClass = 'text-danger'; + } + + return [ + 'submitted' => $submitted, + 'expected' => $expectedDays, + 'percent' => $percent, + 'badgeClass' => $badgeClass, + 'labelClass' => $labelClass, + ]; + } } diff --git a/app/Controllers/ApiDocsController.php b/app/Controllers/ApiDocsController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php old mode 100644 new mode 100755 index 96aa341..df2a879 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -56,13 +56,32 @@ class AuthController extends Controller public function loginMask() { - // Serve the login view directly here - return view('user/login'); // Adjust the view path as needed + $redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getGet('redirect_to') ?? '')); + + if (session()->get('is_logged_in')) { + if ($redirectTo !== null) { + return redirect()->to($redirectTo); + } + + $roles = session()->get('roles') ?? []; + if (empty($roles) && session()->get('role')) { + $roles = [session()->get('role')]; + } + + if (!empty($roles)) { + return $this->redirectToDashboard($roles); + } + } + + return view('user/login', [ + 'redirect_to' => $redirectTo ?? '', + ]); } public function login() { log_message('info', 'Processing login form submission.'); + $redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? $this->request->getGet('redirect_to') ?? '')); // Step 1: Get email, password, and IP from the request $email = $this->request->getPost('email'); @@ -98,7 +117,7 @@ class AuthController extends Controller $this->logLoginAttempt($user['id'], $user['email'], $ip, $this->request->getUserAgent()); // ✅ Step 7: Call loginUser() to set session and redirect - return $this->loginUser($user); + return $this->loginUser($user, $redirectTo); } else { // Step 8: Password mismatch — log failed attempt $this->handleFailedLogin($user['id'], $user['email'], $ip); @@ -469,6 +488,7 @@ class AuthController extends Controller // Generate a secure token for the password reset helper('text'); $token = bin2hex(random_bytes(48)); + $tokenHash = hash('sha256', $token); // Calculate the expiration time for the token (1 hour from now) $expires_at = Time::now()->addHours(1); @@ -477,7 +497,7 @@ class AuthController extends Controller $passwordResetModel = new PasswordResetModel(); $passwordResetModel->insert([ 'email' => $email, - 'token' => $token, + 'token' => $tokenHash, 'created_at' => Time::now(), 'expires_at' => $expires_at, ]); @@ -490,7 +510,7 @@ class AuthController extends Controller return true; } - private function loginUser($user) + private function loginUser($user, ?string $redirectTo = null) { $userRoleModel = new UserRoleModel(); $roles = $userRoleModel->select('roles.name') @@ -524,9 +544,17 @@ class AuthController extends Controller if (count($roleNames) === 1) { // One role → set and redirect directly session()->set('role', $roleNames[0]); + if ($redirectTo !== null) { + return redirect()->to($redirectTo); + } return $this->redirectToDashboard([$roleNames[0]]); } else { // Multiple roles → redirect to role selection view + if ($redirectTo !== null) { + session()->set('post_login_redirect', $redirectTo); + } else { + session()->remove('post_login_redirect'); + } return redirect()->to('/select-role'); } @@ -591,12 +619,17 @@ private function redirectToDashboard(array $roles) { $selectedRole = $this->request->getPost('selected_role'); $availableRoles = session()->get('roles'); + $redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? session()->get('post_login_redirect') ?? '')); if (!$selectedRole || !in_array($selectedRole, $availableRoles)) { return redirect()->to('/select-role')->with('error', 'Invalid role selected.'); } session()->set('role', $selectedRole); + session()->remove('post_login_redirect'); + if ($redirectTo !== null) { + return redirect()->to($redirectTo); + } return $this->redirectToDashboard([$selectedRole]); } @@ -608,7 +641,44 @@ private function redirectToDashboard(array $roles) return redirect()->to('/login')->with('error', 'No roles available.'); } - return view('auth/select_role', ['roles' => $roles]); + return view('auth/select_role', [ + 'roles' => $roles, + 'redirect_to' => (string) (session()->get('post_login_redirect') ?? ''), + ]); + } + + private function sanitizeRedirectTarget(string $redirectTo): ?string + { + $redirectTo = trim($redirectTo); + if ($redirectTo === '') { + return null; + } + + if (preg_match('#^https?://#i', $redirectTo)) { + $appHost = (string) parse_url(base_url('/'), PHP_URL_HOST); + $targetHost = (string) parse_url($redirectTo, PHP_URL_HOST); + $targetPath = (string) parse_url($redirectTo, PHP_URL_PATH); + $targetQuery = (string) parse_url($redirectTo, PHP_URL_QUERY); + + if ($appHost === '' || $targetHost === '' || strcasecmp($appHost, $targetHost) !== 0) { + return null; + } + + $redirectTo = $targetPath !== '' ? $targetPath : '/'; + if ($targetQuery !== '') { + $redirectTo .= '?' . $targetQuery; + } + } + + if (!str_starts_with($redirectTo, '/')) { + $redirectTo = '/' . ltrim($redirectTo, '/'); + } + + if (str_starts_with($redirectTo, '//')) { + return null; + } + + return $redirectTo; } } diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/ClassProgressController.php b/app/Controllers/ClassProgressController.php old mode 100644 new mode 100755 index 134596b..859f308 --- a/app/Controllers/ClassProgressController.php +++ b/app/Controllers/ClassProgressController.php @@ -7,6 +7,7 @@ use App\Models\ClassProgressAttachmentModel; use App\Models\ConfigurationModel; use App\Models\SubjectCurriculumModel; use App\Models\TeacherClassModel; +use App\Services\SemesterRangeService; use CodeIgniter\Exceptions\PageNotFoundException; class ClassProgressController extends BaseController @@ -27,6 +28,10 @@ class ClassProgressController extends BaseController 'db_subject' => 'Quran/Arabic', ], ]; + + /** Unit column for teacher custom Islamic / Quran rows; must match teacher form JS. Segment: "Custom / {text}". */ + public const CUSTOM_UNIT_ROW_LABEL = 'Custom'; + protected ClassProgressReportModel $reportModel; protected ClassProgressAttachmentModel $attachmentModel; protected TeacherClassModel $teacherClassModel; @@ -56,6 +61,7 @@ class ClassProgressController extends BaseController $classSectionName = $first['class_section_name'] ?? null; $classId = $first['class_id'] ?? null; $sundayOptions = $this->buildSundayOptions(); + $defaultWeekStart = $this->pickDefaultWeekStart($sundayOptions); $subjectCurriculum = []; if ($classId) { foreach (self::SUBJECT_SECTIONS as $slug => $section) { @@ -69,7 +75,7 @@ class ClassProgressController extends BaseController 'classSectionName' => $classSectionName, 'classId' => $classId, 'sundayOptions' => $sundayOptions, - 'defaultWeekStart' => $sundayOptions[0] ?? '', + 'defaultWeekStart' => $defaultWeekStart, ]; return view('teacher/class_progress_submit', $data); } @@ -96,6 +102,10 @@ class ClassProgressController extends BaseController return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } + if (! $this->hasIslamicUnitSelection()) { + return redirect()->back()->withInput()->with('error', 'Please select at least one Islamic Studies unit.'); + } + $attachmentErrors = $this->validateAttachmentFiles($subjectSections); if (! empty($attachmentErrors)) { return redirect()->back()->withInput()->with('errors', $attachmentErrors); @@ -117,6 +127,32 @@ class ClassProgressController extends BaseController return redirect()->back()->withInput()->with('error', 'No class assignment found for this report.'); } + $confirmOverwrite = (bool) $this->request->getPost('confirm_overwrite'); + $existingReports = $this->reportModel + ->select('id') + ->where('class_section_id', $classSectionId) + ->where('week_start', $weekStart) + ->where('teacher_id', $teacherId) + ->findAll(); + + if (! $confirmOverwrite && ! empty($existingReports)) { + return redirect()->back() + ->withInput() + ->with('warning', 'A progress report already exists for this week, are you sure you want to override it?') + ->with('confirm_overwrite', true); + } + + if ($confirmOverwrite && ! empty($existingReports)) { + $existingIds = array_values(array_filter(array_map( + static fn (array $row): int => (int) ($row['id'] ?? 0), + $existingReports + ))); + if (! empty($existingIds)) { + $this->attachmentModel->whereIn('report_id', $existingIds)->delete(); + $this->reportModel->whereIn('id', $existingIds)->delete(); + } + } + $status = self::DEFAULT_STATUS; $reportsCreated = 0; @@ -171,10 +207,16 @@ class ClassProgressController extends BaseController if ($selectedSectionId && ! in_array($selectedSectionId, $validSectionIds, true)) { $selectedSectionId = $validSectionIds[0] ?? null; } + [$semester, $schoolYear] = $this->resolveCurrentTerm(); + $allowedTeacherIds = $this->resolveAssignedTeacherIds($selectedSectionId, $semester, $schoolYear); + if (empty($allowedTeacherIds)) { + $allowedTeacherIds = [$teacherId]; + } $builder = $this->reportModel - ->select('class_progress_reports.*, cs.class_section_name') + ->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name') ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') - ->where('teacher_id', $teacherId); + ->join('users u', 'u.id = class_progress_reports.teacher_id', 'left') + ->whereIn('teacher_id', $allowedTeacherIds); if ($selectedSectionId) { $builder->where('class_progress_reports.class_section_id', $selectedSectionId); } @@ -216,20 +258,33 @@ class ClassProgressController extends BaseController { $teacherId = (int) session()->get('user_id'); $row = $this->reportModel - ->select('class_progress_reports.*, cs.class_section_name') + ->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name') ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') - ->where('teacher_id', $teacherId) - ->find((int) $id); + ->join('users u', 'u.id = class_progress_reports.teacher_id', 'left') + ->where('class_progress_reports.id', (int) $id) + ->first(); if (! $row) { throw new PageNotFoundException('Progress report not found.'); } + [$semester, $schoolYear] = $this->resolveCurrentTerm(); + $allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear); + if (empty($allowedTeacherIds)) { + if ($teacherId !== (int) $row['teacher_id']) { + throw new PageNotFoundException('Progress report not found.'); + } + $allowedTeacherIds = [(int) $row['teacher_id']]; + } elseif (! in_array($teacherId, $allowedTeacherIds, true)) { + throw new PageNotFoundException('Progress report not found.'); + } + $row['status_label'] = self::STATUS_OPTIONS[$row['status']] ?? 'Unknown'; $weeklyReports = $this->reportModel - ->select('class_progress_reports.*, cs.class_section_name') + ->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name') ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') - ->where('teacher_id', $teacherId) + ->join('users u', 'u.id = class_progress_reports.teacher_id', 'left') + ->whereIn('teacher_id', $allowedTeacherIds) ->where('class_progress_reports.class_section_id', $row['class_section_id']) ->where('week_start', $row['week_start']) ->orderBy('subject', 'ASC') @@ -255,6 +310,262 @@ class ClassProgressController extends BaseController ]); } + public function edit($id) + { + $teacherId = (int) session()->get('user_id'); + $row = $this->reportModel + ->select('class_progress_reports.*, cs.class_section_name') + ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') + ->where('class_progress_reports.id', (int) $id) + ->first(); + + if (! $row) { + throw new PageNotFoundException('Progress report not found.'); + } + + [$semester, $schoolYear] = $this->resolveCurrentTerm(); + $allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear); + if (empty($allowedTeacherIds)) { + if ($teacherId !== (int) $row['teacher_id']) { + throw new PageNotFoundException('Progress report not found.'); + } + $allowedTeacherIds = [(int) $row['teacher_id']]; + } elseif (! in_array($teacherId, $allowedTeacherIds, true)) { + throw new PageNotFoundException('Progress report not found.'); + } + + $weeklyReports = $this->reportModel + ->select('class_progress_reports.*') + ->whereIn('teacher_id', $allowedTeacherIds) + ->where('class_section_id', $row['class_section_id']) + ->where('week_start', $row['week_start']) + ->orderBy('subject', 'ASC') + ->findAll(); + + $reportMap = []; + foreach ($weeklyReports as $report) { + $subject = (string) ($report['subject'] ?? ''); + if ($subject === '') { + continue; + } + $reportMap[$subject] = $report; + } + + $subjectReports = []; + foreach (self::SUBJECT_SECTIONS as $slug => $section) { + $subjectName = $section['db_subject'] ?? $section['label'] ?? $slug; + $report = $reportMap[$subjectName] ?? null; + if (! $report) { + continue; + } + $parsed = $this->parseUnitChapterSummary((string) ($report['unit_title'] ?? '')); + $subjectReports[$slug] = [ + 'report_id' => (int) ($report['id'] ?? 0), + 'covered' => $report['covered'] ?? '', + 'homework' => $report['homework'] ?? '', + 'unit_title' => $report['unit_title'] ?? '', + 'unit_values' => $parsed['units'], + 'chapter_values' => $parsed['chapters'], + ]; + } + + $assignments = $this->loadTeacherSections($teacherId); + $classId = null; + $classSectionName = $row['class_section_name'] ?? ''; + foreach ($assignments as $assignment) { + if ((int) ($assignment['class_section_id'] ?? 0) === (int) $row['class_section_id']) { + $classId = $assignment['class_id'] ?? null; + $classSectionName = $assignment['class_section_name'] ?? $classSectionName; + break; + } + } + + $subjectCurriculum = []; + if ($classId) { + foreach (self::SUBJECT_SECTIONS as $slug => $section) { + $subjectCurriculum[$slug] = $this->curriculumModel->getOptionsForClass((int) $classId, $slug); + } + } + + $sundayOptions = $this->buildSundayOptions(); + if (!in_array($row['week_start'], $sundayOptions, true)) { + array_unshift($sundayOptions, $row['week_start']); + } + + return view('teacher/class_progress_submit', [ + 'subjectSections' => self::SUBJECT_SECTIONS, + 'subjectCurriculum' => $subjectCurriculum, + 'classSectionId' => $row['class_section_id'], + 'classSectionName' => $classSectionName, + 'classId' => $classId, + 'sundayOptions' => $sundayOptions, + 'defaultWeekStart' => $row['week_start'], + 'existingWeekEnd' => $row['week_end'], + 'existingReports' => $subjectReports, + 'isEdit' => true, + 'formAction' => base_url('teacher/progress/update/' . (int) $row['id']), + 'submitLabel' => 'Update Progress', + ]); + } + + public function update($id) + { + $teacherId = (int) session()->get('user_id'); + $row = $this->reportModel->find((int) $id); + if (! $row) { + throw new PageNotFoundException('Progress report not found.'); + } + + [$semester, $schoolYear] = $this->resolveCurrentTerm(); + $allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear); + if (empty($allowedTeacherIds)) { + if ($teacherId !== (int) $row['teacher_id']) { + throw new PageNotFoundException('Progress report not found.'); + } + $allowedTeacherIds = [(int) $row['teacher_id']]; + } elseif (! in_array($teacherId, $allowedTeacherIds, true)) { + throw new PageNotFoundException('Progress report not found.'); + } + + $subjectSections = self::SUBJECT_SECTIONS; + $rules = [ + 'class_section_id' => 'required|integer', + 'week_start' => 'required|valid_date[Y-m-d]', + 'week_end' => 'required|valid_date[Y-m-d]', + ]; + foreach ($subjectSections as $slug => $section) { + $rules["covered_$slug"] = 'required|string'; + $rules["homework_$slug"] = 'permit_empty|string'; + $rules["unit_{$slug}.*"] = 'permit_empty|string|max_length[120]'; + $rules["chapter_{$slug}.*"] = 'permit_empty|string|max_length[120]'; + } + + if (! $this->validate($rules)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + + if (! $this->hasIslamicUnitSelection()) { + return redirect()->back()->withInput()->with('error', 'Please select at least one Islamic Studies unit.'); + } + + $attachmentErrors = $this->validateAttachmentFiles($subjectSections); + if (! empty($attachmentErrors)) { + return redirect()->back()->withInput()->with('errors', $attachmentErrors); + } + + $weekStart = (string) $this->request->getPost('week_start'); + $weekEnd = (string) $this->request->getPost('week_end'); + if ($weekStart && ! $weekEnd) { + $weekEnd = $this->buildWeekEndFromStart($weekStart); + } + if ($weekStart && $weekEnd && strtotime($weekEnd) < strtotime($weekStart)) { + return redirect()->back()->withInput()->with('error', 'Week end must be the same as or after the week start.'); + } + + $classSectionId = (int) ($row['class_section_id'] ?? 0); + if ($classSectionId === 0) { + return redirect()->back()->withInput()->with('error', 'No class assignment found for this report.'); + } + + $confirmOverwrite = (bool) $this->request->getPost('confirm_overwrite'); + if ($weekStart && $weekStart !== (string) ($row['week_start'] ?? '')) { + $conflicts = $this->reportModel + ->select('id') + ->where('class_section_id', $classSectionId) + ->where('week_start', $weekStart) + ->where('teacher_id', $teacherId) + ->findAll(); + if (! $confirmOverwrite && ! empty($conflicts)) { + return redirect()->back() + ->withInput() + ->with('warning', 'A progress report already exists for this week, are you sure you want to override it?') + ->with('confirm_overwrite', true); + } + if ($confirmOverwrite && ! empty($conflicts)) { + $conflictIds = array_values(array_filter(array_map( + static fn (array $row): int => (int) ($row['id'] ?? 0), + $conflicts + ))); + if (! empty($conflictIds)) { + $this->attachmentModel->whereIn('report_id', $conflictIds)->delete(); + $this->reportModel->whereIn('id', $conflictIds)->delete(); + } + } + } + + $weeklyReports = $this->reportModel + ->select('class_progress_reports.*') + ->whereIn('teacher_id', $allowedTeacherIds) + ->where('class_section_id', $classSectionId) + ->where('week_start', $row['week_start']) + ->orderBy('subject', 'ASC') + ->findAll(); + + $reportMap = []; + foreach ($weeklyReports as $report) { + $subject = (string) ($report['subject'] ?? ''); + if ($subject === '') { + continue; + } + $reportMap[$subject] = $report; + } + + $reportsUpdated = 0; + $flagsInput = $this->request->getPost('flags'); + foreach ($subjectSections as $slug => $section) { + $covered = trim((string) $this->request->getPost("covered_$slug")); + if ($covered === '') { + continue; + } + $homework = trim((string) $this->request->getPost("homework_$slug")); + $unitTitle = $this->buildUnitChapterSummary($slug); + $subjectName = $section['db_subject'] ?? $section['label'] ?? $slug; + $existing = $reportMap[$subjectName] ?? null; + if ($unitTitle === null && $existing) { + $unitTitle = $existing['unit_title'] ?? null; + } + + $data = [ + 'class_section_id' => $classSectionId, + 'week_start' => $weekStart, + 'week_end' => $weekEnd, + 'subject' => $subjectName, + 'unit_title' => $unitTitle, + 'covered' => $covered, + 'homework' => $homework ?: null, + ]; + if ($flagsInput !== null) { + $data['flags_json'] = $this->normalizeFlags($flagsInput); + } + + if ($existing) { + $this->reportModel->update((int) $existing['id'], $data); + $reportId = (int) $existing['id']; + } else { + $data['teacher_id'] = $teacherId; + $data['status'] = self::DEFAULT_STATUS; + $reportId = (int) $this->reportModel->insert($data, true); + } + + $attachmentField = "attachment_$slug"; + $attachments = $this->request->getFileMultiple($attachmentField) ?? []; + $storedAttachments = $this->storeAttachments($reportId, $attachments); + if (! empty($storedAttachments)) { + $this->attachmentModel->insertBatch($storedAttachments); + if (empty($existing['attachment_path'] ?? '')) { + $this->reportModel->update($reportId, ['attachment_path' => $storedAttachments[0]['file_path']]); + } + } + $reportsUpdated++; + } + + if ($reportsUpdated === 0) { + return redirect()->back()->withInput()->with('error', 'Please provide progress for at least one subject.'); + } + + return redirect()->to('teacher/progress/history')->with('success', 'Progress reports updated.'); + } + public function attachment($id) { $row = $this->reportModel->find((int)$id); @@ -398,6 +709,37 @@ class ClassProgressController extends BaseController return $flags ? json_encode($flags) : null; } + /** + * Split stored unit_title into curriculum lines vs custom teacher-typed subjects ("Custom / …"). + * Keep in sync with teacher form, {@see buildUnitChapterSummary()}, and admin views. + * + * @return array{curriculum: listClass progress submissions can be updated at Teacher Progress History.
"; + } + $examDraftNote = ''; + if (in_array('exam draft', $missingItems, true)) { + $semesterLabel = strtolower(trim((string) $semester)); + if ($semesterLabel === 'fall') { + $draftLabel = 'midterm exam draft'; + } elseif ($semesterLabel === 'spring') { + $draftLabel = 'final exam draft'; + } else { + $draftLabel = 'exam draft'; + } + $examDraftNote = "" . ucfirst($draftLabel) . " submissions can be updated at Teacher Exam Drafts.
" + . $examDraftDeadlineEmailHtml; + } + $homeworkNote = ''; + if (in_array('homework', $missingItems, true)) { + $homeworkNote = "Homework scores can be submitted at Teacher Homework.
"; + } + $hasScoreItems = (bool) array_intersect($missingItems, [ + 'midterm scores', + 'midterm comments', + 'final scores', + 'final comments', + 'participation', + 'PTAP comments', + 'homework', + ]); + $nonScoreOnly = ! empty($missingItems) && ! $hasScoreItems; $body = "Dear {$teacherName},
" - . "Administration is gently reminding you to wrap up any remaining score submissions and/or comments for {$sectionName}.
" + . "Administration is gently reminding you to wrap up any remaining " + . ($nonScoreOnly ? "submissions for {$sectionName}." : "score submissions, comments, and related items for {$sectionName}.") + . "
" . $missingNote - . "Visit Teacher Score Submission to address any remaining items.
" + . $progressNote + . $examDraftNote + . $homeworkNote + . ($nonScoreOnly ? '' : "Visit Teacher Score Submission to address any remaining items.
") . "Thank you,
Al Rahma Administration
Exam draft submission deadline (exam_draft_deadline): '
+ . "{$display}"
+ . ($parsed !== null && $rawEsc !== $display ? " (configured value: {$rawEsc})" : '')
+ . '.
Insha Allah this email finds you well"; + if ($variant === 'answered') { + $intro .= ", {$parentName}. Jazakum Allahu khayran for taking the time to speak with us today.
"; + } elseif ($variant === 'no_answer') { + $intro .= ".We tried to reach you but could not connect and left a voice message at {$voicemail}.
"; + } else { + $intro .= "."; + } + + $details = match (true) { + str_starts_with($code, 'ABS_1') + => "This is to let you know that {$studentName} was absent on {$incident} without prior notice.
", + str_starts_with($code, 'ABS_2') + => "This is a reminder that {$studentName} has had repeated absences, most recently on {$incident}, without prior notice.
", + str_starts_with($code, 'ABS_3') + => "This is a reminder that {$studentName} has been absent three times, most recently on {$incident}, without prior notice.
", + str_starts_with($code, 'LATE_2') + => "This is a reminder that {$studentName} has been late multiple times, most recently on {$incident}.
", + str_starts_with($code, 'LATE_3'), + str_starts_with($code, 'LATE_4') + => "This is a follow-up that {$studentName} has had repeated lateness concerns, most recently on {$incident}.
", + str_starts_with($code, 'MIX') + => "This is a follow-up that {$studentName} has had a mix of repeated lateness and absence concerns, most recently on {$incident}.
", + default + => "We'd like to inform you about {$studentName}'s recent attendance concern dated {$incident}.
", + }; + + $closing = "If your child will be absent or late due to illness or family commitments, please let us know ahead of time.
" + . "You can email/call/text us at {$phone}.
"; + + return [$subject, $intro . $details . $closing]; + } + public function compose() { @@ -2033,8 +2085,8 @@ class AttendanceTrackingController extends BaseController $rendered = $this->renderTemplate($code, $variant, $ctx); if (!$rendered) { - return redirect()->to(site_url('attendance/violations')) - ->with('error', 'Template not found for ' . $code . ' (' . $variant . ').'); + log_message('warning', 'Attendance email template missing; using fallback compose body. code=' . $code . ' variant=' . $variant); + $rendered = $this->buildFallbackTemplate($code, $variant, $ctx); } [$subject, $body] = $rendered; diff --git a/app/Controllers/View/AuthorizedUsersController.php b/app/Controllers/View/AuthorizedUsersController.php old mode 100644 new mode 100755 index 5e9ae4a..a6cc46c --- a/app/Controllers/View/AuthorizedUsersController.php +++ b/app/Controllers/View/AuthorizedUsersController.php @@ -10,6 +10,8 @@ use CodeIgniter\I18n\Time; class AuthorizedUsersController extends ResourceController { + private const TOKEN_TTL_HOURS = 24; + protected $userModel; protected $authorizedUserModel; @@ -18,6 +20,30 @@ class AuthorizedUsersController extends ResourceController $this->userModel = new UserModel(); $this->authorizedUserModel = new AuthorizedUserModel(); } + + private function requireLogin() + { + if (!session()->get('is_logged_in')) { + return $this->failUnauthorized('Authentication required.'); + } + + return null; + } + + private function requireOwnership(array $authorizedUser) + { + $userId = (int) session()->get('user_id'); + if ($userId <= 0 || (int) ($authorizedUser['user_id'] ?? 0) !== $userId) { + return $this->failForbidden('You do not have access to this resource.'); + } + + return null; + } + + private function hashToken(string $token): string + { + return hash('sha256', $token); + } /** * Return a list of authorized users for the logged-in main user. * @@ -25,7 +51,10 @@ class AuthorizedUsersController extends ResourceController */ public function index() { - + if ($resp = $this->requireLogin()) { + return $resp; + } + $userId = session()->get('user_id'); $authorizedUsers = $this->authorizedUserModel->where('user_id', $userId)->findAll(); @@ -40,12 +69,20 @@ class AuthorizedUsersController extends ResourceController */ public function show($id = null) { + if ($resp = $this->requireLogin()) { + return $resp; + } + $authorizedUser = $this->authorizedUserModel->find($id); if (!$authorizedUser) { return $this->failNotFound('Authorized user not found.'); } + if ($resp = $this->requireOwnership($authorizedUser)) { + return $resp; + } + return $this->respond($authorizedUser); } @@ -56,6 +93,10 @@ class AuthorizedUsersController extends ResourceController */ public function create() { + if ($resp = $this->requireLogin()) { + return $resp; + } + $email = strtolower($this->request->getPost('email')); // Validate email @@ -66,19 +107,20 @@ class AuthorizedUsersController extends ResourceController $user = $this->userModel->where('email', $email)->first(); if (!$user) { - return $this->failNotFound('No user found with this email.'); + return $this->respondCreated(['message' => 'Authorized user added. A confirmation email has been sent.']); } // Generate a token for confirmation helper('text'); $token = bin2hex(random_bytes(48)); + $tokenHash = $this->hashToken($token); // Add entry to the authorized_users table $this->authorizedUserModel->insert([ 'user_id' => session()->get('user_id'), // Main user ID 'authorized_user_id' => $user['id'], 'email' => $email, - 'token' => $token, + 'token' => $tokenHash, 'status' => 'Pending' ]); @@ -96,6 +138,10 @@ class AuthorizedUsersController extends ResourceController */ public function update($id = null) { + if ($resp = $this->requireLogin()) { + return $resp; + } + // Fetch the authorized user $authorizedUser = $this->authorizedUserModel->find($id); @@ -103,6 +149,10 @@ class AuthorizedUsersController extends ResourceController return $this->failNotFound('Authorized user not found.'); } + if ($resp = $this->requireOwnership($authorizedUser)) { + return $resp; + } + // Update the authorized user’s information (e.g., email) $email = strtolower($this->request->getPost('email')); if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) { @@ -122,12 +172,20 @@ class AuthorizedUsersController extends ResourceController */ public function delete($id = null) { + if ($resp = $this->requireLogin()) { + return $resp; + } + $authorizedUser = $this->authorizedUserModel->find($id); if (!$authorizedUser) { return $this->failNotFound('Authorized user not found.'); } + if ($resp = $this->requireOwnership($authorizedUser)) { + return $resp; + } + // Delete the authorized user record $this->authorizedUserModel->delete($id); @@ -147,16 +205,28 @@ class AuthorizedUsersController extends ResourceController return $this->fail('Invalid confirmation link.'); } - $authorizedUser = $this->authorizedUserModel->where('token', $token)->first(); + $tokenHash = $this->hashToken($token); + $authorizedUser = $this->authorizedUserModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('created_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString()) + ->first(); if (!$authorizedUser) { return $this->fail('Invalid or expired confirmation link.'); } - // Mark the authorized user as active - $this->authorizedUserModel->update($authorizedUser['id'], ['status' => 'Active', 'token' => null]); + // Mark the authorized user as active and rotate token for password setup + $nextToken = bin2hex(random_bytes(48)); + $nextTokenHash = $this->hashToken($nextToken); + $this->authorizedUserModel->update($authorizedUser['id'], [ + 'status' => 'Active', + 'token' => $nextTokenHash, + ]); - return redirect()->to('/set_authorized_user_password/' . $authorizedUser['authorized_user_id']); + return redirect()->to('/set_authorized_user_password/' . $authorizedUser['authorized_user_id'] . '?token=' . $nextToken); } /** @@ -167,13 +237,36 @@ class AuthorizedUsersController extends ResourceController */ public function setPassword($authorizedUserId) { + $token = (string) $this->request->getGet('token'); + if ($token === '') { + return $this->fail('Invalid confirmation link.'); + } + + $tokenHash = $this->hashToken($token); + $authorizedUser = $this->authorizedUserModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('authorized_user_id', $authorizedUserId) + ->where('status', 'Active') + ->where('updated_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString()) + ->first(); + + if (!$authorizedUser) { + return $this->fail('Invalid or expired confirmation link.'); + } + $user = $this->userModel->find($authorizedUserId); if (!$user) { return $this->failNotFound('User not found.'); } - return view('user/set_authorized_user_password', ['userId' => $authorizedUserId]); + return view('user/set_authorized_user_password', [ + 'userId' => $authorizedUserId, + 'token' => $token, + ]); } /** @@ -181,38 +274,59 @@ class AuthorizedUsersController extends ResourceController * * @return ResponseInterface */ - /* - public function savePassword() + public function savePassword($authorizedUserId = null) { - // Validate the request $validation = \Config\Services::validation(); $validation->setRules([ - 'password' => 'required|min_length[6]', + 'password' => [ + 'label' => 'Password', + 'rules' => 'required|min_length[8]|regex_match[/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\\-=\\+*#$%&!?])[A-Za-z\\d@\\-=\\+*#$%&!?]{8,}$/]', + ], 'password_confirm' => 'required|matches[password]', - 'user_id' => 'required|integer' + 'user_id' => 'required|integer', + 'token' => 'required', ]); if (!$this->validate($validation->getRules())) { return $this->failValidationErrors($validation->getErrors()); } - // Get the validated input - $userId = $this->request->getPost('user_id'); - $password = $this->request->getPost('password'); + $userId = (int) $this->request->getPost('user_id'); + $token = (string) $this->request->getPost('token'); + $authorizedUserId = $authorizedUserId !== null ? (int) $authorizedUserId : $userId; - $model = new UserModel(); - $user = $model->find($userId); + if ($userId <= 0 || $authorizedUserId <= 0 || $userId !== $authorizedUserId) { + return $this->fail('Invalid request.'); + } + $tokenHash = $this->hashToken($token); + $authorizedUser = $this->authorizedUserModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('authorized_user_id', $authorizedUserId) + ->where('status', 'Active') + ->where('updated_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString()) + ->first(); + + if (!$authorizedUser) { + return $this->fail('Invalid or expired confirmation link.'); + } + + $user = $this->userModel->find($authorizedUserId); if (!$user) { return $this->failNotFound('User not found.'); } - // Save the password - $model->update($userId, ['password' => password_hash($password, PASSWORD_DEFAULT)]); + $password = (string) $this->request->getPost('password'); + $hashedPassword = pbkdf2_hash($password); + + $this->userModel->update($authorizedUserId, ['password' => $hashedPassword]); + $this->authorizedUserModel->update($authorizedUser['id'], ['token' => null]); return $this->respond(['message' => 'Password has been successfully set.']); } -*/ /** * Sends a confirmation email to the authorized user. * @@ -242,4 +356,4 @@ class AuthorizedUsersController extends ResourceController log_message('error', 'Failed to send authorized user confirmation email to ' . $email); } } -} \ No newline at end of file +} diff --git a/app/Controllers/View/BadgesController.php b/app/Controllers/View/BadgesController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/BroadcastEmailController.php b/app/Controllers/View/BroadcastEmailController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ClassController.php b/app/Controllers/View/ClassController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ClassPrepController.php b/app/Controllers/View/ClassPrepController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ClassPreparationController.php b/app/Controllers/View/ClassPreparationController.php old mode 100644 new mode 100755 index 1da0b69..db9456e --- a/app/Controllers/View/ClassPreparationController.php +++ b/app/Controllers/View/ClassPreparationController.php @@ -60,13 +60,15 @@ class ClassPreparationController extends BaseController $limitToSemester = $this->hasRosterForSemester($schoolYear, $semester); // 1) Get student count per class-section (distinct students, correct semester) - $scQ = $this->studentClassModel - ->select('class_section_id, COUNT(DISTINCT student_id) AS student_count') - ->where('school_year', $schoolYear); + $scQ = $this->db->table('student_class sc') + ->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false) + ->join('students s', 's.id = sc.student_id', 'inner') + ->where('s.is_active', 1) + ->where('sc.school_year', $schoolYear); if ($limitToSemester && $semester !== '') { - $scQ->where('semester', $semester); + $scQ->where('sc.semester', $semester); } - $classSections = $scQ->groupBy('class_section_id')->findAll(); + $classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray(); // 2) Inventory availability — prefer good_qty when present, else condition='good' quantity. $inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed); @@ -298,14 +300,16 @@ class ClassPreparationController extends BaseController $className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId; // Distinct student count for this term - $studentQ = $this->studentClassModel - ->select('COUNT(DISTINCT student_id) AS cnt') - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear); + $studentQ = $this->db->table('student_class sc') + ->select('COUNT(DISTINCT sc.student_id) AS cnt', false) + ->join('students s', 's.id = sc.student_id', 'inner') + ->where('s.is_active', 1) + ->where('sc.class_section_id', $classSectionId) + ->where('sc.school_year', $schoolYear); if ($limitToSemester && $semester !== '') { - $studentQ->where('semester', $semester); + $studentQ->where('sc.semester', $semester); } - $studentRow = $studentQ->first(); + $studentRow = $studentQ->get()->getRowArray(); $studentCount = (int)($studentRow['cnt'] ?? 0); // Live calc + adjustments @@ -355,13 +359,15 @@ class ClassPreparationController extends BaseController $limitToSemester = $this->hasRosterForSemester($schoolYear, $semester); // Student counts - $scQ = $this->studentClassModel - ->select('class_section_id, COUNT(DISTINCT student_id) AS student_count') - ->where('school_year', $schoolYear); + $scQ = $this->db->table('student_class sc') + ->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false) + ->join('students s', 's.id = sc.student_id', 'inner') + ->where('s.is_active', 1) + ->where('sc.school_year', $schoolYear); if ($limitToSemester && $semester !== '') { - $scQ->where('semester', $semester); + $scQ->where('sc.semester', $semester); } - $classSections = $scQ->groupBy('class_section_id')->findAll(); + $classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray(); // Build inventory availability maps $inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed); @@ -448,14 +454,16 @@ class ClassPreparationController extends BaseController $now = utc_now(); $count = 0; foreach ($ids as $classSectionId) { - $studentQ = $this->studentClassModel - ->select('COUNT(DISTINCT student_id) AS cnt') - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear); + $studentQ = $this->db->table('student_class sc') + ->select('COUNT(DISTINCT sc.student_id) AS cnt', false) + ->join('students s', 's.id = sc.student_id', 'inner') + ->where('s.is_active', 1) + ->where('sc.class_section_id', $classSectionId) + ->where('sc.school_year', $schoolYear); if ($limitToSemester && $semester !== '') { - $studentQ->where('semester', $semester); + $studentQ->where('sc.semester', $semester); } - $studentRow = $studentQ->first(); + $studentRow = $studentQ->get()->getRowArray(); $studentCount = (int)($studentRow['cnt'] ?? 0); $classLevel = $this->getClassLevelBySection((string)$classSectionId); $className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId; diff --git a/app/Controllers/View/CommunicationController.php b/app/Controllers/View/CommunicationController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/CompetitionScoresController.php b/app/Controllers/View/CompetitionScoresController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ConfigurationController.php b/app/Controllers/View/ConfigurationController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ContactController.php b/app/Controllers/View/ContactController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/DashboardRedirectController.php b/app/Controllers/View/DashboardRedirectController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/DiscountController.php b/app/Controllers/View/DiscountController.php old mode 100644 new mode 100755 index 85fbf27..e87c073 --- a/app/Controllers/View/DiscountController.php +++ b/app/Controllers/View/DiscountController.php @@ -750,7 +750,9 @@ class DiscountController extends BaseController } } catch (\Throwable $e) {} - $newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2); + $discountableTotal = $tuitionSubtotal + $additionalSubtotal; + $nonDiscountableTotal = $eventSubtotal; + $newTotal = round($discountableTotal + $nonDiscountableTotal, 2); // ---- Payments / Discounts / Refunds ---- $db = $this->db; @@ -794,7 +796,8 @@ class DiscountController extends BaseController ->get()->getRowArray(); $totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0); - $newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid); + $appliedDiscount = min($totalDisc, $discountableTotal); + $newBalance = max(0.0, $newTotal - $appliedDiscount - $totalPaid - $totalRefundPaid); $newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid'); $updateData = [ diff --git a/app/Controllers/View/DocsController.php b/app/Controllers/View/DocsController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/EmailController.php b/app/Controllers/View/EmailController.php old mode 100644 new mode 100755 index 16dc51a..c9b4c4f --- a/app/Controllers/View/EmailController.php +++ b/app/Controllers/View/EmailController.php @@ -170,6 +170,163 @@ class EmailController extends Controller } } + /** + * Send one email with a single To recipient and many CC recipients. + * + * @param string $recipient + * @param string[] $ccRecipients + * @param string $subject + * @param string $htmlMessage + * @param string|null $profile + * @param string|null $replyToEmail + * @param string|null $replyToName + * @param array $attachments + */ + public function sendEmailWithCc( + string $recipient, + array $ccRecipients, + string $subject, + string $htmlMessage, + ?string $profile = null, + ?string $replyToEmail = null, + ?string $replyToName = null, + array $attachments = [] + ): bool { + $ccRecipients = array_values(array_unique(array_filter(array_map(static function ($email) { + $email = trim((string) $email); + return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null; + }, $ccRecipients)))); + + if ($ccRecipients === []) { + return $this->sendEmail($recipient, $subject, $htmlMessage, $profile, $replyToEmail, $replyToName, $attachments); + } + + $autoload = APPPATH . '../vendor/autoload.php'; + if (is_file($autoload)) { + require_once $autoload; + } + + $profile = $this->resolveProfile($profile); + $cfg = $this->getProfileConfig($profile); + + if (empty($cfg['host']) || empty($cfg['user']) || $cfg['pass'] === '') { + log_message('error', "[mail:$profile] Missing SMTP config (host/user/pass). Check .env MAIL_{$this->envKeyFromProfile($profile)}_* or MAIL_DEFAULT_*."); + return false; + } + + $debugEnabled = (bool) env('MAIL_DEBUG', false); + $timeout = (int) env('MAIL_TIMEOUT', 15); + $keepAlive = (bool) env('MAIL_KEEPALIVE', false); + $verifyPeer = filter_var(env('MAIL_VERIFY_PEER', 'true'), FILTER_VALIDATE_BOOLEAN); + + $targetHost = $cfg['host']; + $targetPort = (int) $cfg['port']; + + $resolved = @gethostbyname($targetHost); + if (!$resolved || $resolved === $targetHost) { + log_message('debug', "[mail:$profile] DNS resolve note: host=$targetHost, resolved=$resolved"); + } + + $sockOk = @fsockopen($targetHost, $targetPort, $errno, $errstr, 5); + if (!$sockOk) { + log_message('error', "[mail:$profile] Socket preflight failed to {$targetHost}:{$targetPort} (errno=$errno, err=$errstr). Likely firewall/port/encryption mismatch or wrong host."); + } else { + fclose($sockOk); + } + + $mail = new PHPMailer(true); + + try { + if ($debugEnabled) { + ob_start(); + } + + $mail->isSMTP(); + $mail->Host = $cfg['host']; + $mail->Port = $cfg['port']; + $mail->SMTPAuth = true; + $mail->Username = $cfg['user']; + $mail->Password = $cfg['pass']; + $mail->CharSet = 'UTF-8'; + $mail->Timeout = $timeout; + $mail->SMTPKeepAlive = $keepAlive; + $mail->SMTPAutoTLS = true; + + if ($cfg['encryption'] === 'ssl') { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + } else { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + } + + $mail->SMTPOptions = [ + 'ssl' => [ + 'verify_peer' => $verifyPeer, + 'verify_peer_name' => $verifyPeer, + 'allow_self_signed' => !$verifyPeer, + ], + ]; + + $mail->SMTPDebug = $debugEnabled ? 3 : 0; + $mail->Debugoutput = 'error_log'; + + $mail->setFrom($cfg['fromEmail'], $cfg['fromName']); + if (!empty($cfg['returnPath'])) { + $mail->Sender = $cfg['returnPath']; + } + + $mail->clearReplyTos(); + $rtEmail = env('MAIL_DEFAULT_REPLY_TO'); + $rtName = env('MAIL_DEFAULT_REPLY_TO_NAME'); + if (!$rtEmail || !filter_var($rtEmail, FILTER_VALIDATE_EMAIL)) { + $rtEmail = $replyToEmail ?: ($cfg['replyTo'] ?: $cfg['fromEmail']); + } + if (!$rtName) { + $rtName = $replyToName ?: ($cfg['replyToName'] ?: $cfg['fromName']); + } + $rtName = $this->sanitizeReplyToName($rtName, $cfg['fromName']); + if ($rtEmail) { + $mail->addReplyTo($rtEmail, $rtName); + } + + if (!empty($cfg['dkim']['domain']) && !empty($cfg['dkim']['private']) && !empty($cfg['dkim']['selector'])) { + $mail->DKIM_domain = $cfg['dkim']['domain']; + $mail->DKIM_private = $cfg['dkim']['private']; + $mail->DKIM_selector = $cfg['dkim']['selector']; + $mail->DKIM_identity = $cfg['fromEmail']; + } + + $mail->addAddress($recipient); + foreach ($ccRecipients as $ccRecipient) { + $mail->addCC($ccRecipient); + } + + foreach ($attachments as $att) { + if (!empty($att['path']) && is_file($att['path'])) { + $mail->addAttachment($att['path'], $att['name'] ?? ''); + } + } + + $mail->isHTML(true); + $mail->Subject = $subject; + $mail->Body = $htmlMessage; + + $ok = $mail->send(); + + $dbg = $debugEnabled ? (ob_get_clean() ?: '') : ''; + if ($ok) { + log_message('info', "[mail:$profile] Sent to {$recipient} with " . count($ccRecipients) . " CC recipient(s), subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}"); + return true; + } + + log_message('error', "[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}"); + return false; + } catch (Exception $e) { + $dbg = $debugEnabled ? (ob_get_clean() ?: '') : ''; + log_message('error', "[mail:$profile] Exception: {$e->getMessage()} | PHPMailer: {$mail->ErrorInfo} | Debug: {$dbg}"); + return false; + } + } + /** Resolve null/alias profile names to a canonical env prefix. */ private function resolveProfile(?string $profile): string { diff --git a/app/Controllers/View/EmailExtractorController.php b/app/Controllers/View/EmailExtractorController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/EmergencyContactController.php b/app/Controllers/View/EmergencyContactController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/EventController.php b/app/Controllers/View/EventController.php old mode 100644 new mode 100755 index 442c46e..5e3caf1 --- a/app/Controllers/View/EventController.php +++ b/app/Controllers/View/EventController.php @@ -6,8 +6,18 @@ use App\Models\EventModel; use App\Models\EventChargesModel; use App\Models\UserModel; use App\Models\StudentModel; +use App\Models\StudentClassModel; use App\Models\ConfigurationModel; use App\Models\EnrollmentModel; +use App\Models\InvoiceModel; +use App\Models\InvoiceEventModel; +use App\Models\ClassSectionModel; +use App\Models\ParentModel; +use App\Models\PaymentModel; +use App\Models\CalendarModel; +use App\Services\EmailService; +use Config\Database; +#use ??? use App\Controllers\View\InvoiceController; use CodeIgniter\RESTful\ResourceController; @@ -19,10 +29,19 @@ class EventController extends ResourceController protected $configModel; protected $eventModel; protected $invoiceController; + protected $invoiceModel; + protected $invoiceEventModel; + protected $studentClassModel; + protected $classSectionModel; + protected $paymentModel; + protected $parentModel; + protected $emailService; protected $schoolYear; protected $semester; protected $categories; protected $enrollmentModel; + private ?bool $eventChargesHasCreatedBy = null; + private ?bool $eventChargesHasWaiverSigned = null; public function __construct() { @@ -32,7 +51,14 @@ class EventController extends ResourceController $this->userModel = new UserModel(); // Add this $this->eventModel = new EventModel(); $this->invoiceController = new InvoiceController(); + $this->invoiceModel = new InvoiceModel(); + $this->invoiceEventModel = new InvoiceEventModel(); + $this->studentClassModel = new StudentClassModel(); + $this->classSectionModel = new ClassSectionModel(); + $this->paymentModel = new PaymentModel(); $this->enrollmentModel = new EnrollmentModel(); + $this->parentModel = new ParentModel(); + $this->emailService = new EmailService(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->semester = $this->configModel->getConfig('semester'); @@ -44,6 +70,57 @@ class EventController extends ResourceController ]; } + private function eventChargesSupportsCreatedBy(): bool + { + if ($this->eventChargesHasCreatedBy !== null) { + return $this->eventChargesHasCreatedBy; + } + + try { + $db = Database::connect(); + $this->eventChargesHasCreatedBy = $db->fieldExists('created_by', 'event_charges'); + } catch (\Throwable $e) { + $this->eventChargesHasCreatedBy = false; + } + + return $this->eventChargesHasCreatedBy; + } + + private function eventChargesSupportsWaiverSigned(): bool + { + if ($this->eventChargesHasWaiverSigned !== null) { + return $this->eventChargesHasWaiverSigned; + } + + try { + $db = Database::connect(); + $this->eventChargesHasWaiverSigned = $db->fieldExists('waiver_signed', 'event_charges'); + } catch (\Throwable $e) { + $this->eventChargesHasWaiverSigned = false; + } + + return $this->eventChargesHasWaiverSigned; + } + + private function getEventChargesReturnTo(): string + { + $fallback = site_url('administrator/event-charges'); + $returnTo = trim((string) ($this->request->getPost('return_to') ?? '')); + if ($returnTo === '') { + return $fallback; + } + + $base = rtrim((string) base_url(), '/'); + if (str_starts_with($returnTo, '/')) { + return $returnTo; + } + if ($base !== '' && str_starts_with($returnTo, $base)) { + return $returnTo; + } + + return $fallback; + } + public function index() { $eventModel = new EventModel(); @@ -93,59 +170,52 @@ class EventController extends ResourceController 'created_by' => session()->get('user_id'), ]); - if ($eventId) { - $amount = (float) $this->request->getPost('amount'); - $semester = (string) $this->request->getPost('semester'); - $schoolYear = (string) $this->request->getPost('school_year'); - $userId = (int) (session()->get('user_id') ?? 0); + if ($eventId && $this->request->getPost('add_to_calendar')) { + $calendarModel = new CalendarModel(); - $enrollments = $this->enrollmentModel - ->select('enrollments.student_id, students.parent_id') - ->join('students', 'students.id = enrollments.student_id', 'left') - ->where('enrollments.school_year', $schoolYear) - ->whereIn('enrollments.enrollment_status', ['enrolled', 'payment pending']) - ->findAll(); + $title = trim((string)$this->request->getPost('event_name')); + $date = (string)$this->request->getPost('expiration_date'); + $schoolYear = (string)$this->request->getPost('school_year'); + $semester = (string)$this->request->getPost('semester'); - $parentIds = []; - foreach ($enrollments as $row) { - $studentId = (int) ($row['student_id'] ?? 0); - $parentId = (int) ($row['parent_id'] ?? 0); - if ($studentId <= 0 || $parentId <= 0) { - continue; + if ($title !== '' && $date !== '' && $schoolYear !== '') { + $data = [ + 'title' => $title, + 'description' => (string)$this->request->getPost('description'), + 'event_type' => 'Event', + 'date' => $date, + 'notify_parent' => $this->request->getPost('notify_parent') ? 1 : 0, + 'notify_teacher' => $this->request->getPost('notify_teacher') ? 1 : 0, + 'notify_admin' => $this->request->getPost('notify_admin') ? 1 : 0, + 'no_school' => 0, + 'school_year' => $schoolYear, + 'semester' => $semester ?: $this->semester, + ]; + if (!$calendarModel->supportsEventType()) { + unset($data['event_type']); } - $exists = $this->eventChargesModel - ->where('event_id', $eventId) - ->where('student_id', $studentId) - ->where('school_year', $schoolYear) - ->where('semester', $semester) - ->first(); - - if ($exists) { - continue; + $dupQuery = $calendarModel + ->where('school_year', $data['school_year']) + ->where('date', $data['date']) + ->where('title', $data['title']); + if ($calendarModel->supportsEventType() && isset($data['event_type'])) { + $dupQuery = $dupQuery->where('event_type', $data['event_type']); + } + $existing = $dupQuery->first(); + if (!$existing) { + $calendarModel->save($data); } - - $this->eventChargesModel->insert([ - 'event_id' => $eventId, - 'parent_id' => $parentId, - 'student_id' => $studentId, - 'participation' => 'yes', - 'charged' => $amount, - 'school_year' => $schoolYear, - 'semester' => $semester, - 'updated_by' => $userId ?: null, - ]); - - $parentIds[] = $parentId; - } - - $parentIds = array_unique($parentIds); - foreach ($parentIds as $pid) { - $this->invoiceController->generateInvoice((string) $pid); } } - return redirect()->to('/administrator/events')->with('success', 'Event created successfully'); + $redirect = redirect()->to('/administrator/events')->with('success', 'Event created successfully'); + if ($eventId && $this->request->getPost('send_email_parent')) { + $emailStatus = $this->broadcastEventToParents((int) $eventId); + $redirect = $this->applyEventEmailFlash($redirect, $emailStatus); + } + + return $redirect; } return view('administrator/events/create_event', [ @@ -186,7 +256,65 @@ class EventController extends ResourceController ]); if ($updated) { - return redirect()->to('/administrator/events')->with('success', 'Event updated successfully'); + if ($this->request->getPost('add_to_calendar')) { + $calendarModel = new CalendarModel(); + + $title = trim((string) $this->request->getPost('event_name')); + $date = (string) $this->request->getPost('expiration_date'); + $schoolYear = (string) $this->request->getPost('school_year'); + $semester = (string) $this->request->getPost('semester'); + + if ($title !== '' && $date !== '' && $schoolYear !== '') { + $data = [ + 'title' => $title, + 'description' => (string) $this->request->getPost('description'), + 'event_type' => 'Event', + 'date' => $date, + 'notify_parent' => $this->request->getPost('notify_parent') ? 1 : 0, + 'notify_teacher' => $this->request->getPost('notify_teacher') ? 1 : 0, + 'notify_admin' => $this->request->getPost('notify_admin') ? 1 : 0, + 'no_school' => 0, + 'school_year' => $schoolYear, + 'semester' => $semester ?: $this->semester, + ]; + if (!$calendarModel->supportsEventType()) { + unset($data['event_type']); + } + + $existingByPreviousEvent = $calendarModel + ->where('school_year', (string) ($event['school_year'] ?? '')) + ->where('date', (string) ($event['expiration_date'] ?? '')) + ->where('title', (string) ($event['event_name'] ?? '')); + if ($calendarModel->supportsEventType() && isset($data['event_type'])) { + $existingByPreviousEvent = $existingByPreviousEvent->where('event_type', $data['event_type']); + } + $existingCalendar = $existingByPreviousEvent->first(); + + if ($existingCalendar) { + $calendarModel->update($existingCalendar['id'], $data); + } else { + $dupQuery = $calendarModel + ->where('school_year', $data['school_year']) + ->where('date', $data['date']) + ->where('title', $data['title']); + if ($calendarModel->supportsEventType() && isset($data['event_type'])) { + $dupQuery = $dupQuery->where('event_type', $data['event_type']); + } + $duplicate = $dupQuery->first(); + + if (!$duplicate) { + $calendarModel->save($data); + } + } + } + } + + $redirect = redirect()->to('/administrator/events')->with('success', 'Event updated successfully'); + if ($this->request->getPost('send_email_parent')) { + $emailStatus = $this->broadcastEventToParents((int) $id); + $redirect = $this->applyEventEmailFlash($redirect, $emailStatus); + } + return $redirect; } else { log_message('debug', 'GET detected'); return redirect()->back()->with('error', 'Failed to update event'); @@ -199,6 +327,106 @@ class EventController extends ResourceController ]); } + private function broadcastEventToParents(int $eventId): array + { + $event = $this->eventModel->find($eventId); + if (!$event) { + return ['ok' => false, 'count' => 0, 'message' => 'Event email was skipped because the event could not be loaded.']; + } + + $emails = $this->collectParentBroadcastEmails((string) ($event['school_year'] ?? '')); + if ($emails === []) { + return ['ok' => false, 'count' => 0, 'message' => 'Event email was skipped because no parent email addresses were found.']; + } + + $subject = 'School Event: ' . trim((string) ($event['event_name'] ?? 'Upcoming Event')); + $innerBody = view('emails/event_broadcast', [ + 'event' => $event, + 'eventUrl' => base_url('parent/events'), + 'flyerUrl' => !empty($event['flyer']) ? base_url('uploads/' . ltrim((string) $event['flyer'], '/')) : null, + ]); + $body = view('emails/_wrap_layout', [ + 'title' => $subject, + 'subject' => $subject, + 'body_html' => $innerBody, + ]); + + $ok = $this->emailService->sendCc($emails, $subject, $body, 'general'); + $batchCount = $this->emailService->getLastCcBatchCount(); + + return [ + 'ok' => $ok, + 'count' => count($emails), + 'batches' => $batchCount, + 'message' => $ok + ? 'Event email was sent in ' . $batchCount . ' CC batch(es) for ' . count($emails) . ' recipient(s).' + : 'The event was saved, but sending the parent CC email failed.', + ]; + } + + private function collectParentBroadcastEmails(string $schoolYear = ''): array + { + $emails = []; + + $db = Database::connect(); + + $parentRole = $db->table('roles') + ->select('id') + ->where('LOWER(name)', 'parent') + ->get() + ->getRow(); + + if ($parentRole) { + $firstParentQuery = $db->table('users') + ->select('users.email') + ->join('user_roles', 'user_roles.user_id = users.id') + ->where('user_roles.role_id', (int) $parentRole->id) + ->where('users.email IS NOT NULL', null, false) + ->where('users.email !=', ''); + + if ($schoolYear !== '' && $db->fieldExists('school_year', 'users')) { + $firstParentQuery->where('users.school_year', $schoolYear); + } + + foreach ($firstParentQuery->get()->getResultArray() as $row) { + $email = trim((string) ($row['email'] ?? '')); + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $emails[strtolower($email)] = $email; + } + } + } + + $secondParentQuery = $db->table('parents') + ->select('secondparent_email') + ->where('secondparent_email IS NOT NULL', null, false) + ->where('secondparent_email !=', ''); + + if ($schoolYear !== '' && $db->fieldExists('school_year', 'parents')) { + $secondParentQuery->where('school_year', $schoolYear); + } + + foreach ($secondParentQuery->get()->getResultArray() as $row) { + $email = trim((string) ($row['secondparent_email'] ?? '')); + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $emails[strtolower($email)] = $email; + } + } + + return array_values($emails); + } + + private function applyEventEmailFlash($redirect, array $emailStatus) + { + if (!empty($emailStatus['message'])) { + return $redirect->with( + ($emailStatus['ok'] ?? false) ? 'email_status' : 'email_error', + $emailStatus['message'] + ); + } + + return $redirect; + } + public function delete($id = null) { @@ -218,6 +446,19 @@ class EventController extends ResourceController $parentIds[] = $charge['parent_id']; } + // Also remove any invoice_event rows tied to this event + $eventName = $event['event_name'] ?? null; + if ($eventName) { + $builder = $this->invoiceEventModel->where('event_name', $eventName); + if (!empty($event['school_year'])) { + $builder->where('school_year', $event['school_year']); + } + if (!empty($event['semester'])) { + $builder->where('semester', $event['semester']); + } + $builder->delete(); + } + // Delete event $this->eventModel->delete($id); @@ -239,27 +480,102 @@ class EventController extends ResourceController $semester = $this->request->getGet('semester') ?? $this->semester; $parents = $this->userModel->getParents(); - $events = $this->eventModel->getActiveEvents($this->schoolYear); + $events = $this->eventModel->getActiveEvents($schoolYear); + $filterEventId = (int) ($this->request->getGet('event_id') ?? 0); + $filterParentId = (int) ($this->request->getGet('parent_id') ?? 0); - $charges = $this->eventChargesModel + $chargesBuilder = $this->eventChargesModel ->select('event_charges.*, users.firstname AS parent_firstname, users.lastname AS parent_lastname, students.firstname AS student_firstname, students.lastname AS student_lastname, - events.event_name') + events.event_name, events.description AS event_description, events.amount AS event_amount') ->join('users', 'users.id = event_charges.parent_id', 'left') ->join('students', 'students.id = event_charges.student_id', 'left') ->join('events', 'events.id = event_charges.event_id', 'left') ->where('event_charges.school_year', $schoolYear) - ->where('event_charges.semester', $semester) + ->where('event_charges.semester', $semester); + + if ($filterEventId > 0) { + $chargesBuilder->where('event_charges.event_id', $filterEventId); + } + + $charges = $chargesBuilder ->orderBy('event_charges.created_at', 'DESC') ->findAll(); + foreach ($charges as &$charge) { + if (empty($charge['class_section_id']) && !empty($charge['student_id'])) { + $sections = $this->studentClassModel->getClassSectionIdsByStudentId( + (int)$charge['student_id'], + $schoolYear + ); + if (!empty($sections)) { + $charge['class_section_id'] = $sections[0]; + } + } + } + unset($charge); + + $parentBalances = []; + try { + $balanceRows = $this->invoiceModel + ->select('parent_id, COALESCE(SUM(balance),0) AS total_balance') + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->groupBy('parent_id') + ->findAll(); + + foreach ($balanceRows as $row) { + $parentBalances[(int)($row['parent_id'] ?? 0)] = (float)($row['total_balance'] ?? 0.0); + } + } catch (\Throwable $e) { + log_message('error', 'Failed to load parent balances: ' . $e->getMessage()); + } + + $sectionIds = array_unique(array_filter(array_column($charges, 'class_section_id'))); + $classSectionNames = []; + if (!empty($sectionIds)) { + $sections = $this->classSectionModel + ->select('class_section_id, class_section_name') + ->whereIn('class_section_id', $sectionIds) + ->findAll(); + foreach ($sections as $section) { + $classSectionNames[(int)($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? ''; + } + } + + $semesterOptions = (new EventChargesModel()) + ->select('semester') + ->distinct() + ->orderBy('semester', 'ASC') + ->findColumn('semester'); + $semesterOptions = array_values(array_filter(array_unique($semesterOptions ?? []))); + if (empty($semesterOptions)) { + $semesterOptions = [$this->semester]; + } + + $schoolYearOptions = (new EventChargesModel()) + ->select('school_year') + ->distinct() + ->orderBy('school_year', 'ASC') + ->findColumn('school_year'); + $schoolYearOptions = array_values(array_filter(array_unique($schoolYearOptions ?? []))); + if (empty($schoolYearOptions)) { + $schoolYearOptions = [$this->schoolYear]; + } + return view('administrator/events/event_charges', [ 'charges' => $charges, 'parents' => $parents, 'events' => $events, 'school_year' => $schoolYear, 'semester' => $semester, + 'filterEventId' => $filterEventId, + 'filterParentId' => $filterParentId, + 'parentBalances' => $parentBalances, + 'classSectionNames' => $classSectionNames, + 'semesterOptions' => $semesterOptions, + 'schoolYearOptions' => $schoolYearOptions, ]); } @@ -272,13 +588,25 @@ class EventController extends ResourceController $parentId = $this->request->getPost('parent_id'); $eventId = $this->request->getPost('event_id'); $participations = $this->request->getPost('participation') ?? []; + $externalParticipants = $this->request->getPost('external_participants') ?? []; - if (!$parentId || !$eventId || empty($participations)) { + // Allow "external only" submissions (no enrolled students selected). + if (!$eventId || (empty($participations) && empty($externalParticipants))) { return redirect()->back()->with('error', 'Missing required information.'); } + if (!empty($participations) && !$parentId) { + return redirect()->back()->with('error', 'Select a parent when adding enrolled students.'); + } $userId = session()->get('user_id'); $event = $this->eventModel->getEvent($eventId, $schoolYear); + if (!$event) { + return redirect()->back()->with('error', 'Selected event not found for the chosen school year.'); + } + + $parentsForInvoice = []; + $supportsCreatedBy = $this->eventChargesSupportsCreatedBy(); + $supportsWaiverSigned = $this->eventChargesSupportsWaiverSigned(); foreach ($participations as $studentId => $value) { $existing = $this->eventChargesModel->where([ @@ -296,33 +624,434 @@ class EventController extends ResourceController continue; } - // value is 'yes' + $sectionIds = $this->studentClassModel->getClassSectionIdsByStudentId($studentId, $schoolYear); + $classSectionId = !empty($sectionIds) ? $sectionIds[0] : null; + if ($existing) { - $this->eventChargesModel->update($existing['id'], [ - 'participation' => 'yes', - 'charged' => $event['amount'], - 'updated_by' => $userId - ]); + $updateData = [ + 'participation' => 'yes', + 'updated_by' => $userId, + 'class_section_id'=> $classSectionId, + ]; + if ($supportsWaiverSigned) { + $updateData['waiver_signed'] = (int) ($existing['waiver_signed'] ?? 0); + } + if (isset($event['amount']) && ((float)$event['amount'] !== (float)($existing['charged'] ?? 0))) { + $updateData['charged'] = $event['amount']; + } + $this->eventChargesModel->update($existing['id'], $updateData); + continue; } else { - $this->eventChargesModel->insert([ + $insertData = [ 'parent_id' => $parentId, 'student_id' => $studentId, 'event_id' => $eventId, 'participation' => 'yes', 'charged' => $event['amount'], + 'event_paid' => 0, + 'class_section_id' => $classSectionId, 'school_year' => $schoolYear, 'semester' => $semester, - 'created_by' => $userId, 'updated_by' => $userId - ]); + ]; + if ($supportsWaiverSigned) { + $insertData['waiver_signed'] = 0; + } + if ($supportsCreatedBy) { + $insertData['created_by'] = $userId; + } + $this->eventChargesModel->insert($insertData); } } - $this->invoiceController->generateInvoice($parentId); + foreach ($externalParticipants as $pieces) { + $firstname = $this->normalizeExternalName($pieces['firstname'] ?? ''); + $lastname = $this->normalizeExternalName($pieces['lastname'] ?? ''); + $note = trim($pieces['note'] ?? ''); + $parentFirst = $this->normalizeExternalName($pieces['parent_firstname'] ?? ''); + $parentLast = $this->normalizeExternalName($pieces['parent_lastname'] ?? ''); + $parentPhone = trim($pieces['parent_phone'] ?? ''); + $parentEmail = trim((string)($pieces['parent_email'] ?? '')); + $markPaid = (string)($pieces['paid'] ?? '0') === '1'; + $waiverSigned = (string)($pieces['waiver_signed'] ?? '0') === '1'; + + if (!$firstname && !$lastname) { + continue; + } + + $matchedParentId = $this->findExistingParentIdByExternalContact( + $parentFirst, + $parentLast, + $parentEmail, + $parentPhone + ); + + // Match external participants by kid name + event context. + // Parent contact info can change (or be fixed later) and should be updated, not used for matching. + $matchConditions = [ + 'event_id' => $eventId, + 'external_firstname' => $firstname, + 'external_lastname' => $lastname, + 'school_year' => $schoolYear, + 'semester' => $semester, + ]; + if ($matchedParentId) { + $matchConditions['parent_id'] = $matchedParentId; + } else { + // External-only parent: match by external parent contact to avoid duplicates. + $matchConditions['parent_id'] = null; + $matchConditions['external_parent_firstname'] = $parentFirst ?: null; + $matchConditions['external_parent_lastname'] = $parentLast ?: null; + $matchConditions['external_parent_phone'] = $parentPhone ?: null; + $matchConditions['external_parent_email'] = $parentEmail ?: null; + } + $existingExternal = $this->eventChargesModel->where($matchConditions)->first(); + + if ($existingExternal) { + $updateData = [ + 'participation' => 'yes', + 'charged' => $event['amount'], + 'updated_by' => $userId, + // Preserve existing payment status; only update contact fields when provided. + 'external_note' => $note !== '' ? $note : ($existingExternal['external_note'] ?? null), + 'external_parent_firstname' => $parentFirst !== '' ? $parentFirst : ($existingExternal['external_parent_firstname'] ?? null), + 'external_parent_lastname' => $parentLast !== '' ? $parentLast : ($existingExternal['external_parent_lastname'] ?? null), + 'external_parent_phone' => $parentPhone !== '' ? $parentPhone : ($existingExternal['external_parent_phone'] ?? null), + 'external_parent_email' => $parentEmail !== '' ? $parentEmail : ($existingExternal['external_parent_email'] ?? null), + ]; + if ($supportsWaiverSigned) { + $updateData['waiver_signed'] = $waiverSigned ? 1 : 0; + } + $this->eventChargesModel->update($existingExternal['id'], $updateData); + continue; + } + + $insertData = [ + 'parent_id' => $matchedParentId ?: null, + 'student_id' => null, + 'event_id' => $eventId, + 'participation' => 'yes', + 'charged' => $event['amount'], + 'event_paid' => $markPaid ? 1 : 0, + 'external_firstname' => $firstname, + 'external_lastname' => $lastname, + 'external_note' => $note, + 'external_parent_firstname' => $parentFirst, + 'external_parent_lastname' => $parentLast, + 'external_parent_phone' => $parentPhone, + 'external_parent_email' => $parentEmail, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'updated_by' => $userId, + ]; + if ($supportsWaiverSigned) { + $insertData['waiver_signed'] = $waiverSigned ? 1 : 0; + } + if ($supportsCreatedBy) { + $insertData['created_by'] = $userId; + } + $this->eventChargesModel->insert($insertData); + + $newChargeId = (int)$this->eventChargesModel->getInsertID(); + if ($markPaid && $newChargeId > 0) { + $this->applyEventPaymentStatus($newChargeId, true); + } + + if ($matchedParentId && $this->parentHasEnrollment($matchedParentId, (string)$schoolYear)) { + $parentsForInvoice[$matchedParentId] = true; + } + } + + if ($parentId && $this->parentHasEnrollment((int)$parentId, (string)$schoolYear)) { + $parentsForInvoice[(int)$parentId] = true; + } + + foreach (array_keys($parentsForInvoice) as $pid) { + $this->invoiceController->generateInvoice((string)$pid, (string)$schoolYear, (string)$semester, false); + $this->fixInvoiceStatusAfterCharge((int)$pid, (string)$schoolYear); + } return redirect()->back()->with('success', 'Event charges updated successfully.'); } + public function removeCharge($chargeId = null) + { + $returnTo = $this->getEventChargesReturnTo(); + if (!$chargeId) { + return redirect()->to($returnTo)->with('error', 'Invalid charge.'); + } + + $charge = $this->eventChargesModel->find($chargeId); + if (!$charge) { + return redirect()->to($returnTo)->with('error', 'Charge not found.'); + } + + $paymentId = (int)($charge['event_payment_id'] ?? 0); + if ($paymentId > 0) { + $this->paymentModel->delete($paymentId); + } + + $this->eventChargesModel->delete($chargeId); + if (!empty($charge['parent_id']) && $this->parentHasEnrollment((int)$charge['parent_id'], (string)($charge['school_year'] ?? ''))) { + $this->invoiceController->generateInvoice((string)$charge['parent_id'], (string)($charge['school_year'] ?? $this->schoolYear), (string)($charge['semester'] ?? $this->semester), false); + } + + return redirect()->to($returnTo)->with('success', 'Participation removed, invoices updated.'); + } + + public function toggleEventPayment($chargeId = null) + { + $returnTo = $this->getEventChargesReturnTo(); + if (!$chargeId) { + return redirect()->to($returnTo)->with('error', 'Invalid charge.'); + } + + $isPaid = $this->request->getPost('paid') === '1'; + $meta = $this->applyEventPaymentStatus((int)$chargeId, $isPaid); + if (!$meta) { + return redirect()->to($returnTo)->with('error', 'Charge not found.'); + } + + if (!empty($meta['invoice_id'])) { + $this->invoiceController->generateInvoice((string)$meta['parent_id'], (string)$meta['school_year'], (string)($meta['semester'] ?? $this->semester), false); + $this->fixInvoiceStatusAfterCharge((int)$meta['parent_id'], (string)$meta['school_year']); + } + + return redirect()->to($returnTo)->with('success', 'Event payment status updated.'); + } + + public function toggleWaiverStatus($chargeId = null) + { + $returnTo = $this->getEventChargesReturnTo(); + if (!$chargeId) { + return redirect()->to($returnTo)->with('error', 'Invalid charge.'); + } + + if (!$this->eventChargesSupportsWaiverSigned()) { + return redirect()->to($returnTo)->with('error', 'Waiver tracking is not available until the database migration is applied.'); + } + + $charge = $this->eventChargesModel->find($chargeId); + if (!$charge) { + return redirect()->to($returnTo)->with('error', 'Charge not found.'); + } + + $signed = $this->request->getPost('waiver_signed') === '1'; + + $this->eventChargesModel->update((int) $chargeId, [ + 'waiver_signed' => $signed ? 1 : 0, + 'updated_by' => session()->get('user_id'), + ]); + + return redirect()->to($returnTo)->with('success', $signed ? 'Waiver marked as signed.' : 'Waiver marked as unsigned.'); + } + + private function parentHasEnrollment(int $parentId, string $schoolYear): bool + { + if ($parentId <= 0 || $schoolYear === '') { + return false; + } + + try { + return $this->enrollmentModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->countAllResults() > 0; + } catch (\Throwable $e) { + return false; + } + } + + private function findExistingParentIdByExternalContact(string $first, string $last, string $email, string $phone): ?int + { + $first = trim($first); + $last = trim($last); + $email = trim($email); + $phoneDigits = preg_replace('/\D+/', '', (string)$phone); + + if ($first === '' || $last === '' || $email === '' || $phoneDigits === '') { + return null; + } + + try { + $db = Database::connect(); + $expr = "REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') ="; + + $row = $this->userModel + ->select('id') + ->where('LOWER(firstname)', strtolower($first)) + ->where('LOWER(lastname)', strtolower($last)) + ->where('LOWER(email)', strtolower($email)) + ->where($expr, $db->escapeString($phoneDigits), false) + ->first(); + + $id = (int)($row['id'] ?? 0); + return $id > 0 ? $id : null; + } catch (\Throwable $e) { + log_message('error', 'Failed to match external parent to users: ' . $e->getMessage()); + return null; + } + } + + private function syncInvoicePaymentSummary(int $invoiceId): void + { + if ($invoiceId <= 0) { + return; + } + + $invoice = $this->invoiceModel->find($invoiceId); + if (!$invoice) { + return; + } + + $db = \Config\Database::connect(); + $exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']; + + $paidSum = 0.0; + try { + $qb = $db->table('payments') + ->select('COALESCE(SUM(paid_amount),0) AS tot') + ->where('invoice_id', $invoiceId) + ->where('paid_amount >', 0); + + if ($db->fieldExists('status', 'payments')) { + $qb->groupStart() + ->whereNotIn('status', $exclude) + ->orWhere('status IS NULL', null, false) + ->groupEnd(); + } + if ($db->fieldExists('is_void', 'payments')) { + $qb->groupStart() + ->where('is_void', 0) + ->orWhere('is_void IS NULL', null, false) + ->groupEnd(); + } + + $row = $qb->get()->getRowArray(); + $paidSum = (float)($row['tot'] ?? 0.0); + } catch (\Throwable $e) { + log_message('error', 'Failed to sum payments for invoice ' . $invoiceId . ': ' . $e->getMessage()); + } + + $discountSum = 0.0; + try { + $row = $db->table('discount_usages') + ->select('COALESCE(SUM(discount_amount),0) AS tot') + ->where('invoice_id', $invoiceId) + ->get() + ->getRowArray(); + $discountSum = (float)($row['tot'] ?? 0.0); + } catch (\Throwable $e) { + log_message('error', 'Failed to sum discounts for invoice ' . $invoiceId . ': ' . $e->getMessage()); + } + + $refundSum = 0.0; + try { + $row = $db->table('refunds') + ->select('COALESCE(SUM(refund_paid_amount),0) AS tot') + ->where('invoice_id', $invoiceId) + ->whereIn('status', ['Partial', 'Paid']) + ->get() + ->getRowArray(); + $refundSum = (float)($row['tot'] ?? 0.0); + } catch (\Throwable $e) { + log_message('error', 'Failed to sum refunds for invoice ' . $invoiceId . ': ' . $e->getMessage()); + } + + $total = (float)($invoice['total_amount'] ?? 0.0); + $newBalance = round($total - $discountSum - $refundSum - $paidSum, 2); + $newStatus = ($newBalance <= 0.00001) + ? 'Paid' + : (($paidSum > 0) ? 'Partially Paid' : 'Unpaid'); + + $this->invoiceModel->update($invoiceId, [ + 'paid_amount' => $paidSum, + 'balance' => $newBalance, + 'status' => $newStatus, + 'updated_at' => utc_now(), + ]); + } + + private function applyEventPaymentStatus(int $chargeId, bool $isPaid): ?array + { + if ($chargeId <= 0) { + return null; + } + + $charge = $this->eventChargesModel->find($chargeId); + if (!$charge) { + return null; + } + + $event = $this->eventModel->find($charge['event_id']); + $eventAmount = max(0.0, (float)($event['amount'] ?? 0)); + $paymentId = (int)($charge['event_payment_id'] ?? 0); + + $parentId = (int)($charge['parent_id'] ?? 0); + $schoolYear = (string)($charge['school_year'] ?? $this->schoolYear); + $semester = (string)($charge['semester'] ?? $this->semester); + + if ($parentId > 0 && $eventAmount > 0) { + $hasEnrollment = $this->parentHasEnrollment($parentId, $schoolYear); + $invoiceQuery = $this->invoiceModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->orderBy('id', 'DESC'); + $invoice = $invoiceQuery->first(); + + if (!$invoice && $hasEnrollment) { + $this->invoiceController->generateInvoice((string)$parentId, $schoolYear, $semester, false); + $invoice = $invoiceQuery->first(); + } + + if ($invoice) { + $invoiceId = (int)($invoice['id'] ?? 0); + if ($invoiceId > 0) { + $this->syncInvoicePaymentSummary($invoiceId); + $invoice = $this->invoiceModel->find($invoiceId) ?? $invoice; + } + + if ($isPaid && !$paymentId) { + $paymentSchoolYear = (string)($invoice['school_year'] ?? $schoolYear); + $paymentSemester = (string)($invoice['semester'] ?? $semester); + $paymentId = (int)($this->createEventPayment( + $parentId, + (int)$invoice['id'], + $eventAmount, + $paymentSchoolYear, + $paymentSemester, + (float)($invoice['total_amount'] ?? 0.0), + (float)($invoice['paid_amount'] ?? 0.0), + (float)($invoice['balance'] ?? 0.0) + ) ?? 0); + } elseif (!$isPaid && $paymentId > 0) { + $this->paymentModel->delete($paymentId); + $paymentId = 0; + } + + if (!empty($invoiceId)) { + $this->syncInvoicePaymentSummary($invoiceId); + } + } elseif (!$hasEnrollment) { + // External-only parent: do not create an invoice/payment row; just persist the paid flag on the charge. + $paymentId = 0; + } + } + + $this->eventChargesModel->update($chargeId, [ + 'event_paid' => $isPaid ? 1 : 0, + 'charged' => $eventAmount, + 'event_payment_id' => $paymentId > 0 ? $paymentId : null, + ]); + + return [ + 'parent_id' => $parentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'invoice_id' => isset($invoiceId) && $invoiceId > 0 ? $invoiceId : null, + ]; + } + @@ -336,10 +1065,18 @@ class EventController extends ResourceController $students = $this->studentModel->where('parent_id', $parentId)->findAll(); // Get student_ids that already have charges - $chargedStudentIds = $this->eventChargesModel + $eventId = $this->request->getGet('event_id'); + + $chargesBuilder = $this->eventChargesModel ->where('parent_id', $parentId) ->where('semester', $semester) - ->where('school_year', $schoolYear) + ->where('school_year', $schoolYear); + + if (!empty($eventId)) { + $chargesBuilder->where('event_id', $eventId); + } + + $chargedStudentIds = $chargesBuilder ->groupBy('student_id') ->select('student_id') ->findColumn('student_id'); @@ -356,5 +1093,98 @@ class EventController extends ResourceController return $this->response->setJSON($data); } + private function createEventPayment( + int $parentId, + int $invoiceId, + float $amount, + string $schoolYear, + ?string $semester, + float $invoiceTotal = 0.0, + float $invoicePaid = 0.0, + float $invoiceBalance = 0.0 + ): ?int + { + if ($amount <= 0 || $invoiceId <= 0 || $parentId <= 0) { + return null; + } + + $monthSemester = $semester ?: $this->semester; + $newPaid = (float)$invoicePaid + $amount; + $newBalance = (float)$invoiceBalance - $amount; + $paymentStatus = ($newBalance <= 0.00001) + ? 'Paid' + : (($newPaid > 0) ? 'Partially Paid' : 'Unpaid'); + + $exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']; + $priorCount = $this->paymentModel + ->where('invoice_id', $invoiceId) + ->where('paid_amount >', 0) + ->whereNotIn('status', $exclude) + ->countAllResults(); + $installmentSeq = $priorCount + 1; + + $data = [ + 'parent_id' => $parentId, + 'invoice_id' => $invoiceId, + 'total_amount' => $invoiceTotal > 0 ? $invoiceTotal : $amount, + 'paid_amount' => $amount, + 'balance' => $newBalance, + 'number_of_installments' => $installmentSeq, + 'payment_method' => 'cash', + 'payment_date' => utc_now(), + 'school_year' => $schoolYear, + 'semester' => $monthSemester, + 'status' => $paymentStatus, + 'transaction_id' => $this->paymentModel->generateNewTransactionId(), + 'updated_by' => session()->get('user_id'), + ]; + + if (!$this->paymentModel->insert($data)) { + log_message('error', 'Failed to insert event payment: ' . json_encode($this->paymentModel->errors())); + return null; + } + + return (int)$this->paymentModel->getInsertID(); + } + + private function normalizeExternalName(?string $value): string + { + $raw = trim((string)$value); + if ($raw === '') { + return ''; + } + $parts = preg_split('/\s+/', $raw); + $formatted = array_map(function ($part) { + $part = trim($part); + if ($part === '') { + return ''; + } + return mb_strtoupper(mb_substr($part, 0, 1)) . mb_strtolower(mb_substr($part, 1)); + }, $parts); + return implode(' ', array_filter($formatted, fn($p) => $p !== '')); + } + + private function fixInvoiceStatusAfterCharge($parentId, $schoolYear) + { + if (!$parentId || !$schoolYear) { + return; + } + + $invoices = $this->invoiceModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->findAll(); + + foreach ($invoices as $invoice) { + $status = strtolower(trim($invoice['status'] ?? '')); + $balance = (float)($invoice['balance'] ?? 0.0); + + if ($balance <= 0.00001 && $status !== 'paid') { + $this->invoiceModel->update($invoice['id'], ['status' => 'Paid']); + } elseif ($status === 'paid' && $balance > 0) { + $this->invoiceModel->update($invoice['id'], ['status' => 'Unpaid']); + } + } + } } diff --git a/app/Controllers/View/ExamDraftController.php b/app/Controllers/View/ExamDraftController.php old mode 100644 new mode 100755 index f95e7a7..f4862f6 --- a/app/Controllers/View/ExamDraftController.php +++ b/app/Controllers/View/ExamDraftController.php @@ -6,6 +6,7 @@ use App\Controllers\BaseController; use App\Models\ClassSectionModel; use App\Models\ConfigurationModel; use App\Models\ExamDraftModel; +use App\Models\StudentClassModel; use App\Models\TeacherClassModel; use App\Models\UserModel; use CodeIgniter\HTTP\Files\UploadedFile; @@ -16,6 +17,7 @@ class ExamDraftController extends BaseController protected ExamDraftModel $examDraftModel; protected TeacherClassModel $teacherClassModel; protected ClassSectionModel $classSectionModel; + protected StudentClassModel $studentClassModel; protected UserModel $userModel; protected ConfigurationModel $configModel; @@ -23,9 +25,19 @@ class ExamDraftController extends BaseController protected string $semester; protected bool $hasFinalPdfColumn = false; protected bool $hasIsLegacyColumn = false; + protected bool $hasAcceptanceTypeColumn = false; + protected bool $hasAdminIdColumn = false; + protected bool $hasReviewRevisionColumn = false; + protected bool $hasReviewerCommentColumn = false; + protected bool $hasReviewerCommentsColumn = false; + protected bool $hasAdminCommentsColumn = false; + protected string $authorIdColumn = 'teacher_id'; + protected string $authorFileColumn = 'teacher_file'; + protected string $authorFilenameColumn = 'teacher_filename'; + protected string $reviewerIdColumn = 'admin_id'; + protected string $reviewerCommentColumn = 'reviewer_comment'; - // DB enum: draft, submitted, reviewed, finalized, rejected - // (string literals used to avoid excess constants) + // DB status: submitted, accepted, review needed, rejected, canceled, under review, legacy protected const TEACHER_UPLOAD_DIR = 'exams/drafts'; protected const FINAL_UPLOAD_DIR = 'exams/finals'; @@ -47,6 +59,7 @@ class ExamDraftController extends BaseController $this->examDraftModel = new ExamDraftModel(); $this->teacherClassModel = new TeacherClassModel(); $this->classSectionModel = new ClassSectionModel(); + $this->studentClassModel = new StudentClassModel(); $this->userModel = new UserModel(); $this->configModel = new ConfigurationModel(); $this->db = Database::connect(); @@ -55,6 +68,23 @@ class ExamDraftController extends BaseController $this->semester = (string) ($this->configModel->getConfig('semester') ?? ''); $this->hasFinalPdfColumn = $this->schemaHasColumn('exam_drafts', 'final_pdf_file'); $this->hasIsLegacyColumn = $this->schemaHasColumn('exam_drafts', 'is_legacy'); + $this->hasAcceptanceTypeColumn = $this->schemaHasColumn('exam_drafts', 'acceptance_type'); + $this->hasAdminIdColumn = $this->schemaHasColumn('exam_drafts', 'admin_id'); + $this->hasReviewRevisionColumn = $this->schemaHasColumn('exam_drafts', 'review_revision'); + $this->hasReviewerCommentColumn = $this->schemaHasColumn('exam_drafts', 'reviewer_comment'); + $this->hasReviewerCommentsColumn = $this->schemaHasColumn('exam_drafts', 'reviewer_comments'); + $this->hasAdminCommentsColumn = $this->schemaHasColumn('exam_drafts', 'admin_comments'); + $this->authorIdColumn = $this->schemaHasColumn('exam_drafts', 'teacher_id') ? 'teacher_id' : 'author_id'; + $this->authorFileColumn = $this->schemaHasColumn('exam_drafts', 'teacher_file') ? 'teacher_file' : 'author_file'; + $this->authorFilenameColumn = $this->schemaHasColumn('exam_drafts', 'teacher_filename') ? 'teacher_filename' : 'author_filename'; + $this->reviewerIdColumn = $this->hasAdminIdColumn + ? 'admin_id' + : ($this->schemaHasColumn('exam_drafts', 'reviewer_id') ? 'reviewer_id' : ''); + $this->reviewerCommentColumn = $this->schemaHasColumn('exam_drafts', 'reviewer_comment') + ? 'reviewer_comment' + : ($this->schemaHasColumn('exam_drafts', 'reviewer_comments') + ? 'reviewer_comments' + : ($this->schemaHasColumn('exam_drafts', 'admin_comments') ? 'admin_comments' : '')); helper(['form', 'url', 'date']); } @@ -72,8 +102,9 @@ class ExamDraftController extends BaseController session()->set('class_section_id', $selectedClass); } - $allDrafts = $this->examDraftModel - ->where('teacher_id', $teacherId) + $classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments); + + $allDrafts = $this->visibleTeacherDraftsQuery($teacherId, $classSectionIds) ->orderBy('created_at', 'DESC') ->findAll(); @@ -84,35 +115,55 @@ class ExamDraftController extends BaseController $row['final_pdf_file'] = $pdf; } } + $row = $this->attachTeacherDraftContext($row, $teacherId); } unset($row); - $classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments); $legacyExams = []; // Guard against missing schema column in older databases if ($this->hasIsLegacyColumn) { // Keep all submissions visible; legacy ones are also surfaced in a separate tab $drafts = $allDrafts; + $legacyQuery = $this->examDraftModel + ->select($this->draftSelectColumns()) + ->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last') + ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') + ->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left') + ->where('exam_drafts.is_legacy', 1) + ->where('exam_drafts.status', 'legacy') + ->where('exam_drafts.final_file IS NOT NULL', null, false); + if (!empty($classSectionIds)) { - $legacyExams = $this->examDraftModel - ->select('exam_drafts.*, cs.class_section_name') - ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') - ->whereIn('exam_drafts.class_section_id', $classSectionIds) - ->where('exam_drafts.is_legacy', 1) - ->where('exam_drafts.final_file IS NOT NULL', null, false) - ->orderBy('cs.class_section_name', 'ASC') - ->orderBy('exam_drafts.created_at', 'DESC') - ->findAll(); + $legacyQuery = $legacyQuery->whereIn('exam_drafts.class_section_id', $classSectionIds); } + + $legacyExams = $legacyQuery + ->orderBy('cs.class_section_name', 'ASC') + ->orderBy('exam_drafts.created_at', 'DESC') + ->findAll(); + + foreach ($legacyExams as &$row) { + $row = $this->attachTeacherDraftContext($row, $teacherId); + } + unset($row); } else { // Legacy column absent, show all drafts and skip legacy tab query $drafts = $allDrafts; } + $printablePool = array_merge($drafts, $legacyExams); + $printableIds = $this->printableDraftIds($printablePool); + if ($this->hasReviewRevisionColumn) { + $drafts = $this->groupReviewRevisions($drafts); + $legacyExams = $this->groupReviewRevisions($legacyExams); + } + $drafts = $this->attachPrintableFlags($drafts, $printableIds); + $legacyExams = $this->attachPrintableFlags($legacyExams, $printableIds); + $validation = session()->getFlashdata('validation') ?? $this->validator; - return view('teacher/exam_drafts', [ + return view('teacher/drafts', [ 'assignments' => $assignments, 'selectedClassSection' => $selectedClass, 'drafts' => $drafts, @@ -123,6 +174,7 @@ class ExamDraftController extends BaseController 'semester' => $this->semester, 'maxUploadBytes' => self::MAX_UPLOAD_BYTES, 'validation' => $validation, + 'printableDraftIds' => $printableIds, ]); } @@ -134,8 +186,8 @@ class ExamDraftController extends BaseController } $classSectionId = (int) ($this->request->getPost('class_section_id') ?? session()->get('class_section_id') ?? 0); - $examType = trim((string) $this->request->getPost('exam_type')); - $description = trim((string) $this->request->getPost('description')); + $examType = $this->normalizeExamType($this->request->getPost('exam_type')); + $authorComment = trim((string) $this->request->getPost('author_comment')); if ($classSectionId <= 0) { return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.'); @@ -152,12 +204,8 @@ class ExamDraftController extends BaseController session()->set('class_section_id', $classSectionId); - if ($classSectionId <= 0) { - $classSectionId = $this->resolveSelectedClassSection($assignments); - if ($classSectionId <= 0) { - return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.'); - } - } + $saveAsDraft = $this->request->getPost('action') === 'draft' + || !empty($this->request->getPost('save_as_draft')); $file = $this->request->getFile('draft_file'); $teacherFile = null; @@ -174,38 +222,125 @@ class ExamDraftController extends BaseController } $existingDraft = $this->examDraftModel - ->where('teacher_id', $teacherId) + ->select($this->draftSelectColumns()) + ->where($this->authorIdColumn, $teacherId) ->where('class_section_id', $classSectionId) ->where('semester', $this->semester) ->where('school_year', $this->schoolYear) ->orderBy('version', 'DESC') ->first(); + if ($examType === '') { + return redirect()->back()->withInput()->with('error', 'Select an exam type before submitting.'); + } + + $title = $examType; + + if ($saveAsDraft) { + if ($teacherFile === null && (empty($existingDraft) || empty($this->draftTeacherFile($existingDraft)))) { + return redirect()->back()->withInput()->with('error', 'Upload a file to save a draft.'); + } + if (!empty($existingDraft) && strtolower((string) ($existingDraft['status'] ?? '')) === 'draft') { + $draftRowId = (int) ($existingDraft['id'] ?? 0); + $updateDraft = [ + 'exam_type' => $examType, + 'draft_title' => $title, + 'author_comment' => $authorComment === '' ? null : $authorComment, + 'status' => 'draft', + ]; + $this->applyAuthorFilePayload($updateDraft, $teacherFile, $teacherFilename); + if ($this->examDraftModel->update($draftRowId, $updateDraft)) { + $this->notifyExamDraftEvent($this->examDraftModel->find($draftRowId), 'draft_saved'); + return redirect()->to('/teacher/exam-drafts')->with('success', 'Draft saved.'); + } + return redirect()->back()->withInput()->with('error', 'Unable to update the draft.'); + } + + $nextVersion = 1; + $previousId = null; + if (!empty($existingDraft)) { + $currentVersion = (int) ($existingDraft['version'] ?? 0); + if ($currentVersion <= 0) { + $currentVersion = 1; + } + $nextVersion = $currentVersion + 1; + $previousId = (int) ($existingDraft['id'] ?? 0) ?: null; + } + $payload = [ + $this->authorIdColumn => $teacherId, + 'class_section_id' => $classSectionId, + 'semester' => $this->semester, + 'school_year' => $this->schoolYear, + 'exam_type' => $examType, + 'draft_title' => $title, + 'author_comment' => $authorComment === '' ? null : $authorComment, + 'status' => 'draft', + 'version' => $nextVersion, + 'previous_draft_id' => $previousId, + ]; + $this->applyAuthorFilePayload($payload, $teacherFile, $teacherFilename); + if ($this->examDraftModel->insert($payload)) { + $newDraftId = (int) $this->examDraftModel->getInsertID(); + $this->notifyExamDraftEvent($newDraftId > 0 ? $this->examDraftModel->find($newDraftId) : null, 'draft_saved'); + return redirect()->to('/teacher/exam-drafts')->with('success', 'Draft saved.'); + } + return redirect()->back()->withInput()->with('error', 'Unable to save the draft.'); + } + + $existingStatus = strtolower((string) ($existingDraft['status'] ?? '')); + if ($teacherFile === null) { + return redirect()->back()->withInput()->with('error', 'Upload a DOC/DOCX file to submit the exam draft.'); + } + + // Submit for review: overwrite only if latest is already submitted; otherwise create a new version. + if (!empty($existingDraft) && $existingStatus === 'submitted') { + $draftRowId = (int) ($existingDraft['id'] ?? 0); + $updateSubmit = [ + 'exam_type' => $examType, + 'draft_title' => $title, + 'author_comment' => $authorComment === '' ? null : $authorComment, + 'status' => 'submitted', + ]; + $this->applyAuthorFilePayload($updateSubmit, $teacherFile, $teacherFilename); + if ($this->examDraftModel->update($draftRowId, $updateSubmit)) { + $saved = $this->ensureDraftStatus($draftRowId, 'submitted'); + $this->notifyPrincipalExamDraftSubmitted($saved, $teacherId, $classSectionId); + $this->notifyExamDraftEvent($saved, 'submitted'); + return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.'); + } + return redirect()->back()->withInput()->with('error', 'Unable to submit the exam draft.'); + } + $nextVersion = 1; $previousId = null; if (!empty($existingDraft)) { - $nextVersion = ((int) ($existingDraft['version'] ?? 1)) + 1; + $currentVersion = (int) ($existingDraft['version'] ?? 0); + if ($currentVersion <= 0) { + $currentVersion = 1; + } + $nextVersion = $currentVersion + 1; $previousId = (int) ($existingDraft['id'] ?? 0) ?: null; } - $title = $examType ?: 'Exam Draft'; - $payload = [ - 'teacher_id' => $teacherId, + $this->authorIdColumn => $teacherId, 'class_section_id' => $classSectionId, 'semester' => $this->semester, 'school_year' => $this->schoolYear, - 'exam_type' => $examType ?: null, + 'exam_type' => $examType, 'draft_title' => $title, - 'description' => $description === '' ? null : $description, - 'teacher_file' => $teacherFile, - 'teacher_filename' => $teacherFilename, + 'author_comment' => $authorComment === '' ? null : $authorComment, 'status' => 'submitted', 'version' => $nextVersion, 'previous_draft_id' => $previousId, ]; + $this->applyAuthorFilePayload($payload, $teacherFile, $teacherFilename); if ($this->examDraftModel->insert($payload)) { + $newId = (int) $this->examDraftModel->getInsertID(); + $saved = $newId > 0 ? $this->ensureDraftStatus($newId, 'submitted') : null; + $this->notifyPrincipalExamDraftSubmitted($saved, $teacherId, $classSectionId); + $this->notifyExamDraftEvent($saved, 'submitted'); return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.'); } @@ -214,13 +349,38 @@ class ExamDraftController extends BaseController public function adminIndex() { - $allDrafts = $this->examDraftModel - ->select('exam_drafts.*, cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, a.firstname AS admin_first, a.lastname AS admin_last') - ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') - ->join('users u', 'u.id = exam_drafts.teacher_id', 'left') - ->join('users a', 'a.id = exam_drafts.admin_id', 'left') - ->orderBy('exam_drafts.created_at', 'DESC') - ->findAll(); + return $this->reviewIndex(); + } + + public function principalIndex() + { + return $this->reviewIndex(); + } + + public function reviewIndex() + { + if ($this->reviewerIdColumn !== '') { + $allDrafts = $this->examDraftModel + ->select($this->draftSelectColumns()) + ->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, a.firstname AS admin_first, a.lastname AS admin_last') + ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') + ->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left') + ->join('users a', 'a.id = exam_drafts.' . $this->reviewerIdColumn, 'left') + ->orderBy('exam_drafts.created_at', 'DESC') + ->findAll(); + } else { + $allDrafts = $this->examDraftModel + ->select($this->draftSelectColumns()) + ->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, NULL AS admin_first, NULL AS admin_last', false) + ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') + ->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left') + ->orderBy('exam_drafts.created_at', 'DESC') + ->findAll(); + } + + if ($this->hasReviewRevisionColumn) { + $allDrafts = $this->groupReviewRevisions($allDrafts); + } foreach ($allDrafts as &$row) { if (empty($row['final_pdf_file'])) { @@ -237,36 +397,95 @@ class ExamDraftController extends BaseController ->orderBy('class_section_name', 'ASC') ->findAll(); - // Group legacy uploads (admin-uploaded finalized exams) by class_section for separate tab + // Group legacy uploads (admin-uploaded accepted exams) by class_section for separate tab $legacyByClass = []; if ($this->hasIsLegacyColumn) { - // Keep all submissions visible; additionally surface legacy items in a separate tab - $drafts = $allDrafts; - + // Keep legacy items out of the main submissions list; show them in the legacy tab only. + $drafts = []; foreach ($allDrafts as $d) { $isLegacy = !empty($d['is_legacy']); - if (!$isLegacy) { + if ($isLegacy) { + $cid = (int)($d['class_section_id'] ?? 0); + if (!isset($legacyByClass[$cid])) { + $legacyByClass[$cid] = [ + 'class_section_id' => $cid, + 'class_section_name' => $d['class_section_name'] ?? 'Class ' . $cid, + 'items' => [], + ]; + } + $legacyByClass[$cid]['items'][] = $d; continue; } - $cid = (int)($d['class_section_id'] ?? 0); - if (!isset($legacyByClass[$cid])) { - $legacyByClass[$cid] = [ - 'class_section_id' => $cid, - 'class_section_name' => $d['class_section_name'] ?? 'Class ' . $cid, - 'items' => [], - ]; - } - $legacyByClass[$cid]['items'][] = $d; + $drafts[] = $d; } } else { // Column missing: keep behavior simple and avoid legacy tab $drafts = $allDrafts; } + $legacyFlat = []; + foreach ($legacyByClass as $group) { + foreach ($group['items'] ?? [] as $item) { + $legacyFlat[] = $item; + } + } + $printablePool = array_merge($drafts, $legacyFlat); + $printableIds = $this->printableDraftIds($printablePool); + $drafts = $this->attachPrintableFlags($drafts, $printableIds); + foreach ($legacyByClass as &$group) { + $group['items'] = $this->attachPrintableFlags($group['items'] ?? [], $printableIds); + } + unset($group); + + $classSectionsById = []; + foreach ($classSections as $cs) { + $csId = (int) ($cs['class_section_id'] ?? 0); + if ($csId <= 0) { + continue; + } + $classSectionsById[$csId] = $cs; + } + + $studentCounts = $this->studentClassModel->getStudentCountsBySection($this->schoolYear); + $visibleClasses = []; + foreach ($studentCounts as $csId => $count) { + $csId = (int) $csId; + if ($csId <= 0 || $count <= 0) { + continue; + } + $classData = $classSectionsById[$csId] ?? $this->classSectionModel->where('class_section_id', $csId)->first(); + if ($classData === null) { + $classData = [ + 'class_section_id' => $csId, + 'class_section_name' => 'Class ' . $csId, + ]; + } + $classData['student_count'] = $count; + $visibleClasses[$csId] = $classData; + } + uasort($visibleClasses, static fn ($a, $b): int => strcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? '')); + + $classDraftGroups = []; + foreach ($drafts as $draft) { + $cid = (int) ($draft['class_section_id'] ?? 0); + if ($cid <= 0) { + continue; + } + $classDraftGroups[$cid][] = $draft; + } + $newSubmissionClasses = []; + foreach ($classDraftGroups as $cid => $group) { + foreach ($group as $draft) { + if (strtolower((string) ($draft['status'] ?? '')) === 'submitted') { + $newSubmissionClasses[$cid] = true; + break; + } + } + } + return view('administrator/exam_drafts', [ 'drafts' => $drafts, 'statusBadges' => $this->statusBadgeMap(), - 'statusOptions' => $this->statusOptions(), 'schoolYear' => $this->schoolYear, 'semester' => $this->semester, 'allowedExtensions' => self::ADMIN_ALLOWED_EXTENSIONS, @@ -274,23 +493,44 @@ class ExamDraftController extends BaseController 'examTypes' => $this->examTypes, 'classSections' => $classSections, 'legacyByClass' => $legacyByClass, + 'printableDraftIds' => $printableIds, + 'visibleClasses' => $visibleClasses, + 'classDraftGroups' => $classDraftGroups, + 'newSubmissionClasses' => $newSubmissionClasses, + 'reviewActionUrl' => base_url($this->reviewRoutePrefix() . '/exam-drafts/review'), + 'legacyUploadUrl' => base_url($this->reviewRoutePrefix() . '/exam-drafts/upload-legacy'), + 'reviewPortalLabel' => $this->reviewPortalLabel(), ]); } public function adminUploadLegacy() + { + return $this->reviewUploadLegacy(); + } + + public function principalUploadLegacy() + { + return $this->reviewUploadLegacy(); + } + + public function reviewUploadLegacy() { $adminId = (int) (session()->get('user_id') ?? 0); if ($adminId <= 0) { return redirect()->to('/login'); } - $classSectionId = (int) ($this->request->getPost('class_section_id') ?? 0); + $classSectionIds = $this->request->getPost('class_section_ids'); + if (!is_array($classSectionIds)) { + $classSectionIds = [$classSectionIds]; + } + $classSectionIds = array_values(array_filter(array_map('intval', $classSectionIds))); $schoolYear = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear)); $semester = trim((string) ($this->request->getPost('semester') ?? $this->semester)); - $examType = trim((string) $this->request->getPost('exam_type')); + $examType = $this->normalizeExamType($this->request->getPost('exam_type')); - if ($classSectionId <= 0) { - return redirect()->back()->withInput()->with('error', 'Select a class section.'); + if (empty($classSectionIds)) { + return redirect()->back()->withInput()->with('error', 'Select at least one class section.'); } if ($schoolYear === '') { return redirect()->back()->withInput()->with('error', 'School year is required.'); @@ -298,6 +538,9 @@ class ExamDraftController extends BaseController if ($semester === '') { return redirect()->back()->withInput()->with('error', 'Semester is required.'); } + if ($examType === '') { + return redirect()->back()->withInput()->with('error', 'Exam type is required.'); + } $file = $this->request->getFile('old_exam_file'); if (!$file || !$file->isValid()) { @@ -309,25 +552,6 @@ class ExamDraftController extends BaseController return redirect()->back()->withInput()->with('error', 'File type not allowed or upload failed.'); } - $payload = [ - 'teacher_id' => $adminId, // store under admin user since legacy uploads are admin-only - 'class_section_id' => $classSectionId, - 'semester' => ucfirst(strtolower($semester)), - 'school_year' => $schoolYear, - 'exam_type' => $examType === '' ? null : $examType, - 'draft_title' => $examType === '' ? 'Legacy Exam' : $examType, - 'description' => null, - 'final_file' => $stored, - 'final_filename' => $file->getClientName(), - 'status' => 'finalized', - 'admin_id' => $adminId, - 'reviewed_at' => utc_now(), - 'version' => 1, - ]; - if ($this->hasIsLegacyColumn) { - $payload['is_legacy'] = 1; - } - $pdfName = null; if (strtolower($file->getClientExtension()) === 'pdf') { $pdfName = $stored; @@ -337,30 +561,68 @@ class ExamDraftController extends BaseController self::FINAL_UPLOAD_DIR ); } + $basePayload = [ + $this->authorIdColumn => $adminId, // store under admin user since legacy uploads are admin-only + 'semester' => ucfirst(strtolower($semester)), + 'school_year' => $schoolYear, + 'exam_type' => $examType, + 'draft_title' => $examType, + 'author_comment' => null, + 'final_file' => $stored, + 'final_filename' => $file->getClientName(), + 'status' => 'legacy', + 'reviewed_at' => utc_now(), + 'version' => 1, + ]; + if ($this->reviewerIdColumn !== '') { + $basePayload[$this->reviewerIdColumn] = $adminId; + } + if ($this->hasIsLegacyColumn) { + $basePayload['is_legacy'] = 1; + } if ($pdfName !== null && $this->hasFinalPdfColumn) { - $payload['final_pdf_file'] = $pdfName; + $basePayload['final_pdf_file'] = $pdfName; } - if ($this->examDraftModel->insert($payload)) { - return redirect()->to('/administrator/exam-drafts')->with('success', 'Old exam uploaded successfully.'); + $saved = 0; + foreach ($classSectionIds as $classSectionId) { + $payload = $basePayload; + $payload['class_section_id'] = $classSectionId; + if ($this->examDraftModel->insert($payload)) { + $saved++; + } + } + + if ($saved > 0) { + return redirect()->to($this->reviewDashboardPath())->with('success', 'Old exam uploaded successfully.'); } return redirect()->back()->withInput()->with('error', 'Unable to save the old exam.'); } public function adminReview() + { + return $this->reviewSubmission(); + } + + public function principalReview() + { + return $this->reviewSubmission(); + } + + public function reviewSubmission() { $draftId = (int) ($this->request->getPost('draft_id') ?? 0); if ($draftId <= 0) { return redirect()->back()->with('error', 'Invalid submission selected.'); } - $draft = $this->examDraftModel->find($draftId); + $draft = $this->findDraft($draftId); if (empty($draft)) { return redirect()->back()->with('error', 'Submission not found.'); } - $comments = trim((string) $this->request->getPost('admin_comments')); + $reviewerComment = trim((string) ($this->request->getPost('reviewer_comment') ?? $this->request->getPost('admin_comments'))); $statusInput = trim((string) $this->request->getPost('review_status')); $currentStatus = (string) ($draft['status'] ?? 'draft'); $status = $this->normalizeStatus( @@ -369,23 +631,28 @@ class ExamDraftController extends BaseController ); $status = strtolower($status); if (!in_array($status, $this->statusOptions(), true)) { - $status = 'reviewed'; + $status = 'under review'; } + + $acceptanceType = null; + if ($status === 'accepted') { + $rawAccept = strtolower(trim((string) $this->request->getPost('acceptance_type'))); + $acceptanceType = in_array($rawAccept, ['as_is', 'minor_edits'], true) ? $rawAccept : 'as_is'; + } + $finalFile = null; $finalFilename = null; $file = $this->request->getFile('final_file'); if ($file && $file->isValid() && !$file->hasMoved()) { - $stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR); + $stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR, self::ADMIN_ALLOWED_EXTENSIONS); if ($stored === null) { return redirect()->back()->with('error', 'Unable to store the final draft.')->withInput(); } $finalFile = $stored; $finalFilename = $file->getClientName(); - // Only auto-finalize if the admin explicitly chose "finalized" - // (previously any uploaded file forced finalization) - if ($status === 'finalized') { - $status = 'finalized'; + if ($status === 'accepted') { + $status = 'accepted'; } } elseif ($file && $file->getError() !== UPLOAD_ERR_NO_FILE) { return redirect()->back()->with('error', 'Final file upload failed.'); @@ -393,36 +660,91 @@ class ExamDraftController extends BaseController $update = [ 'status' => $status, - 'admin_comments' => $comments === '' ? null : $comments, - 'admin_id' => (int) (session()->get('user_id') ?? 0), 'reviewed_at' => utc_now(), ]; + $existingComment = (string) ($draft['reviewer_comment'] ?? $draft['reviewer_comments'] ?? $draft['admin_comments'] ?? ''); + $commentValue = $reviewerComment === '' ? null : $this->appendReviewerComment($existingComment, $reviewerComment); + if ($this->hasReviewerCommentColumn) { + $update['reviewer_comment'] = $commentValue; + } + if ($this->hasReviewerCommentsColumn) { + $update['reviewer_comments'] = $commentValue; + } + if ($this->hasAdminCommentsColumn) { + $update['admin_comments'] = $commentValue; + } + if ($this->reviewerIdColumn !== '') { + $update[$this->reviewerIdColumn] = (int) (session()->get('user_id') ?? 0); + } + + if ($this->hasAcceptanceTypeColumn) { + $update['acceptance_type'] = $status === 'accepted' ? $acceptanceType : null; + } if ($finalFile !== null) { - $update['final_file'] = $finalFile; - $update['final_filename'] = $finalFilename; + $baseVersion = (int) ($draft['version'] ?? 1); + if ($baseVersion <= 0) { + $baseVersion = 1; + } + $reviewRevision = $this->nextReviewRevision($draft, $baseVersion); + $reviewExamType = $this->normalizeExamType($draft['exam_type'] ?? $draft['draft_title'] ?? ''); + if ($reviewExamType === '') { + return redirect()->back()->with('error', 'This draft is missing an exam type. Update the exam type before reviewing.'); + } + + $newRow = [ + $this->authorIdColumn => $this->draftTeacherId($draft), + 'class_section_id' => (int) ($draft['class_section_id'] ?? 0), + 'semester' => (string) ($draft['semester'] ?? ''), + 'school_year' => (string) ($draft['school_year'] ?? ''), + 'exam_type' => $reviewExamType, + 'draft_title' => $draft['draft_title'] ?? $reviewExamType, + 'author_comment' => $draft['author_comment'] ?? null, + 'status' => $status, + 'reviewed_at' => $update['reviewed_at'], + 'version' => $baseVersion, + 'previous_draft_id' => (int) ($draft['id'] ?? 0) ?: null, + ]; + if ($this->hasReviewRevisionColumn) { + $newRow['review_revision'] = $reviewRevision; + } + if ($this->hasReviewerCommentColumn) { + $newRow['reviewer_comment'] = $commentValue; + } + if ($this->hasReviewerCommentsColumn) { + $newRow['reviewer_comments'] = $commentValue; + } + if ($this->hasAdminCommentsColumn) { + $newRow['admin_comments'] = $commentValue; + } + if ($this->reviewerIdColumn !== '') { + $newRow[$this->reviewerIdColumn] = $update[$this->reviewerIdColumn] ?? null; + } + if ($this->hasAcceptanceTypeColumn) { + $newRow['acceptance_type'] = $status === 'accepted' ? $acceptanceType : null; + } + if ($this->hasIsLegacyColumn && !empty($draft['is_legacy'])) { + $newRow['is_legacy'] = 1; + } + $newRow['final_file'] = $finalFile; + $newRow['final_filename'] = $finalFilename; $pdfName = $this->ensurePdfExists($finalFile, $file ? $file->getClientExtension() : null); if ($pdfName !== null && $this->hasFinalPdfColumn) { - $update['final_pdf_file'] = $pdfName; + $newRow['final_pdf_file'] = $pdfName; } - } elseif (strtolower($status) === 'finalized' && !empty($draft['teacher_file'])) { - // Auto-promote teacher file when admin finalizes without uploading a final - $copied = $this->copyDraftToFinal($draft['teacher_file']); - if ($copied !== null) { - $update['final_file'] = $copied; - $update['final_filename'] = $draft['teacher_filename'] ?? $draft['teacher_file']; - $pdfName = $this->ensurePdfExists($copied, pathinfo($copied, PATHINFO_EXTENSION)); - if ($pdfName !== null && $this->hasFinalPdfColumn) { - $update['final_pdf_file'] = $pdfName; - } + if ($this->examDraftModel->insert($newRow)) { + $newId = (int) $this->examDraftModel->getInsertID(); + $after = $newId > 0 ? $this->examDraftModel->find($newId) : null; + $this->notifyExamDraftEvent($after, 'review_' . $status); + return redirect()->back()->with('success', 'Review saved successfully.'); } + return redirect()->back()->with('error', 'Unable to save the review.'); } - if (strtolower($status) === 'finalized' && $this->hasIsLegacyColumn) { - $update['is_legacy'] = 1; // finalized exams move to Previous Exams + if ($status === 'legacy') { + $this->prepareLegacyPdfVersion($update, $draft); } - if ($this->hasIsLegacyColumn) { - // Keep existing legacy flag, do not set legacy automatically here + $update['is_legacy'] = strtolower($status) === 'legacy' ? 1 : 0; } $updated = $this->examDraftModel @@ -431,12 +753,83 @@ class ExamDraftController extends BaseController ->update(); if ($updated) { + $after = $this->examDraftModel->find($draftId); + $this->notifyExamDraftEvent($after, 'review_' . $status); return redirect()->back()->with('success', 'Review saved successfully.'); } return redirect()->back()->with('error', 'Unable to save the review.'); } + public function teacherPrint(int $id) + { + $teacherId = (int) (session()->get('user_id') ?? 0); + if ($teacherId <= 0) { + return redirect()->to('/login'); + } + $draft = $this->findDraft($id); + if (empty($draft) || $this->draftTeacherId($draft) !== $teacherId) { + return redirect()->to('/teacher/exam-drafts')->with('error', 'Submission not found.'); + } + $all = $this->examDraftModel + ->select($this->draftSelectColumns()) + ->where($this->authorIdColumn, $teacherId) + ->findAll(); + $printable = $this->printableDraftIds($all); + if (!in_array($id, $printable, true)) { + return redirect()->to('/teacher/exam-drafts')->with('error', 'Only the latest accepted revision can be printed.'); + } + + return $this->streamFinalPdf($draft); + } + + public function teacherStatusFeed() + { + $teacherId = (int) (session()->get('user_id') ?? 0); + if ($teacherId <= 0) { + return $this->response->setStatusCode(401); + } + + $assignments = $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $this->schoolYear); + $classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments); + + $drafts = $this->visibleTeacherDraftsQuery($teacherId, $classSectionIds) + ->select('exam_drafts.id, exam_drafts.status, exam_drafts.acceptance_type, exam_drafts.updated_at, exam_drafts.reviewed_at') + ->orderBy('updated_at', 'DESC') + ->findAll(); + + $payload = [ + 'drafts' => array_map(static fn(array $row): array => [ + 'id' => (int) ($row['id'] ?? 0), + 'status' => strtolower((string) ($row['status'] ?? '')), + 'acceptance_type' => (string) ($row['acceptance_type'] ?? ''), + 'updated_at' => (string) ($row['updated_at'] ?? ''), + 'reviewed_at' => (string) ($row['reviewed_at'] ?? ''), + ], $drafts), + ]; + + return $this->response->setJSON($payload); + } + + public function adminPrint(int $id) + { + $adminId = (int) (session()->get('user_id') ?? 0); + if ($adminId <= 0) { + return redirect()->to('/login'); + } + $draft = $this->findDraft($id); + if (empty($draft)) { + return redirect()->to('/administrator/exam-drafts')->with('error', 'Submission not found.'); + } + $all = $this->examDraftModel->select($this->draftSelectColumns())->findAll(); + $printable = $this->printableDraftIds($all); + if (!in_array($id, $printable, true)) { + return redirect()->to('/administrator/exam-drafts')->with('error', 'Only the latest accepted revision can be printed.'); + } + + return $this->streamFinalPdf($draft); + } + private function resolveSelectedClassSection(array $assignments): int { $candidate = (int) ($this->request->getGet('class_section_id') ?? session()->get('class_section_id') ?? 0); @@ -447,44 +840,345 @@ class ExamDraftController extends BaseController return $validIds[0] ?? 0; } + private function visibleTeacherDraftsQuery(int $teacherId, array $classSectionIds) + { + $query = $this->examDraftModel + ->select($this->draftSelectColumns()) + ->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last') + ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') + ->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left'); + + if (empty($classSectionIds)) { + return $query->where('exam_drafts.' . $this->authorIdColumn, $teacherId); + } + + $query->groupStart() + ->where('exam_drafts.' . $this->authorIdColumn, $teacherId) + ->orGroupStart() + ->whereIn('exam_drafts.class_section_id', $classSectionIds) + ->where('exam_drafts.' . $this->authorIdColumn . ' !=', $teacherId) + ->where('exam_drafts.status !=', 'draft'); + + if ($this->schoolYear !== '') { + $query->where('exam_drafts.school_year', $this->schoolYear); + } + if ($this->semester !== '') { + $query->where('exam_drafts.semester', $this->semester); + } + + $query->groupEnd() + ->groupEnd(); + + return $query; + } + + private function attachTeacherDraftContext(array $row, int $viewerId): array + { + $isOwn = $this->draftTeacherId($row) === $viewerId; + $row['is_own_submission'] = $isOwn; + $row['teacher_display_name'] = $isOwn ? 'You' : $this->draftTeacherName($row); + + return $row; + } + + private function draftTeacherName(array $draft): string + { + $name = trim(((string) ($draft['teacher_first'] ?? '')) . ' ' . ((string) ($draft['teacher_last'] ?? ''))); + if ($name !== '') { + return $name; + } + + $teacherId = $this->draftTeacherId($draft); + return $teacherId > 0 ? 'Teacher #' . $teacherId : 'Teacher'; + } + private function statusBadgeMap(): array { return [ - 'draft' => [ - 'label' => 'Draft', - 'class' => 'bg-secondary text-white', - ], 'submitted' => [ 'label' => 'Submitted', - 'class' => 'bg-warning text-dark', - ], - 'reviewed' => [ - 'label' => 'Reviewed', 'class' => 'bg-info text-dark', ], - 'finalized' => [ - 'label' => 'Finalized', + 'under review' => [ + 'label' => 'Under review', + 'class' => 'bg-warning text-dark', + ], + 'review needed' => [ + 'label' => 'Review needed', + 'class' => 'text-dark', + 'style' => 'background-color:#fd7e14;', + ], + 'accepted' => [ + 'label' => 'Accepted', 'class' => 'bg-success text-white', ], + 'legacy' => [ + 'label' => 'Legacy', + 'class' => 'bg-secondary text-white', + ], 'rejected' => [ 'label' => 'Rejected', 'class' => 'bg-danger text-white', ], + 'canceled' => [ + 'label' => 'Canceled', + 'class' => 'bg-danger text-white', + ], ]; } + private function draftSelectColumns(): string + { + $columns = ['exam_drafts.*']; + if ($this->authorIdColumn !== 'teacher_id') { + $columns[] = 'exam_drafts.' . $this->authorIdColumn . ' AS teacher_id'; + } + if ($this->authorFileColumn !== 'teacher_file') { + $columns[] = 'exam_drafts.' . $this->authorFileColumn . ' AS teacher_file'; + } + if ($this->authorFilenameColumn !== 'teacher_filename') { + $columns[] = 'exam_drafts.' . $this->authorFilenameColumn . ' AS teacher_filename'; + } + return implode(', ', $columns); + } + + private function applyAuthorFilePayload(array &$payload, ?string $file, ?string $filename): void + { + if ($file === null) { + return; + } + $payload[$this->authorFileColumn] = $file; + $payload[$this->authorFilenameColumn] = $filename; + } + + private function draftTeacherId(array $draft): int + { + if (array_key_exists('teacher_id', $draft)) { + return (int) $draft['teacher_id']; + } + return (int) ($draft[$this->authorIdColumn] ?? 0); + } + + private function draftTeacherFile(array $draft): ?string + { + if (array_key_exists('teacher_file', $draft)) { + return $draft['teacher_file'] ?? null; + } + return $draft[$this->authorFileColumn] ?? null; + } + + private function draftTeacherFilename(array $draft): ?string + { + if (array_key_exists('teacher_filename', $draft)) { + return $draft['teacher_filename'] ?? null; + } + return $draft[$this->authorFilenameColumn] ?? null; + } + + private function findDraft(int $id): ?array + { + return $this->examDraftModel + ->select($this->draftSelectColumns()) + ->where('id', $id) + ->first(); + } + + private function ensureDraftStatus(int $id, string $status): ?array + { + $saved = $this->examDraftModel->find($id); + if (empty($saved)) { + return null; + } + $current = strtolower((string) ($saved['status'] ?? '')); + if ($current !== $status) { + $this->examDraftModel->update($id, ['status' => $status]); + $saved = $this->examDraftModel->find($id); + } + return $saved; + } + + private function notifyPrincipalExamDraftSubmitted(?array $draft, int $teacherId, int $classSectionId): void + { + $principalEmail = trim((string) (env('PRINCIPAL_EMAIL') ?: '')); + if ($principalEmail === '' || !filter_var($principalEmail, FILTER_VALIDATE_EMAIL)) { + return; + } + if (empty($draft)) { + return; + } + + try { + $teacher = $this->userModel->find($teacherId) ?? []; + $teacherName = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')); + if ($teacherName === '') { + $teacherName = 'Teacher #' . $teacherId; + } + + $class = $this->classSectionModel->where('class_section_id', (int) $classSectionId)->first() ?? []; + $className = $class['class_section_name'] ?? ('Class ' . $classSectionId); + + $subject = 'Exam draft submitted: ' . $className; + $submittedAt = $draft['created_at'] ?? $draft['updated_at'] ?? ''; + $body = 'A teacher has submitted a new exam draft.
' + . '| Teacher | ' . esc($teacherName) . ' |
| Class | ' . esc($className) . ' |
| Exam type | ' . esc($draft['exam_type'] ?? 'N/A') . ' |
| Version | ' . esc((string) ($draft['version'] ?? '')) . ' |
| Status | ' . esc((string) ($draft['status'] ?? 'submitted')) . ' |
| Submitted at | ' . esc((string) $submittedAt) . ' |
Review drafts: ' . esc($this->reviewPortalLabel()) . ' exam drafts
'; + + $mailer = \Config\Services::emailService(); + $mailer->send($principalEmail, $subject, $body, 'notifications'); + } catch (\Throwable $e) { + log_message('error', 'Failed to send exam draft notification: ' . $e->getMessage()); + } + } + + private function reviewDashboardPath(): string + { + return '/' . $this->reviewRoutePrefix() . '/exam-drafts'; + } + + private function reviewRoutePrefix(): string + { + return $this->isPrincipalReviewer() ? 'principal' : 'administrator'; + } + + private function reviewPortalLabel(): string + { + return $this->isPrincipalReviewer() ? 'Principal' : 'Administrator'; + } + + private function isPrincipalReviewer(): bool + { + $roles = session()->get('roles'); + if (!is_array($roles)) { + $roles = $roles === null ? [] : [$roles]; + } + + foreach ($roles as $role) { + $normalized = strtolower(trim(str_replace([' ', '-'], '_', (string) $role))); + if ($normalized === 'principal') { + return true; + } + } + + return false; + } + + private function nextDraftVersion(array $draft): int + { + $query = $this->examDraftModel + ->select('version') + ->where($this->authorIdColumn, $this->draftTeacherId($draft)) + ->where('class_section_id', (int) ($draft['class_section_id'] ?? 0)) + ->where('semester', (string) ($draft['semester'] ?? '')) + ->where('school_year', (string) ($draft['school_year'] ?? '')); + + $examType = $draft['exam_type'] ?? null; + if ($examType === null || $examType === '') { + $query = $query->where('exam_type', null); + } else { + $query = $query->where('exam_type', $examType); + } + + $latest = $query->orderBy('version', 'DESC')->first(); + $current = (int) ($latest['version'] ?? 0); + if ($current <= 0) { + $current = (int) ($draft['version'] ?? 0); + } + if ($current <= 0) { + $current = 1; + } + return $current + 1; + } + + private function nextReviewRevision(array $draft, int $baseVersion): int + { + if (!$this->hasReviewRevisionColumn) { + return 1; + } + + $query = $this->examDraftModel + ->select('review_revision') + ->where($this->authorIdColumn, $this->draftTeacherId($draft)) + ->where('class_section_id', (int) ($draft['class_section_id'] ?? 0)) + ->where('semester', (string) ($draft['semester'] ?? '')) + ->where('school_year', (string) ($draft['school_year'] ?? '')) + ->where('version', $baseVersion); + + $examType = $draft['exam_type'] ?? null; + if ($examType === null || $examType === '') { + $query = $query->where('exam_type', null); + } else { + $query = $query->where('exam_type', $examType); + } + + $latest = $query->orderBy('review_revision', 'DESC')->first(); + $current = (int) ($latest['review_revision'] ?? 0); + if ($current <= 0) { + $current = (int) ($draft['review_revision'] ?? 0); + } + return $current + 1; + } + + private function appendReviewerComment(string $existing, string $incoming): string + { + $existing = trim($existing); + $incoming = trim($incoming); + if ($incoming === '') { + return $existing; + } + + if ($existing !== '' && str_starts_with($incoming, $existing)) { + $remainder = trim(substr($incoming, strlen($existing))); + if ($remainder === '') { + return $existing; + } + $incoming = $remainder; + } + + $lines = $existing === '' ? [] : preg_split('/\R/', $existing); + $lastLine = $lines ? trim((string) end($lines)) : ''; + if ($lastLine !== '') { + $lastBody = preg_replace('/^\d+\-\s*/', '', $lastLine); + if ($lastBody !== null && trim($lastBody) === $incoming) { + return $existing; + } + } + $nextIndex = count($lines) + 1; + $incoming = trim(preg_replace('/\R+/', ' ', $incoming) ?? $incoming); + $lines[] = $nextIndex . '- ' . $incoming; + return implode("\n", $lines); + } + private function statusOptions(): array { - return ['draft', 'submitted', 'reviewed', 'finalized', 'rejected']; + return ['submitted', 'accepted', 'review needed', 'rejected', 'canceled', 'under review', 'legacy']; } private function normalizeStatus(string $input, string $default): string { $input = strtolower(trim($input)); $aliases = [ - 'pending' => 'submitted', // legacy value - 'final' => 'finalized', - 'approved' => 'finalized', // legacy value + 'submitted' => 'submitted', + 'under review' => 'under review', + 'under_review' => 'under review', + 'reviewed' => 'review needed', + 'review needed' => 'review needed', + 'review_needed' => 'review needed', + 'accepted' => 'accepted', + 'approved' => 'accepted', + 'final' => 'accepted', + 'legacy' => 'legacy', + 'archived' => 'legacy', + 'cancelled' => 'canceled', + 'cancel' => 'canceled', + 'rejected' => 'rejected', + 'draft' => 'submitted', ]; if (isset($aliases[$input])) { $input = $aliases[$input]; @@ -492,6 +1186,202 @@ class ExamDraftController extends BaseController return in_array($input, $this->statusOptions(), true) ? $input : $default; } + /** + * One printable row per exam line: max(version) among accepted rows. + * + * @param list' . esc($eventConfig['intro']) . '
' + . 'From: ' . esc($teacherName) . '
' + . ($className !== '' ? 'Class: ' . esc($className) . '
' : '') + . (!empty($requestData['num_copies']) ? 'Copies: ' . (int) $requestData['num_copies'] . '
' : '') + . (!empty($requestData['required_by']) ? 'Needed by: ' . esc($requestData['required_by']) . '
' : '') + . (!empty($requestData['page_selection']) ? 'Pages: ' . esc($requestData['page_selection']) . '
' : '') + . (!empty($requestData['pickup_method']) ? 'Pickup Method: ' . esc($requestData['pickup_method']) . '
' : '') + . ($isCopyRequest ? '' : ''); + + foreach ($adminRows as $adminRow) { + $email = trim((string) ($adminRow['email'] ?? '')); + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + continue; + } + $mailer->sendEmail($email, $subject, $body, 'notifications'); + } + } +} diff --git a/app/Controllers/View/PrintablesBaseController.php b/app/Controllers/View/PrintablesBaseController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ProjectController.php b/app/Controllers/View/ProjectController.php old mode 100644 new mode 100755 index 21eea66..69c2901 --- a/app/Controllers/View/ProjectController.php +++ b/app/Controllers/View/ProjectController.php @@ -438,6 +438,7 @@ class ProjectController extends Controller // Step 1: Get student IDs from student_class table $studentClassRows = $studentClassModel ->select('student_id') + ->distinct() ->where('class_section_id', $classSectionId) ->where('school_year', $schoolYear) ->findAll(); @@ -494,10 +495,7 @@ class ProjectController extends Controller $studentModel = new StudentModel(); $projectModel = new ProjectModel(); - $studentClasses = $studentClassModel - ->active() - ->where('student_class.class_section_id', $classSectionId) - ->findAll(); + $studentClasses = $studentClassModel->getClassStudents($classSectionId, $this->schoolYear, null); $students = []; foreach ($studentClasses as $sc) { diff --git a/app/Controllers/View/PurchaseOrderController.php b/app/Controllers/View/PurchaseOrderController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/QuizController.php b/app/Controllers/View/QuizController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/RFIDController.php b/app/Controllers/View/RFIDController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/RefundController.php b/app/Controllers/View/RefundController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/RegisterController.php b/app/Controllers/View/RegisterController.php old mode 100644 new mode 100755 index fe22344..4970690 --- a/app/Controllers/View/RegisterController.php +++ b/app/Controllers/View/RegisterController.php @@ -173,16 +173,8 @@ class RegisterController extends Controller $existingUser = $this->userModel->where('email', $post['email'])->first(); if ($existingUser) { - // Step 2: Check if the user has a token (i.e., not verified yet) - if (!empty($existingUser['token']) && $existingUser['is_verified'] == 0) { - // User exists and is unverified - return redirect()->back()->withInput()->with('error', - 'This email address is already registered and is pending activation. Please check your email to activate your account.'); - } else { - // User exists and is already active or has no token - return redirect()->back()->withInput()->with('error', - 'The email address you entered is already in use. Please try a different one.'); - } + return redirect()->back()->withInput()->with('error', + 'This email address is already registered. Please check your email or log in.'); } /* ───────────── 6. Determine role ───────────── */ @@ -194,6 +186,7 @@ class RegisterController extends Controller /* ───────────── 7. Build & insert user ───────────── */ $token = bin2hex(random_bytes(48)); + $tokenHash = hash('sha256', $token); $userData = [ 'firstname' => $post['firstname'], 'lastname' => $post['lastname'], @@ -205,7 +198,7 @@ class RegisterController extends Controller 'city' => $post['city'], 'state' => $post['state'], 'zip' => $post['zip'], - 'token' => $token, + 'token' => $tokenHash, 'is_verified'=> 0, 'accept_school_policy' => (int) $post['accept_school_policy'], 'status' => 'Inactive', @@ -357,4 +350,4 @@ class RegisterController extends Controller -} \ No newline at end of file +} diff --git a/app/Controllers/View/ReimbursementController.php b/app/Controllers/View/ReimbursementController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ReportCardsController.php b/app/Controllers/View/ReportCardsController.php old mode 100644 new mode 100755 index 74b6e1b..7b52691 --- a/app/Controllers/View/ReportCardsController.php +++ b/app/Controllers/View/ReportCardsController.php @@ -4,18 +4,21 @@ namespace App\Controllers\View; use App\Models\CalendarModel; use App\Models\AttendanceDataModel; +use App\Models\ReportCardAcknowledgementModel; use App\Services\SemesterRangeService; class ReportCardsController extends PrintablesBaseController { protected $calendarModel; protected $semesterRangeService; + protected $ackModel; public function __construct() { parent::__construct(); $this->calendarModel = new CalendarModel(); $this->semesterRangeService = new SemesterRangeService($this->configModel); + $this->ackModel = new ReportCardAcknowledgementModel(); } public function report_card() @@ -369,6 +372,24 @@ class ReportCardsController extends PrintablesBaseController $isNumeric = static fn($v) => $v !== null && $v !== '' && is_numeric($v); + $ackMap = []; + try { + $ackRows = $this->ackModel + ->whereIn('student_id', $studentIds) + ->where('school_year', $year) + ->where('semester', $sem) + ->orderBy('updated_at', 'DESC') + ->findAll(); + foreach ($ackRows as $row) { + $sid = (int) ($row['student_id'] ?? 0); + if ($sid > 0 && ! isset($ackMap[$sid])) { + $ackMap[$sid] = $row; + } + } + } catch (\Throwable $e) { + $ackMap = []; + } + $results = []; $completeCount = 0; $warningCount = 0; @@ -471,12 +492,16 @@ class ReportCardsController extends PrintablesBaseController $warningCount++; } + $ack = $sid > 0 ? ($ackMap[$sid] ?? null) : null; $results[] = [ 'id' => $sid, 'name' => $name !== '' ? $name : 'N/A', 'missing' => $missing, 'warnings' => $warnings, 'complete' => $isComplete, + 'viewed_at' => $ack['viewed_at'] ?? null, + 'signed_at' => $ack['signed_at'] ?? null, + 'signed_name' => $ack['signed_name'] ?? null, ]; } @@ -501,6 +526,37 @@ class ReportCardsController extends PrintablesBaseController ]); } + /** + * API: report card parent acknowledgement status + * GET params: student_id, school_year, semester + */ + public function reportCardAcknowledgement() + { + $studentId = (int) ($this->request->getGet('student_id') ?? 0); + if ($studentId <= 0) { + return $this->response->setJSON(['ok' => false, 'error' => 'Missing student_id'])->setStatusCode(400); + } + $schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? '')); + $semester = trim((string) ($this->request->getGet('semester') ?? $this->semester ?? '')); + + $row = $this->ackModel + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->orderBy('updated_at', 'DESC') + ->first(); + + return $this->response->setJSON([ + 'ok' => true, + 'student_id' => $studentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'viewed_at' => $row['viewed_at'] ?? null, + 'signed_at' => $row['signed_at'] ?? null, + 'signed_name' => $row['signed_name'] ?? null, + ]); + } + protected function fetchStudentsByClass(int $sectionId, ?string $schoolYear) { $b = $this->studentClassModel diff --git a/app/Controllers/View/RolePermissionController.php b/app/Controllers/View/RolePermissionController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/RoleSwitcherController.php b/app/Controllers/View/RoleSwitcherController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SchoolCalendarController.php b/app/Controllers/View/SchoolCalendarController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ScoreCommentController.php b/app/Controllers/View/ScoreCommentController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ScoreController.php b/app/Controllers/View/ScoreController.php old mode 100644 new mode 100755 index 4e05fba..13950e2 --- a/app/Controllers/View/ScoreController.php +++ b/app/Controllers/View/ScoreController.php @@ -1287,14 +1287,14 @@ class ScoreController extends Controller public function viewStudentScore() { $parentId = session()->get('user_id'); - $userType = $_SESSION['user_type']; + $userType = session()->get('user_type') ?? ''; $firstParentId = null; $releaseFall = $this->getParentScoresReleasedForSemester('Fall'); $releaseSpring = $this->getParentScoresReleasedForSemester('Spring'); $releaseAny = $releaseFall || $releaseSpring; // Identify the firstparent based on user type - if ($userType === 'primary') { + if ($userType === 'primary' || $userType === '') { $firstParentId = $parentId; } elseif ($userType === 'secondary') { $parentData = $this->db->table('parents') @@ -1378,6 +1378,7 @@ $students = $this->db->table('students') $releaseScores = (strcasecmp($semester, 'Fall') === 0) ? $releaseFall : ((strcasecmp($semester, 'Spring') === 0) ? $releaseSpring : $releaseAny); $scores[$selectedYear][$semester][$studentId] = [ + 'student_id' => $studentId, 'school_id' => $student['school_id'], 'student_firstname' => $student['firstname'], 'student_lastname' => $student['lastname'], diff --git a/app/Controllers/View/ScorePredictor.php b/app/Controllers/View/ScorePredictor.php old mode 100644 new mode 100755 index a17d1ef..80d54f5 --- a/app/Controllers/View/ScorePredictor.php +++ b/app/Controllers/View/ScorePredictor.php @@ -81,10 +81,10 @@ class ScorePredictor extends Controller s.school_id, s.firstname, s.lastname, - fall.semester_score as fall_score, - spring.semester_score as spring_score'); - // Also select class section for per-class trophy decision - $builder->select('sc.class_section_id as class_section_id'); + MAX(fall.semester_score) as fall_score, + MAX(spring.semester_score) as spring_score'); + // Reduce duplication from restored students while keeping a stable class section. + $builder->select('MAX(sc.class_section_id) as class_section_id'); $yearEsc = $this->db->escape($selectedYear); $builder->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $yearEsc, 'left'); $builder->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left'); diff --git a/app/Controllers/View/SendSMSController.php b/app/Controllers/View/SendSMSController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SessionTimeoutController.php b/app/Controllers/View/SessionTimeoutController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SettingsController.php b/app/Controllers/View/SettingsController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SlipPrinterController.php b/app/Controllers/View/SlipPrinterController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/StaffController.php b/app/Controllers/View/StaffController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/StatsController.php b/app/Controllers/View/StatsController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/StickersController.php b/app/Controllers/View/StickersController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/StudentController.php b/app/Controllers/View/StudentController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SubjectCurriculumController.php b/app/Controllers/View/SubjectCurriculumController.php old mode 100644 new mode 100755 index adcb695..2273b72 --- a/app/Controllers/View/SubjectCurriculumController.php +++ b/app/Controllers/View/SubjectCurriculumController.php @@ -99,6 +99,7 @@ class SubjectCurriculumController extends BaseController ->orderBy('classes.class_name', 'ASC') ->orderBy('subject', 'ASC') ->orderBy('unit_number', 'ASC') + ->orderBy("CAST(SUBSTRING_INDEX(subject_curriculum_items.chapter_name, '.', 1) AS UNSIGNED)", 'ASC', false) ->orderBy('chapter_name', 'ASC') ->get() ->getResultArray(); diff --git a/app/Controllers/View/SupplierController.php b/app/Controllers/View/SupplierController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SupplyCategoryController.php b/app/Controllers/View/SupplyCategoryController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SupportController.php b/app/Controllers/View/SupportController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/TeacherController.php b/app/Controllers/View/TeacherController.php old mode 100644 new mode 100755 index f4a87dc..4a85a28 --- a/app/Controllers/View/TeacherController.php +++ b/app/Controllers/View/TeacherController.php @@ -297,12 +297,16 @@ class TeacherController extends BaseController $schoolYear = $forYear ?: ((string)($this->schoolYear ?? 'Not Set')); $teachers = $this->teacherModel->getTeachersAndTAs(); - // Prefer class sections for the selected year, fallback to all if none - $classSections = $classSectionModel - ->where('school_year', $schoolYear) - ->orderBy('class_section_name', 'ASC') - ->findAll(); - if (empty($classSections)) { + // Prefer class sections for the selected year if the column exists, otherwise fallback to all. + if ($this->db->fieldExists('school_year', 'classSection')) { + $classSections = $classSectionModel + ->where('school_year', $schoolYear) + ->orderBy('class_section_name', 'ASC') + ->findAll(); + if (empty($classSections)) { + $classSections = $classSectionModel->orderBy('class_section_name', 'ASC')->findAll(); + } + } else { $classSections = $classSectionModel->orderBy('class_section_name', 'ASC')->findAll(); } diff --git a/app/Controllers/View/TestDBController.php b/app/Controllers/View/TestDBController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/UiController.php b/app/Controllers/View/UiController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/UserController.php b/app/Controllers/View/UserController.php old mode 100644 new mode 100755 index f8d1c6f..2f7e64e --- a/app/Controllers/View/UserController.php +++ b/app/Controllers/View/UserController.php @@ -21,6 +21,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php'; class UserController extends BaseController { + private const ACTIVATION_TTL_HOURS = 48; protected $userModel; protected $roleModel; protected $userRoleModel; @@ -49,6 +50,37 @@ class UserController extends BaseController $this->resetRequestModel = new PasswordResetRequestModel(); } + private function denyAccess(string $message) + { + if ($this->request->isAJAX() || $this->request->getHeaderLine('Accept') === 'application/json') { + return service('response') + ->setStatusCode(403) + ->setJSON(['status' => 'error', 'message' => $message]); + } + + session()->setFlashdata('error', $message); + return redirect()->to('/access_denied'); + } + + private function requirePermission(string $permission) + { + if (!session()->get('is_logged_in')) { + return redirect()->to('/login'); + } + + $userId = (int) session()->get('user_id'); + if ($userId <= 0 || !has_permission($userId, $permission)) { + return $this->denyAccess("You don't have permission to use this feature."); + } + + return null; + } + + private function hashToken(string $token): string + { + return hash('sha256', $token); + } + // Method to show the home page public function home() { @@ -75,6 +107,10 @@ class UserController extends BaseController public function userList() { + if ($resp = $this->requirePermission('read_user')) { + return $resp; + } + helper('url'); return view('user/user_list', [ @@ -85,6 +121,10 @@ class UserController extends BaseController // Method to show the list of users public function index() { + if ($resp = $this->requirePermission('read_user')) { + return $resp; + } + // Fetch users along with their assigned roles $builder = $this->db->table('users'); $builder->select('users.id, users.firstname, users.lastname, users.email, user_roles.role_id, roles.name as role, users.status, users.updated_at'); @@ -122,6 +162,10 @@ class UserController extends BaseController public function userListData() { + if ($resp = $this->requirePermission('read_user')) { + return $resp; + } + return $this->response->setJSON([ 'users' => $this->buildUsersWithRoles(), ]); @@ -253,6 +297,10 @@ class UserController extends BaseController // Method to store a new user public function store() { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + // Validate input data $validation = \Config\Services::validation(); $validation->setRules([ @@ -315,6 +363,10 @@ class UserController extends BaseController // Method to show the form for editing an existing user public function edit($id) { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + $data['user'] = $this->userModel->find($id); $data['roles'] = $this->roleModel->findAll(); $userRoles = $this->userRoleModel->where('user_id', $id)->findAll(); @@ -327,6 +379,10 @@ class UserController extends BaseController // Method to delete an existing user public function delete($id) { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + $this->userModel->delete($id); // Delete the user's roles from the user_roles table @@ -369,27 +425,21 @@ class UserController extends BaseController $email = strtolower($this->request->getPost('email')); $user = $this->userModel->where('email', $email)->first(); - // --- Handle unknown email --- - if (!$user) { - session()->setFlashdata('error', 'If this email is registered, you will receive a reset link.'); - log_message('info', "Password reset requested for non-existing user {$email}"); - return redirect()->back(); - } - - // --- Handle unverified accounts --- - if ((int) $user['is_verified'] === 0) { - session()->setFlashdata('error', 'Please check your email and complete the account activation process before resetting your password.'); - log_message('info', "Password reset blocked for unverified user {$email}"); + // --- Handle unknown or unverified email --- + if (!$user || (int) $user['is_verified'] === 0) { + session()->setFlashdata('success', 'If this email is registered, you will receive a reset link.'); + log_message('info', "Password reset requested for {$email} (user missing or unverified)."); return redirect()->back(); } // --- Verified user: continue with reset --- $token = bin2hex(random_bytes(48)); + $tokenHash = $this->hashToken($token); $expires_at = Time::now()->addHours(1); $this->passwordResetModel->insert([ 'email' => $email, - 'token' => $token, + 'token' => $tokenHash, 'created_at' => Time::now(), 'expires_at' => $expires_at, ]); @@ -447,7 +497,12 @@ class UserController extends BaseController } // You may want to validate the token here - $resetEntry = $this->passwordResetModel->where('token', $token) + $tokenHash = $this->hashToken($token); + $resetEntry = $this->passwordResetModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() ->where('expires_at >=', Time::now()) ->first(); @@ -462,6 +517,10 @@ class UserController extends BaseController //This function processes the new password submission, validating the token, updating the user's password, and cleaning up the reset entry. public function processResetPassword() { + if (strtolower($this->request->getMethod()) !== 'post') { + return redirect()->to('/')->with('error', 'Invalid request.'); + } + $token = $this->request->getPost('token'); $newPassword = $this->request->getPost('password'); $passConfirm = $this->request->getPost('pass_confirm'); @@ -490,7 +549,12 @@ class UserController extends BaseController } // Find the password reset entry - $resetEntry = $this->passwordResetModel->where('token', $token) + $tokenHash = $this->hashToken($token); + $resetEntry = $this->passwordResetModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() ->where('expires_at >=', Time::now()) ->first(); @@ -519,7 +583,12 @@ class UserController extends BaseController ]); // Delete the used token from the password reset table - $this->passwordResetModel->where('token', $token)->delete(); + $this->passwordResetModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->delete(); // Retrieve the user's IP address from the request $ipAddress = $this->request->getIPAddress(); @@ -546,10 +615,16 @@ class UserController extends BaseController public function confirm($token) { - log_message('info', 'Processing email confirmation with token: ' . $token); + log_message('info', 'Processing email confirmation.'); - - $user = $this->userModel->where('token', $token)->first(); + $tokenHash = $this->hashToken($token); + $user = $this->userModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString()) + ->first(); if (!$user || $user['is_verified'] == 1) { return redirect()->to('/invalid_token'); @@ -570,7 +645,14 @@ class UserController extends BaseController { //echo "Reached setPassword with token: " . esc($token); //echo "Token received: " . $token; - $user = $this->userModel->where('token', $token)->first(); + $tokenHash = $this->hashToken($token); + $user = $this->userModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString()) + ->first(); if (!$user || $user['is_verified'] == 1) { return redirect()->to('/invalid_token'); @@ -584,6 +666,10 @@ class UserController extends BaseController public function savePassword() { + if (strtolower($this->request->getMethod()) !== 'post') { + return redirect()->to('/')->with('error', 'Invalid request.'); + } + $validation = \Config\Services::validation(); $validation->setRules([ 'password' => [ @@ -615,9 +701,17 @@ class UserController extends BaseController $token = $this->request->getPost('token'); $password = $this->request->getPost('password'); - $user = $this->userModel->where('id', $userId)->where('token', $token)->first(); + $tokenHash = $this->hashToken($token); + $user = $this->userModel + ->where('id', $userId) + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString()) + ->first(); - log_message('debug', "Attempting to set password for user $userId with token $token"); + log_message('debug', "Attempting to set password for user $userId"); if (!$user || $user['is_verified'] == 1) { return redirect()->to('/invalid_token'); @@ -670,20 +764,39 @@ class UserController extends BaseController $roleKey = (string) $this->request->getPost('role'); log_message('info', 'Role selected: ' . $roleKey); - $roleModel = new RoleModel(); - $route = $roleModel->getRouteByNameOrSlug($roleKey); - - if ($route === null) { - log_message('error', 'Invalid or inactive role selected: ' . $roleKey); - return redirect()->back()->with('error', 'Invalid role selected.'); - } - $userId = (int) session()->get('user_id'); log_message('info', 'User ID: ' . $userId); + if ($userId <= 0) { + return $this->denyAccess("You don't have permission to use this feature."); + } + + $roleRow = $this->db->table('user_roles ur') + ->join('roles r', 'r.id = ur.role_id', 'inner') + ->select('r.name, r.slug, r.dashboard_route') + ->where('ur.user_id', $userId) + ->where('r.is_active', 1) + ->groupStart() + ->where('LOWER(r.name)', strtolower($roleKey)) + ->orWhere('LOWER(r.slug)', strtolower($roleKey)) + ->groupEnd() + ->get() + ->getRowArray(); + + if (empty($roleRow)) { + log_message('error', 'Invalid or unassigned role selected: ' . $roleKey); + return redirect()->back()->with('error', 'Invalid role selected.'); + } + + $route = $roleRow['dashboard_route'] ?? null; + if ($route === null) { + log_message('error', 'No dashboard route configured for role: ' . $roleKey); + return redirect()->back()->with('error', 'Invalid role selected.'); + } + // Persist the *exact* role name or slug—choose your convention. - // If you want to store the canonical name, fetch the row and use $row['name']. - $this->userModel->update($userId, ['role' => $roleKey]); + // Store the canonical name to avoid arbitrary role strings. + $this->userModel->update($userId, ['role' => $roleRow['name']]); log_message('info', 'Role updated in database.'); log_message('info', 'Redirecting to role dashboard: ' . $route); @@ -702,6 +815,10 @@ class UserController extends BaseController public function delete_role($roleId) { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + // Fetch the role to be deleted $role = $this->roleModel->find($roleId); if (!$role) { @@ -731,6 +848,10 @@ class UserController extends BaseController public function loginActivity() { + if ($resp = $this->requirePermission('view_login_activity')) { + return $resp; + } + helper('url'); $perPage = (int) ($this->request->getGet('per_page') ?? 25); @@ -743,6 +864,10 @@ class UserController extends BaseController public function loginActivityData() { + if ($resp = $this->requirePermission('view_login_activity')) { + return $resp; + } + $perPage = (int) ($this->request->getGet('per_page') ?? 25); $page = (int) ($this->request->getGet('page') ?? 1); @@ -752,6 +877,10 @@ class UserController extends BaseController // Method to update an existing user public function updateUser() { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + if (strtolower($this->request->getMethod()) !== 'post') { return redirect()->to(site_url('user/user_list'))->with('error', 'Invalid request.'); } @@ -800,9 +929,6 @@ class UserController extends BaseController 'status' => trim((string)$this->request->getPost('status')), 'is_suspended' => $toBool('is_suspended'), 'is_verified' => $toBool('is_verified'), - 'token' => trim((string)$this->request->getPost('token')), - 'updated_at' => $toDT('updated_at'), - 'created_at' => $toDT('created_at'), ]; // Validation diff --git a/app/Controllers/View/WhatsappController.php b/app/Controllers/View/WhatsappController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/WinnersController.php b/app/Controllers/WinnersController.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/.gitkeep b/app/Database/Migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Database/Migrations/2024-05-26-000000_AddEventCategoryToEvents.php b/app/Database/Migrations/2024-05-26-000000_AddEventCategoryToEvents.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2024-11-05-000700_AddBatchNumberToReimbursements.php b/app/Database/Migrations/2024-11-05-000700_AddBatchNumberToReimbursements.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2024-11-07-000800_AddYearlyBatchNumberToReimbursementBatches.php b/app/Database/Migrations/2024-11-07-000800_AddYearlyBatchNumberToReimbursementBatches.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-09-10-000002_CreateInventoryTables.php b/app/Database/Migrations/2025-09-10-000002_CreateInventoryTables.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-09-11-000010_CreateInventoryMovements.php b/app/Database/Migrations/2025-09-11-000010_CreateInventoryMovements.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-09-11-000300_AddClassroomConditionBuckets.php b/app/Database/Migrations/2025-09-11-000300_AddClassroomConditionBuckets.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-09-11-000400_AddGradeRangeToBookCategories.php b/app/Database/Migrations/2025-09-11-000400_AddGradeRangeToBookCategories.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-09-27-000001_CreateStaffAttendance.php b/app/Database/Migrations/2025-09-27-000001_CreateStaffAttendance.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-08-000100_CreateBadgePrintLogs.php b/app/Database/Migrations/2025-10-08-000100_CreateBadgePrintLogs.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-16-000200_CreateFamiliesTables.php b/app/Database/Migrations/2025-10-16-000200_CreateFamiliesTables.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-17-121500_AddNoSchoolToCalendarEvents.php b/app/Database/Migrations/2025-10-17-121500_AddNoSchoolToCalendarEvents.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-17-130000_CreateWhatsappGroupMemberships.php b/app/Database/Migrations/2025-10-17-130000_CreateWhatsappGroupMemberships.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-19-000300_CreateLateSlipLogs.php b/app/Database/Migrations/2025-10-19-000300_CreateLateSlipLogs.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-19-090000_CreatePaymentNotificationLogs.php b/app/Database/Migrations/2025-10-19-090000_CreatePaymentNotificationLogs.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-20-000500_CreateParentAttendanceReports.php b/app/Database/Migrations/2025-10-20-000500_CreateParentAttendanceReports.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-21-000600_AddSemesterToLateSlipLogs.php b/app/Database/Migrations/2025-10-21-000600_AddSemesterToLateSlipLogs.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-21-110000_CreatePromotionQueue.php b/app/Database/Migrations/2025-10-21-110000_CreatePromotionQueue.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-27-000510_CreateEarlyDismissalSignatures.php b/app/Database/Migrations/2025-10-27-000510_CreateEarlyDismissalSignatures.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-11-05-050100_AddTimezoneToPreferencesAndSettings.php b/app/Database/Migrations/2025-11-05-050100_AddTimezoneToPreferencesAndSettings.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-11-23-011228_AddDonationCategoryToExpenses.php b/app/Database/Migrations/2025-11-23-011228_AddDonationCategoryToExpenses.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-12-01-000100_CreateReimbursementBatchAdminFiles.php b/app/Database/Migrations/2025-12-01-000100_CreateReimbursementBatchAdminFiles.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-12-24-190600_AddReviewFieldsToScoreComments.php b/app/Database/Migrations/2025-12-24-190600_AddReviewFieldsToScoreComments.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-12-27-050000_CreateAttendanceCommentTemplates.php b/app/Database/Migrations/2025-12-27-050000_CreateAttendanceCommentTemplates.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-05-215636_CreatePrintRequests.php b/app/Database/Migrations/2026-01-05-215636_CreatePrintRequests.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-05-215851_FixPrintRequestsForeignKey.php b/app/Database/Migrations/2026-01-05-215851_FixPrintRequestsForeignKey.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php b/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-06-200210_DropPrintRequestsForeignKey.php b/app/Database/Migrations/2026-01-06-200210_DropPrintRequestsForeignKey.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-06-201209_FixPrintRequestsForeignKeyOnceAndForAll.php b/app/Database/Migrations/2026-01-06-201209_FixPrintRequestsForeignKeyOnceAndForAll.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php b/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000001_CreateCompetitions.php b/app/Database/Migrations/2026-01-10-000001_CreateCompetitions.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000002_CreateCompetitionScores.php b/app/Database/Migrations/2026-01-10-000002_CreateCompetitionScores.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000003_CreateCompetitionWinners.php b/app/Database/Migrations/2026-01-10-000003_CreateCompetitionWinners.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000004_AddCompetitionNavItems.php b/app/Database/Migrations/2026-01-10-000004_AddCompetitionNavItems.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000005_AddBelowSixtyNavItem.php b/app/Database/Migrations/2026-01-10-000005_AddBelowSixtyNavItem.php new file mode 100755 index 0000000..7571422 --- /dev/null +++ b/app/Database/Migrations/2026-01-10-000005_AddBelowSixtyNavItem.php @@ -0,0 +1,139 @@ +tableExists('nav_items')) { + return; + } + + $parentColumn = null; + if ($db->fieldExists('menu_parent_id', 'nav_items')) { + $parentColumn = 'menu_parent_id'; + } elseif ($db->fieldExists('parent_id', 'nav_items')) { + $parentColumn = 'parent_id'; + } + + if ($parentColumn === null) { + return; + } + + $navBuilder = $db->table('nav_items'); + $now = date('Y-m-d H:i:s'); + + $communication = $navBuilder + ->select('id') + ->where('label', 'Communication') + ->where($parentColumn, null) + ->get() + ->getRowArray(); + + if ($communication) { + $communicationId = (int) $communication['id']; + } else { + $navBuilder->insert([ + $parentColumn => null, + 'label' => 'Communication', + 'url' => null, + 'sort_order' => 80, + 'is_enabled' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $communicationId = (int) $db->insertID(); + } + + $belowSixtyUrl = 'grading/below-60'; + $existing = $navBuilder->select('id') + ->where('url', $belowSixtyUrl) + ->get() + ->getRowArray(); + + if (!$existing) { + $navBuilder->insert([ + $parentColumn => $communicationId ?: null, + 'label' => 'Below 60 Summary', + 'url' => $belowSixtyUrl, + 'sort_order' => 4, + 'is_enabled' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $belowSixtyId = (int) $db->insertID(); + } else { + $belowSixtyId = (int) $existing['id']; + } + + if (!$db->tableExists('role_nav_items') || !$db->tableExists('roles')) { + return; + } + + if ($belowSixtyId <= 0) { + return; + } + + $roleRows = $db->table('roles') + ->select('id, name') + ->whereIn('name', ['administrator', 'principal', 'vice_principal', 'admin']) + ->get() + ->getResultArray(); + + $roleMap = $db->table('role_nav_items'); + foreach ($roleRows as $role) { + $roleId = (int) ($role['id'] ?? 0); + if ($roleId <= 0) { + continue; + } + + $exists = $roleMap + ->where('role_id', $roleId) + ->where('nav_item_id', $belowSixtyId) + ->get() + ->getRowArray(); + + if ($exists) { + continue; + } + + $roleMap->insert([ + 'role_id' => $roleId, + 'nav_item_id' => $belowSixtyId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + public function down() + { + $db = \Config\Database::connect(); + + if (!$db->tableExists('nav_items')) { + return; + } + + $belowSixtyUrl = 'grading/below-60'; + $row = $db->table('nav_items') + ->select('id') + ->where('url', $belowSixtyUrl) + ->get() + ->getRowArray(); + + if ($row && $db->tableExists('role_nav_items')) { + $db->table('role_nav_items') + ->where('nav_item_id', (int) $row['id']) + ->delete(); + } + + $db->table('nav_items') + ->where('url', $belowSixtyUrl) + ->delete(); + } +} diff --git a/app/Database/Migrations/2026-01-10-000005_CreateCompetitionClassWinners.php b/app/Database/Migrations/2026-01-10-000005_CreateCompetitionClassWinners.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000006_AddQuestionCountToCompetitionClassWinners.php b/app/Database/Migrations/2026-01-10-000006_AddQuestionCountToCompetitionClassWinners.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000007_AddPrizeColumnsToCompetitionClassWinners.php b/app/Database/Migrations/2026-01-10-000007_AddPrizeColumnsToCompetitionClassWinners.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000007_AllowCompetitionWinnerRankTies.php b/app/Database/Migrations/2026-01-10-000007_AllowCompetitionWinnerRankTies.php old mode 100644 new mode 100755 index e7c6498..3729203 --- a/app/Database/Migrations/2026-01-10-000007_AllowCompetitionWinnerRankTies.php +++ b/app/Database/Migrations/2026-01-10-000007_AllowCompetitionWinnerRankTies.php @@ -13,11 +13,11 @@ class AllowCompetitionWinnerRankTies extends Migration return; } - if ($db->indexExists('competition_winners', 'competition_id_class_section_id_rank')) { + if ($this->indexExists($db, 'competition_winners', 'competition_id_class_section_id_rank')) { $db->query('DROP INDEX competition_id_class_section_id_rank ON competition_winners'); } - if (!$db->indexExists('competition_winners', 'competition_id_class_section_id_rank')) { + if (! $this->indexExists($db, 'competition_winners', 'competition_id_class_section_id_rank')) { $db->query( 'CREATE INDEX competition_id_class_section_id_rank ON competition_winners (competition_id, class_section_id, rank)' ); @@ -31,7 +31,7 @@ class AllowCompetitionWinnerRankTies extends Migration return; } - if ($db->indexExists('competition_winners', 'competition_id_class_section_id_rank')) { + if ($this->indexExists($db, 'competition_winners', 'competition_id_class_section_id_rank')) { $db->query('DROP INDEX competition_id_class_section_id_rank ON competition_winners'); } @@ -39,4 +39,23 @@ class AllowCompetitionWinnerRankTies extends Migration 'ALTER TABLE competition_winners ADD UNIQUE KEY competition_id_class_section_id_rank (competition_id, class_section_id, rank)' ); } + + private function indexExists($db, string $table, string $index): bool + { + if (method_exists($db, 'indexExists')) { + return $db->indexExists($table, $index); + } + + $dbName = $db->getDatabase(); + $builder = $db->table('information_schema.STATISTICS'); + $row = $builder + ->select('INDEX_NAME') + ->where('TABLE_SCHEMA', $dbName) + ->where('TABLE_NAME', $table) + ->where('INDEX_NAME', $index) + ->get() + ->getRowArray(); + + return ! empty($row); + } } diff --git a/app/Database/Migrations/2026-01-11-000100_CreateAdminNotificationSubjects.php b/app/Database/Migrations/2026-01-11-000100_CreateAdminNotificationSubjects.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-13-000300_AddCompetitionLockFields.php b/app/Database/Migrations/2026-01-13-000300_AddCompetitionLockFields.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-14-000100_AddStudentActiveFlag.php b/app/Database/Migrations/2026-01-14-000100_AddStudentActiveFlag.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-14-000200_CreateClassProgressReports.php b/app/Database/Migrations/2026-01-14-000200_CreateClassProgressReports.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-20-000000_CreateSubjectCurriculumItems.php b/app/Database/Migrations/2026-01-20-000000_CreateSubjectCurriculumItems.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-25-000000_RemoveClassAssignmentSemester.php b/app/Database/Migrations/2026-01-25-000000_RemoveClassAssignmentSemester.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-26-000100_CreateTeacherSubmissionNotificationHistory.php b/app/Database/Migrations/2026-01-26-000100_CreateTeacherSubmissionNotificationHistory.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-27-092000_AddEventTypeToCalendarEvents.php b/app/Database/Migrations/2026-01-27-092000_AddEventTypeToCalendarEvents.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-27-130500_CreateExamDraftSubmissions.php b/app/Database/Migrations/2026-01-27-130500_CreateExamDraftSubmissions.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-27-200000_AddVersionToExamDrafts.php b/app/Database/Migrations/2026-01-27-200000_AddVersionToExamDrafts.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-27-210500_AddFinalPdfToExamDrafts.php b/app/Database/Migrations/2026-01-27-210500_AddFinalPdfToExamDrafts.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-27-211500_AddIsLegacyToExamDrafts.php b/app/Database/Migrations/2026-01-27-211500_AddIsLegacyToExamDrafts.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-27-220000_AddReviewRevisionToExamDrafts.php b/app/Database/Migrations/2026-01-27-220000_AddReviewRevisionToExamDrafts.php new file mode 100755 index 0000000..52427fe --- /dev/null +++ b/app/Database/Migrations/2026-01-27-220000_AddReviewRevisionToExamDrafts.php @@ -0,0 +1,30 @@ +db->fieldExists('review_revision', 'exam_drafts')) { + $this->forge->addColumn('exam_drafts', [ + 'review_revision' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => false, + 'default' => 0, + 'after' => 'version', + ], + ]); + } + } + + public function down() + { + if ($this->db->fieldExists('review_revision', 'exam_drafts')) { + $this->forge->dropColumn('exam_drafts', 'review_revision'); + } + } +} diff --git a/app/Database/Migrations/2026-01-28-120000_AddClassSectionIdToScoreComments.php b/app/Database/Migrations/2026-01-28-120000_AddClassSectionIdToScoreComments.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-30-000100_CreateParentMeetingSchedules.php b/app/Database/Migrations/2026-01-30-000100_CreateParentMeetingSchedules.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-04-000300_CreateClassProgressAttachments.php b/app/Database/Migrations/2026-02-04-000300_CreateClassProgressAttachments.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-04-000400_CreatePlacementLevels.php b/app/Database/Migrations/2026-02-04-000400_CreatePlacementLevels.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-04-000410_UpdatePlacementLevelRange.php b/app/Database/Migrations/2026-02-04-000410_UpdatePlacementLevelRange.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-04-000420_CreatePlacementBatches.php b/app/Database/Migrations/2026-02-04-000420_CreatePlacementBatches.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-04-000430_CreatePlacementScores.php b/app/Database/Migrations/2026-02-04-000430_CreatePlacementScores.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-04-000440_AddEventOnlyToStudentClass.php b/app/Database/Migrations/2026-02-04-000440_AddEventOnlyToStudentClass.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-05-000100_CreateGradingLocks.php b/app/Database/Migrations/2026-02-05-000100_CreateGradingLocks.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-05-000200_CreateMissingScoreOverrides.php b/app/Database/Migrations/2026-02-05-000200_CreateMissingScoreOverrides.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-05-000300_AddPageSelectionToPrintRequests.php b/app/Database/Migrations/2026-02-05-000300_AddPageSelectionToPrintRequests.php old mode 100644 new mode 100755 index 8af9a6b..70a47e5 --- a/app/Database/Migrations/2026-02-05-000300_AddPageSelectionToPrintRequests.php +++ b/app/Database/Migrations/2026-02-05-000300_AddPageSelectionToPrintRequests.php @@ -1 +1,37 @@ -Print all \ No newline at end of file +db->tableExists('print_requests')) { + return; + } + + if (! $this->db->fieldExists('page_selection', 'print_requests')) { + $this->forge->addColumn('print_requests', [ + 'page_selection' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + 'after' => 'num_copies', + ], + ]); + } + } + + public function down() + { + if (! $this->db->tableExists('print_requests')) { + return; + } + + if ($this->db->fieldExists('page_selection', 'print_requests')) { + $this->forge->dropColumn('print_requests', 'page_selection'); + } + } +} diff --git a/app/Database/Migrations/2026-02-10-000100_AddStyleToPreferences.php b/app/Database/Migrations/2026-02-10-000100_AddStyleToPreferences.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-16-000001_AddEventPaidFlagToCharges.php b/app/Database/Migrations/2026-02-16-000001_AddEventPaidFlagToCharges.php new file mode 100755 index 0000000..b957885 --- /dev/null +++ b/app/Database/Migrations/2026-02-16-000001_AddEventPaidFlagToCharges.php @@ -0,0 +1,27 @@ + [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'after' => 'charged', + ], + ]; + + $this->forge->addColumn('event_charges', $fields); + } + + public function down() + { + $this->forge->dropColumn('event_charges', 'event_paid'); + } +} diff --git a/app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php b/app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php new file mode 100755 index 0000000..54e49f3 --- /dev/null +++ b/app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php @@ -0,0 +1,77 @@ +db->tableExists('report_card_acknowledgements')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'parent_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'student_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'school_year' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + ], + 'semester' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + ], + 'viewed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'signed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'signed_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'signer_ip' => [ + 'type' => 'VARCHAR', + 'constraint' => 45, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey(['parent_id', 'student_id', 'school_year', 'semester'], false, true); + $this->forge->createTable('report_card_acknowledgements'); + } + + public function down() + { + $this->forge->dropTable('report_card_acknowledgements', true); + } +} diff --git a/app/Database/Migrations/2026-04-13-000002_AddClassSectionToEventCharges.php b/app/Database/Migrations/2026-04-13-000002_AddClassSectionToEventCharges.php new file mode 100755 index 0000000..b93d2f5 --- /dev/null +++ b/app/Database/Migrations/2026-04-13-000002_AddClassSectionToEventCharges.php @@ -0,0 +1,32 @@ + [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'after' => 'event_paid', + ], + ]; + + if (! $this->db->fieldExists('class_section_id', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + if ($this->db->fieldExists('class_section_id', 'event_charges')) { + $this->forge->dropColumn('event_charges', 'class_section_id'); + } + } +} diff --git a/app/Database/Migrations/2026-04-13-000003_AddEventPaymentRefToEventCharges.php b/app/Database/Migrations/2026-04-13-000003_AddEventPaymentRefToEventCharges.php new file mode 100755 index 0000000..8853af3 --- /dev/null +++ b/app/Database/Migrations/2026-04-13-000003_AddEventPaymentRefToEventCharges.php @@ -0,0 +1,32 @@ + [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'after' => 'class_section_id', + ], + ]; + + if (! $this->db->fieldExists('event_payment_id', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + if ($this->db->fieldExists('event_payment_id', 'event_charges')) { + $this->forge->dropColumn('event_charges', 'event_payment_id'); + } + } +} diff --git a/app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php b/app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php new file mode 100755 index 0000000..14e346e --- /dev/null +++ b/app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php @@ -0,0 +1,43 @@ + [ + 'type' => 'VARCHAR', + 'constraint' => 150, + 'null' => true, + 'after' => 'event_payment_id', + ], + 'external_lastname' => [ + 'type' => 'VARCHAR', + 'constraint' => 150, + 'null' => true, + ], + 'external_note' => [ + 'type' => 'VARCHAR', + 'constraint' => 254, + 'null' => true, + ], + ]; + + if (! $this->db->fieldExists('external_firstname', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + foreach (['external_firstname', 'external_lastname', 'external_note'] as $field) { + if ($this->db->fieldExists($field, 'event_charges')) { + $this->forge->dropColumn('event_charges', $field); + } + } + } +} diff --git a/app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php b/app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php new file mode 100755 index 0000000..6341d01 --- /dev/null +++ b/app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php @@ -0,0 +1,43 @@ + [ + 'type' => 'VARCHAR', + 'constraint' => 150, + 'null' => true, + 'after' => 'external_note', + ], + 'external_parent_lastname' => [ + 'type' => 'VARCHAR', + 'constraint' => 150, + 'null' => true, + ], + 'external_parent_phone' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + ], + ]; + + if (! $this->db->fieldExists('external_parent_firstname', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + foreach (['external_parent_firstname', 'external_parent_lastname', 'external_parent_phone'] as $field) { + if ($this->db->fieldExists($field, 'event_charges')) { + $this->forge->dropColumn('event_charges', $field); + } + } + } +} diff --git a/app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php b/app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php new file mode 100755 index 0000000..0e09fca --- /dev/null +++ b/app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php @@ -0,0 +1,32 @@ + [ + 'type' => 'VARCHAR', + 'constraint' => 254, + 'null' => true, + 'after' => 'external_parent_phone', + ], + ]; + + if (! $this->db->fieldExists('external_parent_email', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + if ($this->db->fieldExists('external_parent_email', 'event_charges')) { + $this->forge->dropColumn('event_charges', 'external_parent_email'); + } + } +} + diff --git a/app/Database/Migrations/2026-04-19-000001_AddWaiverSignedToEventCharges.php b/app/Database/Migrations/2026-04-19-000001_AddWaiverSignedToEventCharges.php new file mode 100755 index 0000000..26e5ac4 --- /dev/null +++ b/app/Database/Migrations/2026-04-19-000001_AddWaiverSignedToEventCharges.php @@ -0,0 +1,31 @@ + [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'after' => 'charged', + ], + ]; + + if (! $this->db->fieldExists('waiver_signed', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + if ($this->db->fieldExists('waiver_signed', 'event_charges')) { + $this->forge->dropColumn('event_charges', 'waiver_signed'); + } + } +} diff --git a/app/Database/Migrations/ExtendStaffAttendanceForTeachers.php b/app/Database/Migrations/ExtendStaffAttendanceForTeachers.php old mode 100644 new mode 100755 diff --git a/app/Database/Seeds/.gitkeep b/app/Database/Seeds/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Database/Seeds/FamiliesBootstrapSeeder.txt b/app/Database/Seeds/FamiliesBootstrapSeeder.txt old mode 100644 new mode 100755 diff --git a/app/Database/Seeds/FamiliesRebuildSeeder.php b/app/Database/Seeds/FamiliesRebuildSeeder.php old mode 100644 new mode 100755 diff --git a/app/Database/Seeds/NavSeeder.php b/app/Database/Seeds/NavSeeder.php old mode 100644 new mode 100755 diff --git a/app/Database/Seeds/SubjectCurriculumSeeder.php b/app/Database/Seeds/SubjectCurriculumSeeder.php old mode 100644 new mode 100755 diff --git a/app/Events/DeleteUnverifiedUser.php b/app/Events/DeleteUnverifiedUser.php old mode 100644 new mode 100755 diff --git a/app/Filters/.gitkeep b/app/Filters/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Filters/ApiAuthFilter.php b/app/Filters/ApiAuthFilter.php old mode 100644 new mode 100755 diff --git a/app/Filters/ApiDocsAuthFilter.php b/app/Filters/ApiDocsAuthFilter.php old mode 100644 new mode 100755 diff --git a/app/Filters/AuthFilter.php b/app/Filters/AuthFilter.php old mode 100644 new mode 100755 index f3fcae6..0bc85f3 --- a/app/Filters/AuthFilter.php +++ b/app/Filters/AuthFilter.php @@ -41,7 +41,13 @@ class AuthFilter implements FilterInterface // Must be logged in if (empty($userRoles) || empty($userId)) { - return redirect()->to('/login'); + $target = ltrim($request->getUri()->getPath(), '/'); + $query = $request->getUri()->getQuery(); + if ($query !== '') { + $target .= '?' . $query; + } + + return redirect()->to('/login?redirect_to=' . rawurlencode('/' . ltrim($target, '/'))); } // Enforce 12-hour session lifetime @@ -110,7 +116,14 @@ class AuthFilter implements FilterInterface ]); } - return redirect()->to('/login')->with('error', 'Your session has expired. Please log in again.'); + $target = ltrim($request->getUri()->getPath(), '/'); + $query = $request->getUri()->getQuery(); + if ($query !== '') { + $target .= '?' . $query; + } + + return redirect()->to('/login?redirect_to=' . rawurlencode('/' . ltrim($target, '/'))) + ->with('error', 'Your session has expired. Please log in again.'); } public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) diff --git a/app/Filters/CleanupScheduler.php b/app/Filters/CleanupScheduler.php old mode 100644 new mode 100755 diff --git a/app/Filters/PermissionFilter.php b/app/Filters/PermissionFilter.php old mode 100644 new mode 100755 diff --git a/app/Filters/TimezoneFilter.php b/app/Filters/TimezoneFilter.php old mode 100644 new mode 100755 diff --git a/app/Helpers/.gitkeep b/app/Helpers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Helpers/AuthHelper.php b/app/Helpers/AuthHelper.php old mode 100644 new mode 100755 diff --git a/app/Helpers/DocumentHelper.php b/app/Helpers/DocumentHelper.php deleted file mode 100644 index e69de29..0000000 diff --git a/app/Helpers/GlobalConfigHelper.php b/app/Helpers/GlobalConfigHelper.php old mode 100644 new mode 100755 diff --git a/app/Helpers/attendance_comment_helper.php b/app/Helpers/attendance_comment_helper.php old mode 100644 new mode 100755 diff --git a/app/Helpers/auth_helper.php b/app/Helpers/auth_helper.php new file mode 100755 index 0000000..0315b27 --- /dev/null +++ b/app/Helpers/auth_helper.php @@ -0,0 +1,3 @@ + utc_now(), ]; - $html = view('emails/below_sixty_performance', $emailData, ['saveData' => true]); + $html = trim((string)($payload['html'] ?? '')); + if ($html === '') { + $html = view('emails/below_sixty_performance', $emailData, ['saveData' => true]); + } $okAny = false; foreach ($emails as $to) { diff --git a/app/Listeners/SchoolEventListener.php b/app/Listeners/SchoolEventListener.php old mode 100644 new mode 100755 diff --git a/app/Listeners/WhatsappInviteListener.php b/app/Listeners/WhatsappInviteListener.php old mode 100644 new mode 100755 diff --git a/app/Models/.gitkeep b/app/Models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Models/AdditionalChargeModel.php b/app/Models/AdditionalChargeModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AdminNotificationSubjectModel.php b/app/Models/AdminNotificationSubjectModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AttendanceCommentTemplateModel.php b/app/Models/AttendanceCommentTemplateModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AttendanceDataModel.php b/app/Models/AttendanceDataModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AttendanceDayModel.php b/app/Models/AttendanceDayModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AttendanceEmailTemplateModel.php b/app/Models/AttendanceEmailTemplateModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AttendanceRecordModel.php b/app/Models/AttendanceRecordModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AttendanceTrackingModel.php b/app/Models/AttendanceTrackingModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AuthorizedUserModel.php b/app/Models/AuthorizedUserModel.php old mode 100644 new mode 100755 diff --git a/app/Models/BadgePrintLogModel.php b/app/Models/BadgePrintLogModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CalendarModel.php b/app/Models/CalendarModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ClassModel.php b/app/Models/ClassModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ClassPrepAdjustmentModel.php b/app/Models/ClassPrepAdjustmentModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ClassPreparationLogModel.php b/app/Models/ClassPreparationLogModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ClassProgressAttachmentModel.php b/app/Models/ClassProgressAttachmentModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ClassProgressReportModel.php b/app/Models/ClassProgressReportModel.php old mode 100644 new mode 100755 index f7c0285..d5ad154 --- a/app/Models/ClassProgressReportModel.php +++ b/app/Models/ClassProgressReportModel.php @@ -4,6 +4,15 @@ namespace App\Models; use CodeIgniter\Model; +/** + * Weekly class progress reports (one row per subject per week). + * + * unit_title stores a compact summary of unit/chapter rows: segments joined with " ; ". + * Teacher-entered custom Islamic or Quran topics use the prefix "Custom / {text}" + * (see {@see \App\Controllers\ClassProgressController::CUSTOM_UNIT_ROW_LABEL} and + * {@see \App\Controllers\ClassProgressController::splitUnitTitleForDisplay()}). + * DB column is VARCHAR(120); controller truncates to match. + */ class ClassProgressReportModel extends Model { protected $table = 'class_progress_reports'; @@ -27,7 +36,42 @@ class ClassProgressReportModel extends Model 'flags_json', 'attachment_path', ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; protected $updatedField = 'updated_at'; + + /** + * Validation is performed in {@see \App\Controllers\ClassProgressController} on HTTP input. + * Rules below document schema limits and can be enabled if you set {@see $skipValidation} to false. + */ + protected $skipValidation = true; + + protected $validationRules = [ + 'class_section_id' => 'required|integer', + 'teacher_id' => 'required|integer', + 'week_start' => 'required|valid_date[Y-m-d]', + 'week_end' => 'required|valid_date[Y-m-d]', + 'subject' => 'required|string|max_length[160]', + 'unit_title' => 'permit_empty|string|max_length[120]', + 'covered' => 'permit_empty|string', + 'homework' => 'permit_empty|string', + 'assessment' => 'permit_empty|string', + 'status' => 'permit_empty|in_list[on_track,slightly_behind,behind]', + 'status_notes' => 'permit_empty|string|max_length[200]', + 'class_notes' => 'permit_empty|string', + 'next_week_plan' => 'permit_empty|string', + 'support_needed' => 'permit_empty|string', + 'flags_json' => 'permit_empty|string', + 'attachment_path' => 'permit_empty|string|max_length[255]', + ]; + + protected $validationMessages = [ + 'unit_title' => [ + 'max_length' => 'Unit and chapter summary cannot exceed 120 characters.', + ], + 'subject' => [ + 'max_length' => 'Subject cannot exceed 160 characters.', + ], + ]; } diff --git a/app/Models/ClassSectionModel.php b/app/Models/ClassSectionModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CommunicationLogModel.php b/app/Models/CommunicationLogModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CompetitionClassWinnerModel.php b/app/Models/CompetitionClassWinnerModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CompetitionModel.php b/app/Models/CompetitionModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CompetitionScoreModel.php b/app/Models/CompetitionScoreModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CompetitionWinnerModel.php b/app/Models/CompetitionWinnerModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ConfigurationModel.php b/app/Models/ConfigurationModel.php old mode 100644 new mode 100755 index 86ca89b..0a6463d --- a/app/Models/ConfigurationModel.php +++ b/app/Models/ConfigurationModel.php @@ -22,11 +22,13 @@ class ConfigurationModel extends Model */ public function getConfigValueByKey(string $key) { - // Deterministic read in case historical duplicates exist - $result = $this->where('config_key', $key) + // Use a fresh builder to avoid stale state from shared model builder. + $builder = $this->db->table($this->table); + $result = $builder->where('config_key', $key) ->orderBy('id', 'DESC') - ->first(); - return $result ? $result['config_value'] : null; + ->get(1) + ->getRowArray(); + return $result['config_value'] ?? null; } /** diff --git a/app/Models/ContactUsModel.php b/app/Models/ContactUsModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CurrentFlagModel.php b/app/Models/CurrentFlagModel.php old mode 100644 new mode 100755 diff --git a/app/Models/DiscountUsageModel.php b/app/Models/DiscountUsageModel.php old mode 100644 new mode 100755 diff --git a/app/Models/DiscountVoucherModel.php b/app/Models/DiscountVoucherModel.php old mode 100644 new mode 100755 diff --git a/app/Models/EarlyDismissalSignatureModel.php b/app/Models/EarlyDismissalSignatureModel.php old mode 100644 new mode 100755 diff --git a/app/Models/EmailTemplateModel.php b/app/Models/EmailTemplateModel.php old mode 100644 new mode 100755 diff --git a/app/Models/EmergencyContactModel.php b/app/Models/EmergencyContactModel.php old mode 100644 new mode 100755 diff --git a/app/Models/EnrollmentModel.php b/app/Models/EnrollmentModel.php old mode 100644 new mode 100755 diff --git a/app/Models/EventChargesModel.php b/app/Models/EventChargesModel.php old mode 100644 new mode 100755 index 096c903..e0a61fc --- a/app/Models/EventChargesModel.php +++ b/app/Models/EventChargesModel.php @@ -15,8 +15,20 @@ class EventChargesModel extends Model 'student_id', 'participation', 'charged', + 'waiver_signed', + 'event_paid', + 'event_payment_id', + 'class_section_id', + 'external_firstname', + 'external_lastname', + 'external_note', + 'external_parent_firstname', + 'external_parent_lastname', + 'external_parent_phone', + 'external_parent_email', 'semester', 'school_year', + 'created_by', 'updated_by', 'created_at', 'updated_at' @@ -30,7 +42,7 @@ class EventChargesModel extends Model public function getChargesWithEventInfo($parentId = null, $schoolYear = null, $semester = null) { - $builder = $this->select('event_charges.*, events.event_name, events.amount') + $builder = $this->select('event_charges.*, events.event_name, events.amount AS event_amount, events.description AS event_description') ->join('events', 'events.id = event_charges.event_id', 'left'); if ($parentId) { diff --git a/app/Models/EventModel.php b/app/Models/EventModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ExamDraftModel.php b/app/Models/ExamDraftModel.php old mode 100644 new mode 100755 index 1044245..bf96c84 --- a/app/Models/ExamDraftModel.php +++ b/app/Models/ExamDraftModel.php @@ -8,19 +8,51 @@ class ExamDraftModel extends Model { protected $table = 'exam_drafts'; protected $primaryKey = 'id'; + protected $returnType = 'array'; + protected $useTimestamps = true; + protected $createdField = 'created_at'; + protected $updatedField = 'updated_at'; + protected bool $updateOnlyChanged = false; + + /** @var list