diff --git a/app/Controllers/View/WhatsappController.php b/app/Controllers/View/WhatsappController.php index 8bfdd0e..a55e1de 100644 --- a/app/Controllers/View/WhatsappController.php +++ b/app/Controllers/View/WhatsappController.php @@ -106,14 +106,12 @@ class WhatsappController extends BaseController $existing = $this->linkModel->where([ 'class_section_id' => $sectionId, 'school_year' => $this->schoolYear, - 'semester' => $this->semester, ])->first(); $payload = [ 'class_section_id' => $sectionId, 'class_section_name' => $sectionName, 'school_year' => $this->schoolYear, - 'semester' => $this->semester, 'invite_link' => trim($inviteLink), 'active' => $active, ]; @@ -434,8 +432,6 @@ class WhatsappController extends BaseController sp.secondparent_lastname AS sp_lastname, sp.secondparent_email AS sp_email, sp.secondparent_phone AS sp_phone, - sp.school_year AS sp_school_year, - sp.semester AS sp_semester, u.id AS u_id, u.firstname AS u_firstname, @@ -448,14 +444,6 @@ class WhatsappController extends BaseController $b->join('users u', 'u.id = sp.firstparent_id', 'left'); - // Scope by term using parents table (authoritative for the pairing) - if ($schoolYear !== '') { - $b->where('sp.school_year', $schoolYear); - } - if ($semester !== '') { - $b->where('sp.semester', $semester); - } - $rows = $b->get()->getResultArray(); // Flatten into a single contacts list (primary + second parent as separate rows) @@ -606,11 +594,10 @@ class WhatsappController extends BaseController $pb->join('students s', 's.id = sc.student_id', 'inner'); // adjust if your table is named `student` (singular) $pb->join('users u', 'u.id = s.parent_id', 'inner'); // primary parent lives on students.parent_id - // Second parent row for the same term (allow NULL/'' semester if you sometimes omit it) + // Second parent row for the primary parent. $pb->join( 'parents sp', - "sp.firstparent_id = u.id - AND sp.school_year = sc.school_year", + 'sp.firstparent_id = u.id', 'left' ); @@ -1202,7 +1189,7 @@ class WhatsappController extends BaseController /** * Class mode: - * - Given classSectionId, find students (student_class) for the term + * - Given classSectionId, find students (student_class) for the year * - Map to primaries (users) and second-parents (parents) * - Return one bundle **per primary parent** (so each parent gets their own email) */ @@ -1215,7 +1202,6 @@ class WhatsappController extends BaseController $stuRows = $this->db->table('student_class') ->select('student_id') ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester) ->where('class_section_id', $classSectionId) ->get()->getResultArray(); if (empty($stuRows)) return []; @@ -1302,7 +1288,7 @@ class WhatsappController extends BaseController /** * All mode: - * - Iterate all distinct class_section_id in student_class (for the term) + * - Iterate all distinct class_section_id in student_class (for the year) * - Reuse class bundles per section and flatten */ private function bundlesForAllParentsAllClasses(array $linkBySection): array @@ -1310,7 +1296,6 @@ class WhatsappController extends BaseController $secRows = $this->db->table('student_class') ->select('DISTINCT class_section_id', false) ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester) ->orderBy('class_section_id', 'ASC') ->get()->getResultArray(); diff --git a/app/Database/Migrations/2026-07-12-040600_FixSchoolYearColumns.php b/app/Database/Migrations/2026-07-12-040600_FixSchoolYearColumns.php new file mode 100644 index 0000000..95792e6 --- /dev/null +++ b/app/Database/Migrations/2026-07-12-040600_FixSchoolYearColumns.php @@ -0,0 +1,372 @@ +db->DBDriver !== 'MySQLi') { + throw new \RuntimeException( + 'This migration targets MySQL 8 because it uses enforced CHECK constraints.' + ); + } + + // These annual entities lacked a school_year column in the audited schema. + foreach (['class_progress_reports', 'exams', 'print_requests'] as $table) { + if ($this->db->tableExists($table) && ! $this->db->fieldExists('school_year', $table)) { + // Nullable avoids inventing a year for existing rows. + // Backfill it, then make it NOT NULL in a later migration. + $this->forge->addColumn($table, [ + 'school_year' => [ + 'type' => 'VARCHAR', + 'constraint' => 9, + 'null' => true, + ], + ]); + } + } + + // Validate and normalize every direct owner to VARCHAR(9). + foreach ($this->yearOwnerTables as $table) { + if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { + continue; + } + + $this->assertValidSchoolYears($table, 'school_year'); + $this->normalizeYearColumn($table, 'school_year'); + $this->ensureYearCheckConstraint($table, 'school_year'); + $this->ensureYearIndex($table, 'school_year'); + } + + // Promotion is a transition and legitimately owns both source and target years. + if ($this->db->tableExists('promotion_queue')) { + foreach (['school_year_from', 'school_year_to'] as $column) { + if (! $this->db->fieldExists($column, 'promotion_queue')) { + continue; + } + + $this->assertValidSchoolYears('promotion_queue', $column); + $this->normalizeYearColumn('promotion_queue', $column); + $this->ensureYearCheckConstraint('promotion_queue', $column); + $this->ensureYearIndex('promotion_queue', $column); + } + } + + // Remove redundant columns. Dependent non-primary indexes are removed first. + foreach ($this->tablesWithoutDirectYear as $table) { + if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { + continue; + } + + $this->dropIndexesContainingColumn($table, 'school_year'); + $this->dropChecksContainingColumn($table, 'school_year'); + $this->forge->dropColumn($table, 'school_year'); + } + + foreach ($this->tablesWithoutDirectSemester as $table) { + if (! $this->db->tableExists($table) || ! $this->db->fieldExists('semester', $table)) { + continue; + } + + $this->dropIndexesContainingColumn($table, 'semester'); + $this->dropChecksContainingColumn($table, 'semester'); + $this->forge->dropColumn($table, 'semester'); + } + + foreach ($this->yearOwnerTables as $table) { + if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { + continue; + } + + $this->ensureYearIndex($table, 'school_year'); + } + } + + public function down(): void + { + throw new \RuntimeException( + 'This migration is intentionally irreversible because dropping school_year columns destroys data. Restore from backup instead of pretending rollback can resurrect it.' + ); + } + + private function assertValidSchoolYears(string $table, string $column): void + { + $tableName = $this->quoteIdentifier($table); + $columnName = $this->quoteIdentifier($column); + + $invalid = $this->db->query( + "SELECT COUNT(*) AS aggregate + FROM {$tableName} + WHERE {$columnName} IS NOT NULL + AND ( + {$columnName} NOT REGEXP '^[0-9]{4}-[0-9]{4}$' + OR CAST(RIGHT({$columnName}, 4) AS UNSIGNED) + <> CAST(LEFT({$columnName}, 4) AS UNSIGNED) + 1 + )" + )->getRow(); + + if ((int) ($invalid->aggregate ?? 0) > 0) { + throw new \RuntimeException( + "Cannot migrate {$table}.{$column}: found {$invalid->aggregate} invalid school-year value(s). Expected YYYY-YYYY with consecutive years, for example 2025-2026." + ); + } + } + + private function normalizeYearColumn(string $table, string $column): void + { + $metadata = $this->db->query( + 'SELECT IS_NULLABLE, COLLATION_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ?', + [$table, $column] + )->getRow(); + + if ($metadata === null) { + return; + } + + $nullable = $metadata->IS_NULLABLE === 'YES' ? 'NULL' : 'NOT NULL'; + $collation = $metadata->COLLATION_NAME && preg_match('/^[A-Za-z0-9_]+$/', $metadata->COLLATION_NAME) + ? ' COLLATE ' . $metadata->COLLATION_NAME + : ''; + + $this->db->query(sprintf( + 'ALTER TABLE %s MODIFY COLUMN %s VARCHAR(9)%s %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($column), + $collation, + $nullable + )); + } + + private function ensureYearCheckConstraint(string $table, string $column): void + { + $constraint = $this->objectName('chk_sy', $table, $column); + + $exists = $this->db->query( + 'SELECT COUNT(*) AS aggregate + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = ? + AND CONSTRAINT_TYPE = \'CHECK\'', + [$table, $constraint] + )->getRow(); + + if ((int) ($exists->aggregate ?? 0) > 0) { + return; + } + + $columnName = $this->quoteIdentifier($column); + + $this->db->query(sprintf( + "ALTER TABLE %s ADD CONSTRAINT %s CHECK ( + %s IS NULL OR ( + %s REGEXP '^[0-9]{4}-[0-9]{4}$' + AND CAST(RIGHT(%s, 4) AS UNSIGNED) + = CAST(LEFT(%s, 4) AS UNSIGNED) + 1 + ) + )", + $this->quoteIdentifier($table), + $this->quoteIdentifier($constraint), + $columnName, + $columnName, + $columnName, + $columnName + )); + } + + private function ensureYearIndex(string $table, string $column): void + { + $indexed = $this->db->query( + 'SELECT COUNT(*) AS aggregate + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ?', + [$table, $column] + )->getRow(); + + if ((int) ($indexed->aggregate ?? 0) > 0) { + return; + } + + $index = $this->objectName('idx_sy', $table, $column); + + $this->db->query(sprintf( + 'ALTER TABLE %s ADD INDEX %s (%s)', + $this->quoteIdentifier($table), + $this->quoteIdentifier($index), + $this->quoteIdentifier($column) + )); + } + + private function dropIndexesContainingColumn(string $table, string $column): void + { + $indexes = $this->db->query( + 'SELECT DISTINCT INDEX_NAME + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ? + AND INDEX_NAME <> \'PRIMARY\'', + [$table, $column] + )->getResult(); + + foreach ($indexes as $index) { + $this->db->query(sprintf( + 'ALTER TABLE %s DROP INDEX %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($index->INDEX_NAME) + )); + } + } + + private function dropChecksContainingColumn(string $table, string $column): void + { + $checks = $this->db->query( + 'SELECT tc.CONSTRAINT_NAME + FROM information_schema.TABLE_CONSTRAINTS tc + INNER JOIN information_schema.CHECK_CONSTRAINTS cc + ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + WHERE tc.CONSTRAINT_SCHEMA = DATABASE() + AND tc.TABLE_NAME = ? + AND tc.CONSTRAINT_TYPE = \'CHECK\' + AND cc.CHECK_CLAUSE LIKE ?', + [$table, '%' . $column . '%'] + )->getResult(); + + foreach ($checks as $check) { + $this->db->query(sprintf( + 'ALTER TABLE %s DROP CHECK %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($check->CONSTRAINT_NAME) + )); + } + } + + private function objectName(string $prefix, string $table, string $column): string + { + // MySQL identifiers are limited to 64 characters. + return substr($prefix . '_' . $table . '_' . $column . '_' . substr(sha1($table . '.' . $column), 0, 8), 0, 64); + } + + private function quoteIdentifier(string $identifier): string + { + return '`' . str_replace('`', '``', $identifier) . '`'; + } +} diff --git a/app/Database/Migrations/2026-07-12-041500_EnsureSchoolYearOnFinancialTables.php b/app/Database/Migrations/2026-07-12-041500_EnsureSchoolYearOnFinancialTables.php new file mode 100644 index 0000000..72154f0 --- /dev/null +++ b/app/Database/Migrations/2026-07-12-041500_EnsureSchoolYearOnFinancialTables.php @@ -0,0 +1,141 @@ + table => nullable */ + private array $tables = [ + 'discount_usages' => false, + 'event_charges' => true, + 'invoice_event' => true, + 'payment_error' => true, + 'payment_notification_logs' => true, + 'payment_transactions' => true, + 'reimbursement_batch_items' => true, + ]; + + public function up(): void + { + if ($this->db->DBDriver !== 'MySQLi') { + throw new RuntimeException('This migration requires MySQL/MariaDB through the MySQLi driver.'); + } + + foreach ($this->tables as $table => $nullable) { + if (! $this->db->tableExists($table)) { + continue; + } + + if (! $this->db->fieldExists('school_year', $table)) { + $this->forge->addColumn($table, [ + 'school_year' => [ + 'type' => 'VARCHAR', + 'constraint' => 9, + 'null' => $nullable, + 'after' => $this->afterColumn($table), + ], + ]); + } + + $this->backfillSchoolYear($table); + $this->assertValidYears($table); + + $nullSql = $nullable ? 'NULL' : 'NOT NULL'; + $this->db->query( + sprintf( + 'ALTER TABLE `%s` MODIFY `school_year` VARCHAR(9) %s', + str_replace('`', '``', $table), + $nullSql + ) + ); + + $this->ensureIndex($table); + } + } + + public function down(): void + { + // These columns are part of the business model and must not be dropped on rollback. + } + + private function assertValidYears(string $table): void + { + $sql = sprintf( + "SELECT COUNT(*) AS invalid_count + FROM `%s` + WHERE `school_year` IS NOT NULL + AND ( + `school_year` NOT REGEXP '^[0-9]{4}-[0-9]{4}$' + OR CAST(RIGHT(`school_year`, 4) AS UNSIGNED) + <> CAST(LEFT(`school_year`, 4) AS UNSIGNED) + 1 + )", + str_replace('`', '``', $table) + ); + + $row = $this->db->query($sql)->getRowArray(); + $invalid = (int) ($row['invalid_count'] ?? 0); + + if ($invalid > 0) { + throw new RuntimeException( + "Cannot normalize {$table}.school_year: {$invalid} invalid value(s). Expected YYYY-YYYY, for example 2025-2026." + ); + } + } + + private function backfillSchoolYear(string $table): void + { + if ($table === 'discount_usages' && $this->db->tableExists('invoices')) { + $this->db->query( + "UPDATE `discount_usages` du + INNER JOIN `invoices` i ON i.`id` = du.`invoice_id` + SET du.`school_year` = i.`school_year` + WHERE (du.`school_year` IS NULL OR TRIM(du.`school_year`) = '') + AND i.`school_year` REGEXP '^[0-9]{4}-[0-9]{4}$' + AND CAST(RIGHT(i.`school_year`, 4) AS UNSIGNED) + = CAST(LEFT(i.`school_year`, 4) AS UNSIGNED) + 1" + ); + } + } + + private function ensureIndex(string $table): void + { + $indexName = 'idx_' . $table . '_school_year'; + if (strlen($indexName) > 64) { + $indexName = 'idx_' . substr(hash('sha256', $table . '_school_year'), 0, 24); + } + + $row = $this->db->query( + 'SELECT COUNT(*) AS index_count + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ?', + [$table, 'school_year'] + )->getRowArray(); + + if ((int) ($row['index_count'] ?? 0) === 0) { + $this->db->query(sprintf( + 'CREATE INDEX `%s` ON `%s` (`school_year`)', + str_replace('`', '``', $indexName), + str_replace('`', '``', $table) + )); + } + } + + private function afterColumn(string $table): string + { + $preferred = ['semester', 'invoice_id', 'payment_id', 'batch_id']; + foreach ($preferred as $column) { + if ($this->db->fieldExists($column, $table)) { + return $column; + } + } + + return 'id'; + } +} diff --git a/app/Database/Migrations/2026-07-12-044500_ApplySchoolYearSemesterAuditCorrections.php b/app/Database/Migrations/2026-07-12-044500_ApplySchoolYearSemesterAuditCorrections.php new file mode 100644 index 0000000..b9650c8 --- /dev/null +++ b/app/Database/Migrations/2026-07-12-044500_ApplySchoolYearSemesterAuditCorrections.php @@ -0,0 +1,256 @@ +db->DBDriver !== 'MySQLi') { + throw new RuntimeException('This migration requires MySQL/MariaDB through the MySQLi driver.'); + } + + if ($this->db->tableExists('archived_paypal_transactions') + && $this->db->fieldExists('school_year', 'archived_paypal_transactions')) { + $this->assertValidSchoolYears('archived_paypal_transactions', 'school_year'); + $this->normalizeYearColumn('archived_paypal_transactions', 'school_year'); + $this->ensureYearCheckConstraint('archived_paypal_transactions', 'school_year'); + } + + foreach ($this->schoolYearIndexTables as $table) { + if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { + continue; + } + + $this->ensureIndex($table, 'school_year'); + } + + foreach ($this->semesterDropTables as $table) { + if (! $this->db->tableExists($table) || ! $this->db->fieldExists('semester', $table)) { + continue; + } + + $this->dropIndexesContainingColumn($table, 'semester'); + $this->dropChecksContainingColumn($table, 'semester'); + $this->forge->dropColumn($table, 'semester'); + } + + foreach ($this->schoolYearIndexTables as $table) { + if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { + continue; + } + + $this->ensureIndex($table, 'school_year'); + } + } + + public function down(): void + { + throw new RuntimeException( + 'This migration is intentionally irreversible because dropping semester columns destroys data.' + ); + } + + private function assertValidSchoolYears(string $table, string $column): void + { + $tableName = $this->quoteIdentifier($table); + $columnName = $this->quoteIdentifier($column); + + $invalid = $this->db->query( + "SELECT COUNT(*) AS aggregate + FROM {$tableName} + WHERE {$columnName} IS NOT NULL + AND ( + {$columnName} NOT REGEXP '^[0-9]{4}-[0-9]{4}$' + OR CAST(RIGHT({$columnName}, 4) AS UNSIGNED) + <> CAST(LEFT({$columnName}, 4) AS UNSIGNED) + 1 + )" + )->getRow(); + + if ((int) ($invalid->aggregate ?? 0) > 0) { + throw new RuntimeException( + "Cannot migrate {$table}.{$column}: found {$invalid->aggregate} invalid school-year value(s)." + ); + } + } + + private function normalizeYearColumn(string $table, string $column): void + { + $metadata = $this->db->query( + 'SELECT IS_NULLABLE, COLLATION_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ?', + [$table, $column] + )->getRow(); + + if ($metadata === null) { + return; + } + + $nullable = $metadata->IS_NULLABLE === 'YES' ? 'NULL' : 'NOT NULL'; + $collation = $metadata->COLLATION_NAME && preg_match('/^[A-Za-z0-9_]+$/', $metadata->COLLATION_NAME) + ? ' COLLATE ' . $metadata->COLLATION_NAME + : ''; + + $this->db->query(sprintf( + 'ALTER TABLE %s MODIFY COLUMN %s VARCHAR(9)%s %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($column), + $collation, + $nullable + )); + } + + private function ensureYearCheckConstraint(string $table, string $column): void + { + $constraint = $this->objectName('chk_sy', $table, $column); + + $exists = $this->db->query( + 'SELECT COUNT(*) AS aggregate + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = ? + AND CONSTRAINT_TYPE = \'CHECK\'', + [$table, $constraint] + )->getRow(); + + if ((int) ($exists->aggregate ?? 0) > 0) { + return; + } + + $columnName = $this->quoteIdentifier($column); + + $this->db->query(sprintf( + "ALTER TABLE %s ADD CONSTRAINT %s CHECK ( + %s IS NULL OR ( + %s REGEXP '^[0-9]{4}-[0-9]{4}$' + AND CAST(RIGHT(%s, 4) AS UNSIGNED) + = CAST(LEFT(%s, 4) AS UNSIGNED) + 1 + ) + )", + $this->quoteIdentifier($table), + $this->quoteIdentifier($constraint), + $columnName, + $columnName, + $columnName, + $columnName + )); + } + + private function ensureIndex(string $table, string $column): void + { + $indexed = $this->db->query( + 'SELECT COUNT(*) AS aggregate + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ?', + [$table, $column] + )->getRow(); + + if ((int) ($indexed->aggregate ?? 0) > 0) { + return; + } + + $this->db->query(sprintf( + 'ALTER TABLE %s ADD INDEX %s (%s)', + $this->quoteIdentifier($table), + $this->quoteIdentifier($this->objectName('idx_sy', $table, $column)), + $this->quoteIdentifier($column) + )); + } + + private function dropIndexesContainingColumn(string $table, string $column): void + { + $indexes = $this->db->query( + 'SELECT DISTINCT INDEX_NAME + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ? + AND INDEX_NAME <> \'PRIMARY\'', + [$table, $column] + )->getResult(); + + foreach ($indexes as $index) { + $this->db->query(sprintf( + 'ALTER TABLE %s DROP INDEX %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($index->INDEX_NAME) + )); + } + } + + private function dropChecksContainingColumn(string $table, string $column): void + { + $checks = $this->db->query( + 'SELECT tc.CONSTRAINT_NAME + FROM information_schema.TABLE_CONSTRAINTS tc + INNER JOIN information_schema.CHECK_CONSTRAINTS cc + ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + WHERE tc.CONSTRAINT_SCHEMA = DATABASE() + AND tc.TABLE_NAME = ? + AND tc.CONSTRAINT_TYPE = \'CHECK\' + AND cc.CHECK_CLAUSE LIKE ?', + [$table, '%' . $column . '%'] + )->getResult(); + + foreach ($checks as $check) { + $this->db->query(sprintf( + 'ALTER TABLE %s DROP CHECK %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($check->CONSTRAINT_NAME) + )); + } + } + + private function objectName(string $prefix, string $table, string $column): string + { + return substr($prefix . '_' . $table . '_' . $column . '_' . substr(sha1($table . '.' . $column), 0, 8), 0, 64); + } + + private function quoteIdentifier(string $identifier): string + { + return '`' . str_replace('`', '``', $identifier) . '`'; + } +} diff --git a/app/Database/Migrations/2026-07-12-044700_EnsureWhatsappGroupLinksSchoolYearIndex.php b/app/Database/Migrations/2026-07-12-044700_EnsureWhatsappGroupLinksSchoolYearIndex.php new file mode 100644 index 0000000..87cb121 --- /dev/null +++ b/app/Database/Migrations/2026-07-12-044700_EnsureWhatsappGroupLinksSchoolYearIndex.php @@ -0,0 +1,47 @@ +db->DBDriver !== 'MySQLi') { + throw new RuntimeException('This migration requires MySQL/MariaDB through the MySQLi driver.'); + } + + if (! $this->db->tableExists('whatsapp_group_links') + || ! $this->db->fieldExists('school_year', 'whatsapp_group_links')) { + return; + } + + $indexed = $this->db->query( + 'SELECT COUNT(*) AS aggregate + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ?', + ['whatsapp_group_links', 'school_year'] + )->getRow(); + + if ((int) ($indexed->aggregate ?? 0) > 0) { + return; + } + + $this->db->query( + 'ALTER TABLE `whatsapp_group_links` ADD INDEX `idx_sy_whatsapp_group_links_school_year` (`school_year`)' + ); + } + + public function down(): void + { + if ($this->db->tableExists('whatsapp_group_links')) { + $this->db->query('ALTER TABLE `whatsapp_group_links` DROP INDEX `idx_sy_whatsapp_group_links_school_year`'); + } + } +} diff --git a/app/Models/AttendanceDataModel.php b/app/Models/AttendanceDataModel.php index 25bf399..8c74f10 100644 --- a/app/Models/AttendanceDataModel.php +++ b/app/Models/AttendanceDataModel.php @@ -18,7 +18,6 @@ class AttendanceDataModel extends Model 'date', 'status', 'reason', - 'reported', 'is_reported', 'is_notified', 'semester', diff --git a/app/Models/ClassPrepAdjustmentModel.php b/app/Models/ClassPrepAdjustmentModel.php index 1d90b80..106ed54 100644 --- a/app/Models/ClassPrepAdjustmentModel.php +++ b/app/Models/ClassPrepAdjustmentModel.php @@ -7,6 +7,6 @@ class ClassPrepAdjustmentModel extends Model { protected $table = 'class_prep_adjustments'; protected $allowedFields = [ - 'class_section_id', 'item_name', 'adjustment', 'school_year', 'created_at' + 'class_section_id', 'item_name', 'adjustment', 'adjustable', 'created_at' ]; -} \ No newline at end of file +} diff --git a/app/Models/ClassPreparationLogModel.php b/app/Models/ClassPreparationLogModel.php index b76a342..348d4e1 100644 --- a/app/Models/ClassPreparationLogModel.php +++ b/app/Models/ClassPreparationLogModel.php @@ -11,7 +11,6 @@ class ClassPreparationLogModel extends Model protected $allowedFields = [ 'class_section_id', 'class_section', - 'school_year', 'prep_data', 'created_at', ]; diff --git a/app/Models/ContactUsModel.php b/app/Models/ContactUsModel.php index 95f9c06..7907a6a 100644 --- a/app/Models/ContactUsModel.php +++ b/app/Models/ContactUsModel.php @@ -19,8 +19,6 @@ class ContactUsModel extends Model 'reciever_id', 'subject', 'message', - 'semester', - 'school_year', 'created_at', 'updated_at' ]; @@ -35,8 +33,6 @@ class ContactUsModel extends Model 'reciever_id' => 'required|integer', 'subject' => 'required|string|max_length[255]', 'message' => 'required|string', - 'semester' => 'required|string|max_length[255]', - 'school_year' => 'permit_empty|string|max_length[9]' ]; protected $validationMessages = []; @@ -51,10 +47,7 @@ class ContactUsModel extends Model */ public function getMessagesBySemesterAndYear($semester, $school_year) { - return $this->where([ - 'semester' => $semester, - 'school_year' => $school_year - ])->findAll(); + return $this->findAll(); } /** @@ -92,4 +85,4 @@ class ContactUsModel extends Model { return $this->update($id, $data); } -} \ No newline at end of file +} diff --git a/app/Models/EmailTemplateModel.php b/app/Models/EmailTemplateModel.php index 1d00b5d..bff0879 100644 --- a/app/Models/EmailTemplateModel.php +++ b/app/Models/EmailTemplateModel.php @@ -6,12 +6,19 @@ use CodeIgniter\Model; class EmailTemplateModel extends Model { protected $table = 'email_templates'; protected $primaryKey = 'id'; - protected $allowedFields = ['template_key','name','subject','body','is_active']; + protected $allowedFields = ['code', 'variant', 'subject', 'body_html', 'is_active', 'updated_by', 'updated_at']; public function getActiveTemplates(): array { - return $this->where('is_active', 1)->orderBy('template_key','asc')->findAll(); + return $this->select('email_templates.*, code AS template_key, code AS name, body_html AS body') + ->where('is_active', 1) + ->orderBy('code', 'asc') + ->findAll(); } + public function findByKey(string $key): ?array { - return $this->where('template_key', $key)->where('is_active', 1)->first(); + return $this->select('email_templates.*, code AS template_key, code AS name, body_html AS body') + ->where('code', $key) + ->where('is_active', 1) + ->first(); } } diff --git a/app/Models/EmergencyContactModel.php b/app/Models/EmergencyContactModel.php index 7b4c864..e7933fb 100644 --- a/app/Models/EmergencyContactModel.php +++ b/app/Models/EmergencyContactModel.php @@ -16,8 +16,6 @@ class EmergencyContactModel extends Model 'cellphone', 'email', 'relation', - 'semester', - 'school_year', 'created_at', 'updated_at' ]; diff --git a/app/Models/EnrollmentModel.php b/app/Models/EnrollmentModel.php index 00a09db..7e5e067 100644 --- a/app/Models/EnrollmentModel.php +++ b/app/Models/EnrollmentModel.php @@ -86,7 +86,7 @@ class EnrollmentModel extends Model /** * Get all enrolled students (full student info) for a given parent. */ - public function getEnrolledStudents(int $parentId, string $schoolYear = null, string $semester = null): array + public function getEnrolledStudents(int $parentId, ?string $schoolYear = null, ?string $semester = null): array { $builder = $this->select('students.*') ->join('students', 'students.id = enrollments.student_id') @@ -106,7 +106,7 @@ class EnrollmentModel extends Model /** * Get student basic info (ID, name, grade) for a given parent where status is enrolled or Payment pending. */ - public function getenrolledStudentDetails(int $parentId, string $schoolYear = null): array + public function getenrolledStudentDetails(int $parentId, ?string $schoolYear = null): array { $builder = $this->db->table('enrollments') ->select('students.id, students.firstname, students.lastname, students.grade_level') diff --git a/app/Models/EventChargesModel.php b/app/Models/EventChargesModel.php index e0a61fc..d8fb7b5 100644 --- a/app/Models/EventChargesModel.php +++ b/app/Models/EventChargesModel.php @@ -28,7 +28,6 @@ class EventChargesModel extends Model 'external_parent_email', 'semester', 'school_year', - 'created_by', 'updated_by', 'created_at', 'updated_at' diff --git a/app/Models/ExamDraftModel.php b/app/Models/ExamDraftModel.php index bf96c84..2832aac 100644 --- a/app/Models/ExamDraftModel.php +++ b/app/Models/ExamDraftModel.php @@ -32,7 +32,6 @@ class ExamDraftModel extends Model ]; protected $allowedFields = [ - 'teacher_id', 'author_id', 'class_section_id', 'semester', @@ -40,20 +39,14 @@ class ExamDraftModel extends Model 'exam_type', 'draft_title', 'author_comment', - 'description', - 'teacher_file', - 'teacher_filename', 'author_file', 'author_filename', 'status', 'acceptance_type', 'review_revision', 'reviewer_id', - 'admin_id', 'is_legacy', - 'reviewer_comment', 'reviewer_comments', - 'admin_comments', 'reviewed_at', 'final_file', 'final_filename', @@ -87,11 +80,9 @@ class ExamDraftModel extends Model * @var array */ protected array $casts = [ - 'teacher_id' => 'int', 'author_id' => 'int', 'class_section_id' => 'int', 'reviewer_id' => '?int', - 'admin_id' => '?int', 'review_revision' => 'int', 'version' => 'int', 'previous_draft_id' => '?int', diff --git a/app/Models/ExamModel.php b/app/Models/ExamModel.php index 9e17e37..e792c09 100644 --- a/app/Models/ExamModel.php +++ b/app/Models/ExamModel.php @@ -12,9 +12,10 @@ class ExamModel extends Model protected $allowedFields = [ 'student_id', - 'school_id', + 'student_school_id', 'class_section_id', 'exam_name', + 'school_year', 'created_at' ]; @@ -24,4 +25,4 @@ class ExamModel extends Model protected $updatedField = ''; // Set this if you later add an `updated_at` column protected $returnType = 'array'; // You can change this to 'object' if preferred -} \ No newline at end of file +} diff --git a/app/Models/FinalExamModel.php b/app/Models/FinalExamModel.php index ce7fe0e..a8d3c18 100644 --- a/app/Models/FinalExamModel.php +++ b/app/Models/FinalExamModel.php @@ -18,7 +18,6 @@ class FinalExamModel extends Model 'class_section_id', 'updated_by', 'score', - 'comment', 'semester', 'school_year', 'created_at', diff --git a/app/Models/FinalScoreModel.php b/app/Models/FinalScoreModel.php index 4e5dca5..d6c2388 100644 --- a/app/Models/FinalScoreModel.php +++ b/app/Models/FinalScoreModel.php @@ -14,7 +14,7 @@ class FinalScoreModel extends Model 'student_id', 'school_id', 'class_section_id', - 'teacher_id', + 'updated_by', 'score', 'score_letter', 'comment', @@ -37,4 +37,4 @@ class FinalScoreModel extends Model // Return the score, or null if no result is found return $result ? $result['score'] : null; } -} \ No newline at end of file +} diff --git a/app/Models/InventoryItemModel.php b/app/Models/InventoryItemModel.php index 106631a..987251c 100644 --- a/app/Models/InventoryItemModel.php +++ b/app/Models/InventoryItemModel.php @@ -30,10 +30,6 @@ protected $table = 'inventory_items'; 'sku', 'notes', - // academic tags - 'semester', - 'school_year', - // audit 'updated_by', @@ -50,7 +46,5 @@ protected $table = 'inventory_items'; 'name' => 'required|min_length[2]', 'quantity' => 'permit_empty|integer', 'unit_price' => 'permit_empty|decimal', - 'semester' => 'permit_empty|in_list[Spring,Fall]', - 'school_year' => 'permit_empty|max_length[16]', // e.g. 2025-2026 ]; } diff --git a/app/Models/InvoiceStudentListModel.php b/app/Models/InvoiceStudentListModel.php index 31c4dbf..9256bc1 100644 --- a/app/Models/InvoiceStudentListModel.php +++ b/app/Models/InvoiceStudentListModel.php @@ -20,8 +20,6 @@ class InvoiceStudentListModel extends Model 'student_lastname', 'school_id', 'enrolled', - 'school_year', - 'semester', 'created_at', 'updated_at' ]; diff --git a/app/Models/IpAttemptModel.php b/app/Models/IpAttemptModel.php index 93eb74e..54ce17d 100644 --- a/app/Models/IpAttemptModel.php +++ b/app/Models/IpAttemptModel.php @@ -13,8 +13,6 @@ class IpAttemptModel extends Model 'ip_address', 'attempts', 'last_attempt_at', - 'semester', - 'school_year', 'blocked_until' ]; @@ -48,4 +46,4 @@ class IpAttemptModel extends Model { return $this->insert($data); } -} \ No newline at end of file +} diff --git a/app/Models/LoginActivityModel.php b/app/Models/LoginActivityModel.php index 2151d66..ab09e28 100644 --- a/app/Models/LoginActivityModel.php +++ b/app/Models/LoginActivityModel.php @@ -15,7 +15,6 @@ class LoginActivityModel extends Model 'logout_time', 'ip_address', 'user_agent', - 'school_year', 'semester', 'created_at', 'updated_at' @@ -35,4 +34,4 @@ class LoginActivityModel extends Model } -?> \ No newline at end of file +?> diff --git a/app/Models/MessageModel.php b/app/Models/MessageModel.php index 84c35e7..5c549d6 100644 --- a/app/Models/MessageModel.php +++ b/app/Models/MessageModel.php @@ -21,8 +21,7 @@ class MessageModel extends Model 'priority', 'attachment', 'status', - 'semester', // Added field - 'school_year' // Added field + 'semester' ]; protected $useTimestamps = false; // Since you're manually handling date fields @@ -150,10 +149,8 @@ class MessageModel extends Model */ public function getMessagesBySemesterAndYear($semester, $school_year = null) { - $builder = $this->where('semester', $semester); - if ($school_year) { - $builder->where('school_year', $school_year); - } - return $builder->orderBy('sent_datetime', 'DESC')->findAll(); + return $this->where('semester', $semester) + ->orderBy('sent_datetime', 'DESC') + ->findAll(); } -} \ No newline at end of file +} diff --git a/app/Models/NotificationModel.php b/app/Models/NotificationModel.php index 2abe476..9147488 100644 --- a/app/Models/NotificationModel.php +++ b/app/Models/NotificationModel.php @@ -17,8 +17,8 @@ class NotificationModel extends Model 'status', 'action_url', 'attachment_path', - 'semester', - 'school_year', + 'expires_at', + 'sent_at', 'scheduled_at' ]; protected $useTimestamps = true; diff --git a/app/Models/ParentModel.php b/app/Models/ParentModel.php index d615169..9d62fae 100644 --- a/app/Models/ParentModel.php +++ b/app/Models/ParentModel.php @@ -15,7 +15,5 @@ class ParentModel extends Model 'secondparent_email', 'secondparent_phone', 'firstparent_id', - 'semester', - 'school_year', ]; } diff --git a/app/Models/PaymentTransactionModel.php b/app/Models/PaymentTransactionModel.php index 428ccdf..295a2a5 100644 --- a/app/Models/PaymentTransactionModel.php +++ b/app/Models/PaymentTransactionModel.php @@ -18,18 +18,11 @@ class PaymentTransactionModel extends Model 'payment_method', 'payment_status', 'transaction_fee', - 'payment_reference', 'semester', - 'school_year', - 'is_full_payment', // Flag to track full payment status - 'created_at', - 'updated_at' + 'school_year' ]; - // Set automatic timestamps - protected $useTimestamps = true; - protected $createdField = 'created_at'; - protected $updatedField = 'updated_at'; + protected $useTimestamps = false; // Validation rules protected $validationRules = [ @@ -38,8 +31,8 @@ class PaymentTransactionModel extends Model 'amount' => 'required|decimal', 'payment_method' => 'required|string|max_length[50]', 'payment_status' => 'required|string|max_length[50]', - 'payment_reference' => 'permit_empty|string|max_length[255]', - 'is_full_payment' => 'required|in_list[0,1]', // 0 for installment, 1 for full payment + 'semester' => 'permit_empty|string|max_length[30]', + 'school_year' => 'permit_empty|string|max_length[9]', ]; // Custom error messages @@ -64,13 +57,6 @@ class PaymentTransactionModel extends Model 'required' => 'The payment status is required.', 'max_length' => 'The payment status must not exceed 50 characters.', ], - 'payment_reference' => [ - 'max_length' => 'The payment reference must not exceed 255 characters.', - ], - 'is_full_payment' => [ - 'required' => 'The full payment flag is required.', - 'in_list' => 'The full payment flag must be either 0 (installment) or 1 (full payment).', - ], ]; /** @@ -106,7 +92,9 @@ class PaymentTransactionModel extends Model */ public function updateTransactionStatus($transactionId, $status) { - return $this->update($transactionId, ['payment_status' => $status]); + return $this->where('transaction_id', $transactionId) + ->set(['payment_status' => $status]) + ->update(); } /** @@ -130,7 +118,9 @@ class PaymentTransactionModel extends Model */ public function updateTransactionFee($transactionId, $transactionFee) { - return $this->update($transactionId, ['transaction_fee' => $transactionFee]); + return $this->where('transaction_id', $transactionId) + ->set(['transaction_fee' => $transactionFee]) + ->update(); } /** diff --git a/app/Models/PlacementLevelModel.php b/app/Models/PlacementLevelModel.php index 2eda942..e6ea05a 100644 --- a/app/Models/PlacementLevelModel.php +++ b/app/Models/PlacementLevelModel.php @@ -12,7 +12,6 @@ class PlacementLevelModel extends Model protected $allowedFields = [ 'student_id', 'level', - 'school_year', 'created_by', 'updated_by', 'created_at', diff --git a/app/Models/PreferencesModel.php b/app/Models/PreferencesModel.php index e6c6490..f0b4c47 100644 --- a/app/Models/PreferencesModel.php +++ b/app/Models/PreferencesModel.php @@ -12,8 +12,8 @@ class PreferencesModel extends Model // Allowed fields to enable mass assignment protected $allowedFields = [ 'user_id', // Foreign key linking preferences to a user - 'receive_email_notifications', // Email notifications preference - 'receive_sms_notifications', // SMS notifications preference + 'notification_email', // Email notifications preference + 'notification_sms', // SMS notifications preference 'theme', // Theme preference (e.g., light or dark) 'language', // Language preference 'timezone', // Timezone preference @@ -22,11 +22,6 @@ class PreferencesModel extends Model 'menu_custom_bg', // Custom menu background color 'menu_custom_text', // Custom menu text color 'menu_custom_mode', // Custom menu mode: light|dark - 'receive_push_notifications', // Push notifications preference - 'daily_summary_email', // Daily summary email preference - 'privacy_mode', // Privacy mode setting - 'marketing_emails', // Marketing emails preference - 'account_activity_alerts', // Account activity alerts preference 'created_at', // Timestamp of when the record was created 'updated_at' // Timestamp of when the record was last updated ]; diff --git a/app/Models/SectionModel.php b/app/Models/SectionModel.php index b09406d..a35f624 100644 --- a/app/Models/SectionModel.php +++ b/app/Models/SectionModel.php @@ -12,7 +12,6 @@ class SectionModel extends Model protected $allowedFields = [ 'section_name', 'description', - 'created_at', 'updated_at', 'updated_by' ]; @@ -55,4 +54,4 @@ class SectionModel extends Model { return $this->delete($sectionId); } -} \ No newline at end of file +} diff --git a/app/Models/StaffModel.php b/app/Models/StaffModel.php index cc4eddd..67a0f1e 100644 --- a/app/Models/StaffModel.php +++ b/app/Models/StaffModel.php @@ -21,8 +21,6 @@ class StaffModel extends Model 'phone', 'role_name', 'active_role', - 'status', - 'school_year', 'created_at', 'updated_at' ]; @@ -44,7 +42,7 @@ class StaffModel extends Model } /** - * Insert or update a staff row keyed by (user_id, school_year). + * Insert or update a staff row keyed by user_id. * Ensures created_at is only set on insert and updated_at can be provided by caller. */ public function upsert(array $data): bool @@ -53,27 +51,9 @@ class StaffModel extends Model return false; } - // If school_year not provided, try to keep existing or fall back to current year string - $schoolYear = $data['school_year'] ?? null; + unset($data['school_year'], $data['status']); - if ($schoolYear === null) { - // Try to find any existing row by user_id to reuse its school_year - $existingAny = $this->where('user_id', $data['user_id'])->orderBy('id', 'DESC')->first(); - if ($existingAny && isset($existingAny['school_year'])) { - $schoolYear = $existingAny['school_year']; - $data['school_year'] = $schoolYear; - } - } - - $existing = null; - if ($schoolYear !== null) { - $existing = $this->where('user_id', $data['user_id']) - ->where('school_year', $schoolYear) - ->first(); - } else { - // If no school_year, treat (user_id) as key - $existing = $this->where('user_id', $data['user_id'])->first(); - } + $existing = $this->where('user_id', $data['user_id'])->first(); $now = utc_now(); @@ -100,4 +80,3 @@ class StaffModel extends Model return $this->insert($data) !== false; } } - diff --git a/app/Models/StudentClassModel.php b/app/Models/StudentClassModel.php index edcd194..f45f932 100644 --- a/app/Models/StudentClassModel.php +++ b/app/Models/StudentClassModel.php @@ -11,7 +11,6 @@ class StudentClassModel extends Model protected $allowedFields = [ 'student_id', - 'school_id', 'class_section_id', 'school_year', 'is_event_only', diff --git a/app/Models/StudentModel.php b/app/Models/StudentModel.php index c451fc6..2cf6833 100644 --- a/app/Models/StudentModel.php +++ b/app/Models/StudentModel.php @@ -19,7 +19,6 @@ class StudentModel extends Model 'photo_consent', 'is_new', 'parent_id', - 'school_year', 'registration_date', 'tuition_paid', 'year_of_registration', @@ -122,7 +121,9 @@ class StudentModel extends Model // school_year filter (skip if null or "all") if ($schoolYear !== null && strtolower($schoolYear) !== 'all') { - $builder->where('students.school_year', $schoolYear); + $builder + ->join('student_class sc_filter', 'sc_filter.student_id = students.id', 'inner') + ->where('sc_filter.school_year', $schoolYear); } return $builder @@ -171,12 +172,13 @@ class StudentModel extends Model users.cellphone as phone, students.registration_grade, classSection.class_section_name as current_class, - "' . $schoolYear . '" as school_year, + student_class.school_year, "' . $semester . '" as semester ') ->join('users', 'users.id = students.parent_id', 'left') ->join('student_class', 'student_class.student_id = students.id', 'left') - ->join('class_section', 'student_class.class_section_id = classSection.id', 'left') + ->join('classSection', 'student_class.class_section_id = classSection.class_section_id', 'left') + ->where('student_class.school_year', $schoolYear) ->get() ->getResultArray(); } @@ -199,7 +201,7 @@ class StudentModel extends Model ') ->join('student_class', 'students.id = student_class.student_id', 'left') ->join('teacher_class', 'student_class.class_section_id = teacher_class.class_section_id', 'left') - ->join('class_section', 'student_class.class_section_id = classSection.id', 'left') + ->join('classSection', 'student_class.class_section_id = classSection.class_section_id', 'left') ->where('teacher_class.teacher_id', $teacherId) ->get() ->getResultArray(); diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php index e61d509..7c7de1d 100644 --- a/app/Models/UserModel.php +++ b/app/Models/UserModel.php @@ -28,7 +28,6 @@ class UserModel extends Model 'failed_attempts', 'last_failed_at', 'semester', - 'school_year', 'status', 'is_suspended', 'is_verified', @@ -237,13 +236,23 @@ class UserModel extends Model public function getUsersByRoleAndSchoolYear(string $roleName, string $schoolYear): array { - return $this->select('users.*') + $builder = $this->select('users.*') ->join('user_roles', 'user_roles.user_id = users.id') ->join('roles', 'roles.id = user_roles.role_id') ->where('roles.name', $roleName) - ->where('users.school_year', $schoolYear) - ->where('user_roles.deleted_at', null) - ->findAll(); + ->where('user_roles.deleted_at', null); + + if ($this->userRolesHaveSchoolYear()) { + $builder->where('user_roles.school_year', $schoolYear); + } elseif ($this->roleUsesTeacherAssignments($roleName)) { + $builder->join( + 'teacher_class tc_year', + 'tc_year.teacher_id = users.id AND tc_year.school_year = ' . $this->db->escape($schoolYear), + 'inner' + ); + } + + return $builder->groupBy('users.id')->findAll(); } public function countAdminsBySchoolYear(string $schoolYear): int @@ -260,8 +269,8 @@ class UserModel extends Model ) ->join('user_roles ur', 'ur.user_id = u.id', 'inner') ->join('roles r', 'r.id = ur.role_id', 'inner') - ->where('u.school_year', $schoolYear) ->where('r.is_active', 1) + ->where('LOWER(r.name) NOT IN ("guest","teacher","teacher_assistant","parent")', null, false) ->groupBy('u.id') ->having('is_admin', 1); @@ -407,4 +416,19 @@ class UserModel extends Model ->orderBy($sort === 'roles' ? 'roles' : $sort, $order) ->findAll(); } + + private function userRolesHaveSchoolYear(): bool + { + try { + return in_array('school_year', $this->db->getFieldNames('user_roles'), true); + } catch (\Throwable $e) { + return false; + } + } + + private function roleUsesTeacherAssignments(string $roleName): bool + { + $role = strtolower(trim($roleName)); + return $role === 'ta' || str_contains($role, 'teacher'); + } } diff --git a/app/Models/UserRoleModel.php b/app/Models/UserRoleModel.php index 03c029b..25afc35 100644 --- a/app/Models/UserRoleModel.php +++ b/app/Models/UserRoleModel.php @@ -13,8 +13,6 @@ class UserRoleModel extends Model protected $allowedFields = [ 'user_id', 'role_id', - 'semester', - 'school_year', 'created_at', 'updated_at', 'updated_by', @@ -99,4 +97,4 @@ public function getRolesByUserId(int $userId): array 'updated_by' => session()->get('user_id') ]); } -} \ No newline at end of file +} diff --git a/app/Models/WhatsappGroupLinkModel.php b/app/Models/WhatsappGroupLinkModel.php index cc4415d..30f1a71 100644 --- a/app/Models/WhatsappGroupLinkModel.php +++ b/app/Models/WhatsappGroupLinkModel.php @@ -11,7 +11,6 @@ class WhatsappGroupLinkModel extends Model 'class_section_id', 'class_section_name', 'school_year', - 'semester', 'invite_link', 'active', ]; @@ -22,9 +21,9 @@ class WhatsappGroupLinkModel extends Model * * @param int $sectionId Class/section code (not PK). * @param string $year School year, e.g. "2025-2026". - * @param string $sem Semester, e.g. "Fall". + * @param string $sem Deprecated/ignored; links are scoped by school year. * @param bool $onlyActive If true, require active=1. - * @param bool $allowNullSemester If true, accept rows with semester IS NULL as well. + * @param bool $allowNullSemester Deprecated/ignored. */ public function getLinkForSection( int $sectionId, @@ -40,17 +39,6 @@ class WhatsappGroupLinkModel extends Model ->where('class_section_id', $sectionId) ->where('school_year', $year); - if ($sem !== '') { - if ($allowNullSemester) { - $b = $b->groupStart() - ->where('semester', $sem) - ->orWhere('semester IS NULL', null, false) - ->groupEnd(); - } else { - $b = $b->where('semester', $sem); - } - } - if ($onlyActive) { $b = $b->where('active', 1); } @@ -70,7 +58,7 @@ class WhatsappGroupLinkModel extends Model * @param string $year * @param string $sem * @param bool|null $onlyActive true: active only, false: inactive only, null: both - * @param bool $allowNullSemester If true, include rows with semester IS NULL. + * @param bool $allowNullSemester Deprecated/ignored. */ public function getAllForTerm( string $year, @@ -84,17 +72,6 @@ class WhatsappGroupLinkModel extends Model $b = $this->asArray() ->where('school_year', $year); - if ($sem !== '') { - if ($allowNullSemester) { - $b = $b->groupStart() - ->where('semester', $sem) - ->orWhere('semester IS NULL', null, false) - ->groupEnd(); - } else { - $b = $b->where('semester', $sem); - } - } - if ($onlyActive === true) { $b = $b->where('active', 1); } elseif ($onlyActive === false) { @@ -122,7 +99,6 @@ class WhatsappGroupLinkModel extends Model 'class_section_id' => $sectionId, 'class_section_name' => $sectionName, 'school_year' => trim($year), - 'semester' => trim($sem), 'invite_link' => trim($inviteLink), 'active' => $active ? 1 : 0, ]; @@ -131,7 +107,6 @@ class WhatsappGroupLinkModel extends Model $existing = $this->asArray() ->where('class_section_id', $sectionId) ->where('school_year', $payload['school_year']) - ->where('semester', $payload['semester']) ->first(); if ($existing) { diff --git a/app/Models/WhatsappInviteLogModel.php b/app/Models/WhatsappInviteLogModel.php index 282dc0d..c695a8f 100644 --- a/app/Models/WhatsappInviteLogModel.php +++ b/app/Models/WhatsappInviteLogModel.php @@ -42,7 +42,7 @@ class WhatsappInviteLogModel extends Model /** * Log a successful send. */ - public function logSuccess(int $parentId, string $email, int $classSectionId = null, int $linkId = null): bool + public function logSuccess(int $parentId, string $email, ?int $classSectionId = null, ?int $linkId = null): bool { return (bool) $this->insert([ 'parent_id' => $parentId, @@ -58,7 +58,7 @@ class WhatsappInviteLogModel extends Model /** * Log a failure with error message. */ - public function logFailure(int $parentId, string $email, string $error, int $classSectionId = null, int $linkId = null): bool + public function logFailure(int $parentId, string $email, string $error, ?int $classSectionId = null, ?int $linkId = null): bool { return (bool) $this->insert([ 'parent_id' => $parentId, diff --git a/app/Views/attendance/violations_notified.php b/app/Views/attendance/violations_notified.php index ecee700..8fe261e 100644 --- a/app/Views/attendance/violations_notified.php +++ b/app/Views/attendance/violations_notified.php @@ -262,9 +262,9 @@ $show_actions = false; // notified page hides actions by design // Initialize Bootstrap tooltips (for "View full" note) document.addEventListener('DOMContentLoaded', function () { - if (window.bootstrap) { + if (window.bootstrap && bootstrap.Tooltip) { const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); - tooltipTriggerList.forEach(function (el) { new bootstrap.Tooltip(el); }); + tooltipTriggerList.forEach(function (el) { bootstrap.Tooltip.getOrCreateInstance(el); }); } }); diff --git a/app/Views/layout/main.php b/app/Views/layout/main.php index f716ae0..62f2d44 100644 --- a/app/Views/layout/main.php +++ b/app/Views/layout/main.php @@ -41,8 +41,6 @@ - - diff --git a/app/Views/layout/main_layout.php b/app/Views/layout/main_layout.php index 113453e..915d769 100644 --- a/app/Views/layout/main_layout.php +++ b/app/Views/layout/main_layout.php @@ -250,18 +250,19 @@ html, body { overflow-x: hidden; } + + + - - - diff --git a/app/Views/layout/register_layout.php b/app/Views/layout/register_layout.php index e1e113b..ac95687 100644 --- a/app/Views/layout/register_layout.php +++ b/app/Views/layout/register_layout.php @@ -36,7 +36,6 @@ - @@ -45,4 +44,4 @@ renderSection('scripts') ?> - \ No newline at end of file + diff --git a/app/Views/parent/add_second_parent.php b/app/Views/parent/add_second_parent.php index 4333540..20e3b22 100644 --- a/app/Views/parent/add_second_parent.php +++ b/app/Views/parent/add_second_parent.php @@ -118,11 +118,6 @@
- - - - - -endSection() ?> \ No newline at end of file +endSection() ?> diff --git a/app/Views/teacher/teacher_navbar.php b/app/Views/teacher/teacher_navbar.php index 15e89d2..7699b2b 100644 --- a/app/Views/teacher/teacher_navbar.php +++ b/app/Views/teacher/teacher_navbar.php @@ -101,9 +101,10 @@ diff --git a/school_year_semester_audit.md b/school_year_semester_audit.md new file mode 100644 index 0000000..b60dbe8 --- /dev/null +++ b/school_year_semester_audit.md @@ -0,0 +1,68 @@ +# School Year and Semester Schema Audit + +Source: `scool_view2.sql` +Tables found: 73 + +## Result + +The schema is not fully fixed. + +## Required financial tables + +Confirmed with `school_year`: +- payment_notification_logs +- payment_transactions +- reimbursement_batch_items + +Absent from this dump and therefore not verifiable: +- discount_usages +- event_charges +- invoice_event +- payment_error + +## School-year defects + +- `archived_paypal_transactions.school_year` is `VARCHAR(20)` instead of `VARCHAR(9)`. +- Tables with `school_year` but no direct school-year index: +- archived_paypal_transactions +- class_progress_reports +- exams +- missing_score_overrides +- payments +- placement_batches +- print_requests +- report_card_acknowledgements +- semester_scores +- staff_attendance +- teacher_attendance_data +- whatsapp_group_links + +## Tables where `semester` should be removed + +- badge_print_logs +- classes +- contactus +- emergency_contacts +- inventory_items +- invoice_students_list +- ip_attempts +- notification_recipients +- notifications +- parents +- support_requests +- user_notifications +- whatsapp_group_links + +## Tables requiring business-rule confirmation before removing `semester` + +- archived_paypal_transactions +- discount_vouchers +- payment_transactions +- reimbursement_batch_items +- scan_log + +## Notes + +`semester` should remain on semester-owned academic and operational records such as exams, quizzes, grades, class sections, attendance, semester reports, and semester-specific charges or payments. + +Child tables should not duplicate `semester` when it can be obtained reliably through their parent record, unless immutable historical snapshots are an explicit requirement.