# School-Year-Aware Code Update Plan for CodeIgniter 4 ## Goal Update the CodeIgniter 4 application so school-year filtering is applied only to records that are logically owned by a school year. The application must stop assuming that every table containing a `school_year` column should be filtered by it. Some tables are year-owned, some are global, some contain global identities with year-specific relationship records, and some contain optional school-year context. Required behavior: - Active-year pages display only active-year records. - A selected historical year can be viewed intentionally. - Closed or archived years are read-only unless an explicit administrative operation allows changes. - Authentication, authorization, configuration, identity, and infrastructure tables remain global. - Finance, attendance, exams, events, progress, and reports never mix records from different years. - Missing required school-year context causes a clear error instead of returning unfiltered records. - The backend validates all school-year parameters. Query parameters are input, not truth. --- ## Current Problem A broad database script added or populated `school_year = 2025-2026` across many tables. Some of those tables should never be school-year scoped. Examples of tables that must not be filtered directly by school year: - `users` - `roles` - `permissions` - `role_permissions` - `user_roles` - `settings` - `configuration` - `preferences` - `school_years` - `login_activity` - `ip_attempts` - `password_resets` - `migrations` - `families` - `students` - `parents` - `teachers` - `staff` CodeIgniter models and services must not decide scope based only on whether a `school_year` column exists. A bad migration can add a column. It cannot redefine the business meaning of a table, despite databases occasionally behaving as if they have opinions. --- ## Current Database Assumptions Treat the following as the working database state before application changes: - Valid year-owned records are tagged with `school_year = 2025-2026`. - Optional-context tables keep `school_year` nullable and without a default. - `notifications`, `user_notifications`, and `payment_notification_logs` were verified with no forced school-year value after cleanup. - Some global tables may still physically contain an incorrect `school_year` column until application references are removed and a cleanup migration is executed. Immediate application rule: ```text YEAR_SCOPED: Require validated selected school-year context. GLOBAL: Never add a school_year condition. IDENTITY_WITH_YEAR_RELATION: Load the identity globally and filter participation through an enrollment, assignment, membership, or other year-owned relationship table. CONTEXT: Keep school_year nullable and filter it only in explicitly year-specific views. ``` Do not run another script that stamps all nullable context rows with the active year. --- ## Target Table Classification Create one explicit source of truth for table behavior. ### 1. Year-Owned Tables These tables require school-year filtering for normal reads and writes. Examples: - `classes` - `classSection` - `sections` - `enrollments` - `student_class` - `teacher_class` - `attendance_data` - `attendance_day` - `attendance_record` - `attendance_tracking` - `class_progress_reports` - `exam_drafts` - `exams` - `final_exam` - `final_score` - `semester_scores` - `invoices` - `invoice_installments` - `payments` - `payment_transactions` - `manual_payments` - `expenses` - `refunds` - `calendar_events` - `events` - `parent_notifications` - `parent_meeting_schedules` - `print_requests` - `report_card_acknowledgements` - `whatsapp_group_links` - `whatsapp_group_memberships` CodeIgniter query-builder rule: ```php $builder->where('school_year', $context->yearName()); ``` Long-term preferred rule: ```php $builder->where('school_year_id', $context->id()); ``` ### 2. Global Tables These tables must never receive automatic school-year filtering. Examples: - `users` - `authorized_users` - `roles` - `permissions` - `role_permissions` - `user_roles` - `nav_items` - `role_nav_items` - `parent_accounts` - `settings` - `configuration` - `preferences` - `user_preferences` - `email_templates` - `school_years` - `migrations` - `cache` - `cache_locks` - `sessions` - `personal_access_tokens` - `password_resets` - `password_reset_requests` Rule: ```php // No school_year condition. ``` ### 3. Global Identity Tables With Year-Owned Relationships Identity records are global. Their participation in a school year is year-specific. Global identity examples: - `families` - `parents` - `students` - `teachers` - `staff` - `family_guardians` - `family_students` - `emergency_contacts` - `student_allergies` - `student_medical_conditions` Year-specific relationship examples: ```text enrollments - student_id - school_year_id or school_year - class_id - status ``` ```text family_school_years - family_id - school_year_id or school_year - status - opening_balance ``` ```text teacher_class - teacher_id - class_id - school_year_id or school_year ``` Correct CodeIgniter query pattern: ```php $builder = $db->table('students s'); $builder ->join('enrollments e', 'e.student_id = s.id') ->where('e.school_year', $context->yearName()) ->select('s.*') ->distinct(); ``` Incorrect pattern: ```php $db->table('students') ->where('students.school_year', $context->yearName()); ``` Use the real relationship path defined by the schema. Do not invent a direct join because it looks pleasantly short. ### 4. Nullable Context Tables These tables are global or operational records that may optionally include school-year context. Examples: - `audit_logs` - `communication_logs` - `notifications` - `notification_recipients` - `user_notifications` - `messages` - `support_requests` - `contactus` - `finance_notification_logs` - `payment_notification_logs` Rules: - `school_year` remains nullable. - Normal global views must not automatically hide rows. - Year-specific views may include global rows plus selected-year rows. - Strict school-year reports may include only selected-year rows. - Creation logic must not automatically stamp the active year unless the action is genuinely year-specific. Optional-context query: ```php $builder ->groupStart() ->where('school_year', null) ->orWhere('school_year', $context->yearName()) ->groupEnd(); ``` Strict year-only query: ```php $builder->where('school_year', $context->yearName()); ``` --- ## Phase 1: Add Central Table Metadata Create: ```text app/Support/SchoolYear/SchoolYearTableRegistry.php ``` ```php 'YEAR_SCOPED', self::isGlobal($table) => 'GLOBAL', self::isIdentityWithYearRelation($table) => 'IDENTITY_WITH_YEAR_RELATION', self::isContext($table) => 'CONTEXT', default => throw new \InvalidArgumentException( "Table '{$table}' is not registered for school-year behavior." ), }; } } ``` Every table touched by school-year logic must be classified. An unclassified table should fail review or automated checks rather than quietly inheriting whatever condition happened to be nearby. --- ## Phase 2: Add a School-Year Context Value Object Create: ```text app/Support/SchoolYear/SchoolYearContext.php ``` ```php id; } public function yearName(): string { return $this->yearName; } public function status(): string { return $this->status; } public function isActive(): bool { return $this->status === 'active'; } public function isReadonly(): bool { return in_array($this->status, ['closed', 'archived'], true); } public function isExplicitSelection(): bool { return $this->explicitSelection; } } ``` Do not pass raw strings such as `2025-2026` across controllers and services as the main context object. Raw strings cannot carry status, ID, writability, or validation state. --- ## Phase 3: Add a SchoolYearContextService Create or update: ```text app/Services/SchoolYearContextService.php ``` Responsibilities: 1. Read explicit school year from route placeholders or query parameters. 2. Support `school_year_id` and `school_year` during the transition period. 3. Validate that the requested school year exists. 4. Validate that the authenticated user may access it. 5. Fall back to a session-selected year. 6. Fall back to the active year. 7. Fail clearly if no valid year can be resolved. 8. Return a `SchoolYearContext` object. 9. Never trust the frontend to determine whether a year is writable. Resolution order: ```text 1. Route placeholder 2. Query parameter school_year_id 3. Query parameter school_year 4. Session-selected school year 5. Active school year from school_years 6. Throw a controlled exception if none exists ``` Suggested implementation outline: ```php normalizeInt($request->getGet('school_year_id')); $requestedName = trim((string) $request->getGet('school_year')); if ($requestedId !== null) { $row = $this->schoolYearModel->find($requestedId); if ($row === null) { throw new RuntimeException('Selected school year was not found.'); } return $this->fromRow($row, true); } if ($requestedName !== '') { $row = $this->schoolYearModel ->where('name', $requestedName) ->first(); if ($row === null) { throw new RuntimeException('Selected school year was not found.'); } return $this->fromRow($row, true); } $sessionYearId = session('selected_school_year_id'); if (is_numeric($sessionYearId)) { $row = $this->schoolYearModel->find((int) $sessionYearId); if ($row !== null) { return $this->fromRow($row, false); } } $active = $this->schoolYearModel ->where('status', 'active') ->orderBy('id', 'DESC') ->first(); if ($active === null) { throw new RuntimeException('No active school year is configured.'); } return $this->fromRow($active, false); } private function normalizeInt(mixed $value): ?int { if ($value === null || $value === '' || ! ctype_digit((string) $value)) { return null; } return (int) $value; } private function fromRow(array $row, bool $explicit): SchoolYearContext { return new SchoolYearContext( id: (int) $row['id'], yearName: (string) $row['name'], status: (string) $row['status'], explicitSelection: $explicit, ); } } ``` Register the service in: ```text app/Config/Services.php ``` ```php public static function schoolYearContext(bool $getShared = true) { if ($getShared) { return static::getSharedInstance('schoolYearContext'); } return new \App\Services\SchoolYearContextService( model(\App\Models\SchoolYearModel::class) ); } ``` Use dependency injection where practical. Calling `service()` from every random method is convenient until testing begins and the convenience invoice arrives. --- ## Phase 4: Add a Write Guard Create: ```text app/Services/SchoolYearWriteGuard.php ``` ```php status() === 'active') { return; } if ( $context->status() === 'draft' && $allowDraftForAdmin && $isAdmin ) { return; } throw PageForbiddenException::forPageForbidden( 'The selected school year is read-only.' ); } } ``` Suggested status behavior: ```text active: Writable. closing: Restricted to explicit closing operations. closed: Read-only. archived: Read-only. draft: Admin-only when the operation explicitly permits draft changes. ``` Apply the guard to all writes affecting year-owned tables, including: - invoices - payments - attendance - exams - scores - classes and sections - student placement - teacher assignment - events - report-card acknowledgements - WhatsApp group assignments - carry-forward operations Do not apply it to global user, role, permission, or configuration updates unless those operations modify year-owned data. --- ## Phase 5: Add Reusable Model Methods CodeIgniter 4 does not provide Laravel-style Eloquent scopes or relationships. Do not copy `whereHas()`, traits returning Eloquent builders, or `Model::query()` into CI4 and hope PHP develops telepathy. Use explicit model methods or repository/service query methods. ### Base method for year-owned models Create: ```text app/Models/Concerns/SchoolYearScopedModelTrait.php ``` ```php fieldExists('school_year_id')) { return $this->where( $this->table . '.school_year_id', $schoolYear->id() ); } return $this->where( $this->table . '.school_year', $schoolYear->yearName() ); } if (is_int($schoolYear)) { return $this->where( $this->table . '.school_year_id', $schoolYear ); } return $this->where( $this->table . '.school_year', $schoolYear ); } private function fieldExists(string $field): bool { return in_array($field, $this->db->getFieldNames($this->table), true); } } ``` Important correction: the registry, not `fieldExists()`, decides whether a table is year-scoped. `fieldExists()` may only choose between the transitional columns `school_year_id` and `school_year` after the model has already been classified as year-owned. Prefer a simpler explicit version per model during the transition: ```php public function forSchoolYear(SchoolYearContext $context): self { return $this->where('school_year', $context->yearName()); } ``` This is less magical and easier to audit. ### Optional-context model methods Create methods only on applicable models: ```php public function includeGlobalAndYear( SchoolYearContext $context ): self { return $this ->groupStart() ->where('school_year', null) ->orWhere('school_year', $context->yearName()) ->groupEnd(); } public function onlyForYear( SchoolYearContext $context ): self { return $this->where('school_year', $context->yearName()); } ``` ### Do Not Add School-Year Methods To Global Models Do not add automatic school-year behavior to: - `UserModel` - `RoleModel` - `PermissionModel` - `SettingModel` - `ConfigurationModel` - `SchoolYearModel` - `StudentModel` - `FamilyModel` - `ParentModel` - `TeacherModel` - `StaffModel` Identity models may expose methods that join year-owned relationship tables, but the identity table itself remains global. --- ## Phase 6: Add Repositories or Domain Services for Complex Queries Use dedicated services or repositories for cross-table queries instead of placing joins in controllers. Suggested files: ```text app/Repositories/StudentRepository.php app/Repositories/FamilyRepository.php app/Repositories/TeacherRepository.php app/Repositories/FinanceRepository.php app/Services/Finance/BalanceService.php app/Services/SchoolYearClosingService.php ``` Example student roster query: ```php db->table('students s'); $builder ->select('s.*, e.class_id, e.status AS enrollment_status') ->join('enrollments e', 'e.student_id = s.id') ->where('e.school_year', $context->yearName()); if ($classId !== null) { $builder->where('e.class_id', $classId); } return $builder ->orderBy('s.last_name', 'ASC') ->orderBy('s.first_name', 'ASC') ->get() ->getResultArray(); } } ``` Example global profile with year-owned child data: ```php $family = $familyModel->find($familyId); if ($family === null) { throw PageNotFoundException::forPageNotFound('Family not found.'); } $invoices = $invoiceModel ->where('family_id', $familyId) ->forSchoolYear($context) ->findAll(); ``` --- ## Phase 7: Resolve Context in Controllers Consistently Create a base API controller helper or a small controller trait. Suggested file: ```text app/Controllers/BaseApiController.php ``` ```php resolve( $this->request, $routeSchoolYearId ); } catch (Throwable $e) { log_message('warning', 'School-year resolution failed: {message}', [ 'message' => $e->getMessage(), ]); throw $e; } } } ``` Controller usage: ```php public function index() { $context = $this->resolveSchoolYearContext(); $rows = $this->invoiceModel ->forSchoolYear($context) ->orderBy('id', 'DESC') ->findAll(); return $this->respond([ 'school_year' => [ 'id' => $context->id(), 'name' => $context->yearName(), 'status' => $context->status(), 'readonly' => $context->isReadonly(), ], 'data' => $rows, ]); } ``` For year-owned endpoints, never silently continue without a valid context. --- ## Phase 8: Update Routes Use explicit school-year routes for year-owned resources. In: ```text app/Config/Routes.php ``` Recommended structure: ```php $routes->group('api/v1', ['filter' => 'auth'], static function ($routes) { // Global resources $routes->get('users', 'Api\UserController::index'); $routes->get('roles', 'Api\RoleController::index'); $routes->get('permissions', 'Api\PermissionController::index'); $routes->get('settings', 'Api\SettingController::index'); $routes->get('school-years', 'Api\SchoolYearController::index'); // Current/resolved school year $routes->group('school-years/current', static function ($routes) { $routes->get('classes', 'Api\ClassController::index'); $routes->get('families', 'Api\FamilyYearController::index'); $routes->get( 'reports/closing', 'Api\SchoolYearClosingController::show' ); }); // Explicit selected school year $routes->group( 'school-years/(:num)', static function ($routes) { $routes->get('classes', 'Api\ClassController::index/$1'); $routes->get( 'families', 'Api\FamilyYearController::index/$1' ); $routes->get( 'reports/closing', 'Api\SchoolYearClosingController::show/$1' ); } ); // Global profile with year-specific child endpoints $routes->get('families/(:num)', 'Api\FamilyController::show/$1'); $routes->get( 'school-years/(:num)/families/(:num)/invoices', 'Api\FamilyInvoiceController::index/$1/$2' ); }); ``` Do not put global resources under school-year routes merely because the frontend currently appends `school_year` to every URL. Bad: ```text /api/v1/school-years/4/users ``` Correct: ```text /api/v1/users /api/v1/school-years/4/teacher-assignments ``` Maintain compatibility with current query parameters temporarily, but route all resolution through `SchoolYearContextService`. --- ## Phase 9: Add Request Filters Only for Cross-Cutting Validation A CodeIgniter filter may validate that a route requiring school-year context has one, but it must not mutate every database query globally. Suggested filter: ```text app/Filters/RequireSchoolYearFilter.php ``` Responsibilities: - Resolve and validate school-year context. - Store the resolved context as a request attribute substitute, service state, or controller-accessible object. - Return a controlled `400`, `403`, or `404` response when invalid. - Never add SQL conditions itself. - Never run on global endpoints. Register an alias in: ```text app/Config/Filters.php ``` ```php public array $aliases = [ // ... 'schoolYear' => \App\Filters\RequireSchoolYearFilter::class, ]; ``` Apply it only to year-owned route groups. Do not implement a universal model callback that appends `school_year` to every query. That would recreate the original bug with more ceremony. --- ## Phase 10: Audit and Replace Existing Queries Search the CodeIgniter project: ```bash rg -n "school_year" app tests database rg -n "school_year_id" app tests database rg -n "getGet\(['\"]school_year" app rg -n "getVar\(['\"]school_year" app rg -n "where\(['\"][^'\"]*school_year" app rg -n "db->table|builder\(" app rg -n "allowedFields" app/Models ``` Classify every use: 1. **Year-owned table** Keep the filter, but obtain the value from validated context. 2. **Global table** Remove the filter and remove `school_year` from model `allowedFields`. 3. **Identity table** Replace direct filtering with joins through enrollment or assignment tables. 4. **Optional-context table** Choose global, global-plus-year, or strict-year behavior based on the endpoint. 5. **Raw SQL** Add explicit table aliases and scope only the year-owned table. 6. **Write path** Add the write guard and force the year value from backend context. Bad: ```php $currentYear = $this->request->getGet('school_year'); $users = $this->userModel ->where('school_year', $currentYear) ->findAll(); ``` Correct: ```php $users = $this->userModel ->orderBy('last_name', 'ASC') ->orderBy('first_name', 'ASC') ->findAll(); ``` Bad: ```php $students = $this->studentModel ->where('school_year', $currentYear) ->findAll(); ``` Correct: ```php $context = $this->resolveSchoolYearContext(); $students = $this->studentRepository ->findRosterForYear($context); ``` Bad write: ```php $data = $this->request->getJSON(true); $this->paymentModel->insert($data); ``` Correct write: ```php $context = $this->resolveSchoolYearContext(); service('schoolYearWriteGuard')->assertWritable($context); $data = $this->request->getJSON(true) ?? []; $data['school_year'] = $context->yearName(); $data['school_year_id'] = $context->id(); unset($data['requested_school_year'], $data['is_readonly']); $this->paymentModel->insert($data); ``` The client must not be allowed to write into one year while displaying another. --- ## Phase 11: Update Model Definitions Review every CodeIgniter model. For year-owned models: - Include `school_year` and, during transition, `school_year_id` in `$allowedFields`. - Add validation rules where required. - Add `forSchoolYear()` or equivalent explicit methods. - Do not automatically default to the active year inside `beforeInsert` unless every creation path is guaranteed to be year-specific. - Prefer services/controllers to set the validated year explicitly. Example: ```php 'required|is_natural_no_zero', 'amount' => 'required|decimal', 'school_year' => 'required|max_length[20]', ]; } ``` For global models: - Remove `school_year` and `school_year_id` from `$allowedFields`. - Remove school-year validation rules. - Remove `beforeFind`, `beforeInsert`, or `beforeUpdate` callbacks that add the active year. - Remove constructor logic reading `configuration.semester` or `configuration.school_year` unless the model genuinely owns year-specific records. For optional-context models: - Keep `school_year` nullable. - Do not mark it `required`. - Populate it only when the action has explicit school-year meaning. --- ## Phase 12: Update Finance First Finance is the highest-risk area. Audit: - family and parent balances - invoice listings - invoice installment calculations - payment listings - payment allocation - manual payment creation - reimbursements - refunds - discounts and discount usage - event charges - carry-forward calculations - closing previews - closing reports - opening balances - transaction and notification logs Rules: 1. Current-year balances use only current-year financial records. 2. Historical debt does not appear as current-year overdue debt unless an explicit carry-forward transaction exists. 3. Carry-forward creates a target-year record. It must not mutate historical invoices. 4. Closing previews use the selected year, not whatever year is globally active. 5. Every source table in an aggregate query must be reviewed separately. 6. Payment transactions and invoice-event links must inherit or explicitly store the same school year as their parent record. 7. Optional logs may remain null and must not be treated as financial ownership records. Example balance service: ```php public function calculateFamilyBalance( int $familyId, SchoolYearContext $context ): array { $invoiceTotal = (float) $this->db ->table('invoices') ->selectSum('total_amount', 'total') ->where('family_id', $familyId) ->where('school_year', $context->yearName()) ->get() ->getRow('total'); $paymentTotal = (float) $this->db ->table('payments') ->selectSum('paid_amount', 'total') ->where('family_id', $familyId) ->where('school_year', $context->yearName()) ->get() ->getRow('total'); return [ 'invoice_total' => $invoiceTotal, 'payment_total' => $paymentTotal, 'balance' => $invoiceTotal - $paymentTotal, ]; } ``` Do not compute a balance by summing all historical invoices and payments for a family. That is not an accounting shortcut. It is data contamination. Use database transactions for closing and carry-forward operations: ```php $this->db->transException(true)->transStart(); try { // Lock/check source year. // Create target-year carry-forward records. // Mark closing batch. // Write audit records. $this->db->transComplete(); } catch (\Throwable $e) { log_message('error', 'School-year closing failed: {message}', [ 'message' => $e->getMessage(), ]); throw $e; } ``` --- ## Phase 13: Update Academic Queries Audit: - classes - sections - class-section mappings - enrollments - student placement - teacher assignments - attendance - progress reports - exam drafts - exams - semester scores - final scores - promotion - report cards Rules: - `students` and `teachers` remain global. - `classes`, `sections`, assignments, and enrollments are year-owned. - Active-year rosters come from enrollment tables. - Teacher pages derive students from year-owned assignments and enrollments. - Reports always use the selected school year. - Semester filtering is applied only to semester-owned academic records, not global identity or annual records. Example: ```php $builder = $this->db->table('classes c'); $classes = $builder ->select('c.*, COUNT(DISTINCT e.student_id) AS student_count') ->join( 'enrollments e', 'e.class_id = c.id AND e.school_year = c.school_year', 'left' ) ->where('c.school_year', $context->yearName()) ->groupBy('c.id') ->orderBy('c.name', 'ASC') ->get() ->getResultArray(); ``` When both tables are year-owned, join on identifiers and ensure their school-year values agree. --- ## Phase 14: Update Identity and Profile Pages Audit: - parent profiles - family management - student profiles - teacher profiles - staff pages Rules: 1. Profile identity loads by global primary key. 2. Year-specific tabs use the selected context. 3. Historical profile activity remains accessible. 4. A person is not hidden globally merely because they lack an enrollment for the selected year. 5. Roster pages may show only identities participating in the selected year. 6. Profile pages and roster pages are different use cases. Do not force them through one misleading query. Example: ```php $parent = $this->parentModel->find($parentId); if ($parent === null) { return $this->failNotFound('Parent not found.'); } $invoices = $this->invoiceModel ->where('parent_id', $parentId) ->forSchoolYear($context) ->findAll(); return $this->respond([ 'parent' => $parent, 'school_year' => $context->yearName(), 'invoices' => $invoices, ]); ``` --- ## Phase 15: Remove School-Year Logic From Global Features Remove year filtering from: - login - token validation - password reset - sessions - user management - role and permission checks - navigation/menu construction - settings - configuration - global email templates - cache - migrations - system health endpoints Bad: ```php $this->permissionModel ->where('school_year', $context->yearName()) ->findAll(); ``` Correct: ```php $this->permissionModel ->orderBy('name', 'ASC') ->findAll(); ``` If a setting truly varies by year, create a separate table: ```text school_year_settings - id - school_year_id - config_key - config_value ``` Do not turn `configuration` into a table where some rows are global and some are secretly annual without an explicit design. --- ## Phase 16: Update Frontend Integration The backend remains authoritative, but the frontend should use a centralized school-year state. Required frontend state: ```text activeSchoolYear selectedSchoolYear selectedSchoolYearId selectedSchoolYearName selectedSchoolYearStatus isReadonly ``` Frontend rules: 1. Year-owned pages send the selected school-year ID. 2. Global pages do not depend on school-year parameters. 3. Navigation preserves the selected year only where useful. 4. Mutation buttons are disabled for read-only years. 5. The API still enforces writability even when buttons are disabled. 6. Dropdown lists use alphabetical ordering where applicable. 7. Tables support sorting consistently. 8. A selected year in the URL must match the resolved year returned by the API. Recommended API usage: ```ts api.get(`/api/v1/school-years/${selectedSchoolYearId}/classes`); api.get(`/api/v1/school-years/${selectedSchoolYearId}/reports/closing`); api.get('/api/v1/users'); api.get('/api/v1/roles'); api.get('/api/v1/settings'); api.get(`/api/v1/families/${familyId}`); api.get( `/api/v1/school-years/${selectedSchoolYearId}/families/${familyId}/invoices` ); ``` During migration, existing endpoints using: ```text ?school_year_id=4&school_year=2025-2026 ``` may remain, but the backend must reject conflicting values rather than choosing whichever one happens to be read first. --- ## Phase 17: Add CodeIgniter Tests Use CodeIgniter 4 testing utilities and PHPUnit. Suggested test locations: ```text tests/unit/Support/SchoolYear/ tests/unit/Services/ tests/database/Models/ tests/feature/Api/ ``` Run: ```bash php spark test ``` or: ```bash vendor/bin/phpunit ``` ### Unit Tests Test: - `SchoolYearTableRegistry::categoryOf()` - context resolution precedence - invalid ID handling - invalid year-name handling - active-year fallback - read-only status handling - write-guard behavior ### Database Tests Use: ```php use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; ``` Test year-owned models: - classes return only selected-year rows - attendance returns only selected-year rows - invoices return only selected-year rows - payments return only selected-year rows - progress reports return only selected-year rows Test global models: - users load regardless of selected year - roles load regardless of selected year - permissions load regardless of selected year - configuration loads regardless of selected year - all school years remain visible Test identity behavior: - student profile loads globally - student roster changes by selected year - family profile loads globally - family balance changes by selected year - teacher profile loads globally - teacher assignments change by selected year Test context tables: - null-context logs remain visible in global views - selected-year views include the intended rows - strict reports exclude unrelated and null-context rows - notification rows are not automatically stamped ### Feature/API Tests Use `FeatureTestTrait`. Verify: - missing required context returns an error - invalid school-year ID returns `404` or validation failure - unauthorized historical access returns `403` - closed-year writes return `403` - active-year writes succeed - global endpoints work without school-year parameters - conflicting `school_year_id` and `school_year` values are rejected - endpoint response includes resolved school-year metadata ### Finance Regression Tests At minimum: - old-year invoice does not affect current-year balance - old-year payment does not affect current-year balance - carry-forward creates exactly one target-year record - rerunning carry-forward is idempotent or blocked - closing preview and closing execution produce matching totals - rollback occurs when any closing step fails --- ## Phase 18: Add Static and Runtime Safety Checks Add a CI script that searches for suspicious global-table filters. Suggested script: ```text scripts/check-school-year-filters.php ``` It should flag patterns such as: ```text UserModel ... where('school_year' RoleModel ... where('school_year' PermissionModel ... where('school_year' ConfigurationModel ... where('school_year' students.school_year families.school_year parents.school_year teachers.school_year staff.school_year ``` Also verify: - all year-owned models contain explicit year filtering methods - global models do not contain school-year callbacks - every year-owned insert path sets a backend-resolved school year - every year-owned mutation invokes the write guard This check will not prove correctness, but it catches predictable regressions before they become production archaeology. --- ## Phase 19: Database Cleanup Migration After application code no longer references incorrect columns, create a CodeIgniter migration. Generate it: ```bash php spark make:migration RemoveInvalidSchoolYearColumns ``` Suggested migration principles: 1. Check the table and field before altering it. 2. Drop indexes or foreign keys first where necessary. 3. Remove only columns confirmed to be invalid. 4. Do not remove nullable context columns. 5. Keep the `down()` method conservative. Recreating a column does not restore deleted values. Candidate invalid columns: ```text school_years.school_year users.school_year authorized_users.school_year roles.school_year permissions.school_year role_permissions.school_year user_roles.school_year nav_items.school_year role_nav_items.school_year settings.school_year configuration.school_year preferences.school_year user_preferences.school_year email_templates.school_year attendance_comment_template.school_year admin_notification_subjects.school_year login_activity.school_year ip_attempts.school_year password_resets.school_year password_reset_requests.school_year migrations.school_year families.school_year parents.school_year students.school_year teachers.school_year staff.school_year family_guardians.school_year family_students.school_year emergency_contacts.school_year student_allergies.school_year student_medical_conditions.school_year inventory_categories.school_year inventory_items.school_year suppliers.school_year supplies.school_year supply_categories.school_year questiontypes.school_year ``` Migration outline: ```php targets as $table) { if ( $this->db->tableExists($table) && $this->db->fieldExists('school_year', $table) ) { $this->forge->dropColumn($table, 'school_year'); } } } public function down() { foreach ($this->targets as $table) { if ( $this->db->tableExists($table) && ! $this->db->fieldExists('school_year', $table) ) { $this->forge->addColumn($table, [ 'school_year' => [ 'type' => 'VARCHAR', 'constraint' => 20, 'null' => true, ], ]); } } } } ``` Before running: ```bash php spark migrate:status mysqldump --no-data school_view > school_view_schema_before_cleanup.sql mysqldump school_view > school_view_backup_before_cleanup.sql php spark migrate php spark test ``` Cleanup order: 1. Audit code references. 2. Remove invalid model fields and filters. 3. Deploy application changes. 4. Back up the database. 5. Run migration. 6. Run automated tests. 7. Run API smoke tests. 8. Review application logs. Dropping columns before removing application references is merely debugging with extra property damage. --- ## Phase 20: Migrate From Text to `school_year_id` The final design should use foreign keys. Migration sequence: 1. Add nullable `school_year_id` to year-owned tables. 2. Backfill it from `school_year`. 3. Add indexes. 4. Update models to allow both fields temporarily. 5. Update writes to set both fields. 6. Change reads to use `school_year_id`. 7. Validate that every year-owned record has a valid ID. 8. Add foreign keys. 9. Make `school_year_id` non-null where ownership is mandatory. 10. Remove text `school_year` only after all application and reporting code uses IDs. Generate migrations: ```bash php spark make:migration AddSchoolYearIdToYearOwnedTables php spark make:migration BackfillSchoolYearIds php spark make:migration EnforceSchoolYearForeignKeys ``` Backfill example: ```php $this->db->query( 'UPDATE invoices i JOIN school_years sy ON sy.name = i.school_year SET i.school_year_id = sy.id WHERE i.school_year_id IS NULL' ); ``` Index example: ```php $this->forge->addKey('school_year_id'); ``` Foreign key example: ```php $this->forge->addForeignKey( 'school_year_id', 'school_years', 'id', 'RESTRICT', 'CASCADE' ); ``` Use `RESTRICT` for deletion unless there is a deliberate archival strategy. Cascading deletion of a school year through finance and academic history would be an impressively efficient catastrophe. --- ## Implementation Order 1. Confirm the table classification against the actual schema. 2. Add `SchoolYearTableRegistry`. 3. Add `SchoolYearContext`. 4. Add `SchoolYearContextService`. 5. Register the service in `Config\Services`. 6. Add `SchoolYearWriteGuard`. 7. Add explicit year-scoping methods to year-owned models. 8. Remove school-year behavior from global models. 9. Add repositories/services for identity relationship queries. 10. Update finance queries. 11. Update school-year closing preview and execution. 12. Update academic and attendance queries. 13. Update parent, family, student, teacher, and staff pages. 14. Update API routes and controller context resolution. 15. Update frontend school-year state and endpoint usage. 16. Add unit, database, feature, and regression tests. 17. Add static checks for prohibited filters. 18. Deploy code changes. 19. Back up the database. 20. Remove invalid columns through a CI4 migration. 21. Begin the `school_year_id` foreign-key migration. --- ## Acceptance Criteria The implementation is complete only when: - No global table is filtered by `school_year`. - No identity table is directly filtered by `school_year`. - Every year-owned read uses validated selected-year context. - Every year-owned write forces the year from backend context. - Every year-owned write validates that the selected year is writable. - Finance totals never mix years unless using an explicit carry-forward record. - Closed and archived years reject normal mutations. - Context tables remain nullable and are not automatically stamped. - Global authentication and configuration work regardless of selected year. - Profile identity remains accessible across years. - Rosters and assignments change correctly with selected year. - APIs return resolved year metadata. - Conflicting year parameters are rejected. - Tests prove that historical records do not leak into active-year pages. - Tests prove that global pages do not break when the selected year changes. - No required year-owned endpoint silently returns all rows when context is absent. - All dropdown lists are alphabetically ordered where business ordering does not override it. - All data tables provide consistent sorting. --- ## Non-Negotiable Rule Never decide whether to apply school-year filtering merely by checking whether a column exists. Column existence describes the current schema. The table registry and domain rules define the intended behavior.