# Global School-Year Selector and Historical Read-Only Mode — Reviewed Implementation Plan v2 ## Purpose Add a global school-year selector to the authenticated CodeIgniter 4 application so a user can: 1. See the active school year by default. 2. Select another available school year. 3. View data belonging to the selected school year across all school-year-aware pages. 4. Clearly see **READ-ONLY** when a non-active school year is selected. 5. Never create, update, delete, approve, publish, charge, pay, enroll, assign, or otherwise mutate school-year-owned business data while a non-active school year is selected. This is not merely a view change. The selected year must control school-year-aware database reads, AJAX/API requests, exports, print views, and year-owned write endpoints. A decorative dropdown that does not change the query scope would be impressively useless. --- ## Current Project Audit The project already contains part of the required foundation: - `app/Support/SchoolYear/SchoolYearContext.php` - `app/Services/SchoolYearContextService.php` - `app/Services/SchoolYearWriteGuard.php` - `app/Models/Concerns/SchoolYearScopedModelTrait.php` - `app/Filters/RequireSchoolYearFilter.php` - `app/Models/SchoolYearModel.php` - school-year lifecycle management under `app/Controllers/Administrator/SchoolYearController.php` - an existing page-level filter at `app/Views/partials/academic_filter.php` However, the current implementation is incomplete and inconsistent: - The application has approximately **372 PHP view files**. - Approximately **229 application views** use one of the shared layouts: - `layout/management_layout` - `layout/main_layout` - `layout/main` - Approximately **33 views** directly include `partials/academic_filter.php`, which would create duplicate school-year selectors after a global selector is added. - Approximately **57 controllers** still read the school year directly from session or configuration instead of resolving `SchoolYearContext`. - Approximately **63 models** reference `school_year` or `school_year_id`, but the scoped-model trait is not currently used by those models. - `RequireSchoolYearFilter` exists as an alias but is not broadly applied to the relevant route groups. - There are no dedicated automated tests covering school-year selection, scoping, or historical write protection. The implementation must consolidate these competing patterns rather than adding a third one. ### Additional findings from the review The first plan was directionally correct, but the project audit exposed several implementation blockers that must be resolved before the selector is released: - `BaseController::syncActiveSchoolYearSession()` currently removes `selected_school_year_id` whenever the configured active year differs from the session. That method would erase a historical selection on the next request and must be removed or replaced. - `AuthController`, `SchoolYearManagementService`, and `SchoolYearClosingService` still write the active year name into session. The active configuration value may remain a temporary legacy mirror, but it cannot remain a request-time source of truth. - Approximately **30 views contain their own school-year `` - all allowed options - current request URI in a hidden `return_to` field - auto-submit on change or a clear Apply button - active status label - `READ-ONLY` button-style label when non-active - accessible label and keyboard behavior Suggested markup: ```php
getUri()->getQuery(); ?> isReadonly()): ?> READ-ONLY ACTIVE
``` Preserve relevant existing query parameters when redirecting, but remove stale `school_year_id`, `school_year`, `schoolYear`, and legacy `year` parameters so the session selection is not immediately overridden by an old URL. ### 7. Insert the partial into shared layouts Update: - `app/Views/layout/management_layout.php` - `app/Views/layout/main_layout.php` - `app/Views/layout/main.php` Place the selector immediately below the application navbar and above page content. Do not add it to: - `layout/register_layout.php` - `layout/email_layout.php` - PDFs - print-only layouts - public pages - fragments/modals/partials This covers most UI pages without editing hundreds of files individually. ### 8. Remove duplicate page-level school-year selectors Refactor: `app/Views/partials/academic_filter.php` The partial is currently used by approximately 33 views and combines school year with semester. After the global selector is introduced: - remove the school-year control from this partial, or - add a parameter such as `$showSchoolYearControl = false` and default it to false. Retain page-specific controls such as semester and class section. All affected controllers must use the global `SchoolYearContext` instead of trusting `$_GET['school_year']` from the old partial. ### 9. Migrate all custom selectors and shared navigation queries `partials/academic_filter.php` is only one source of duplication. The audit found approximately 30 views with a school-year ` ``` Before a write, verify that: 1. the submitted context ID matches the currently resolved context; 2. the context is active; 3. the target record belongs to that context. Reject mismatches with HTTP 409 and ask the user to reload. This prevents a stale form in one tab from writing after another tab changes the global selection. ### Redirects and links Because selection persists in session, ordinary internal links do not need to append the school year. For shareable reports or exports, explicit `school_year_id` may be included in the URL and must take precedence over session. --- ## Model Migration Contract Apply `SchoolYearScopedModelTrait` to models that directly own a school-year column. Example: ```php use App\Models\Concerns\SchoolYearScopedModelTrait; class AttendanceRecordModel extends Model { use SchoolYearScopedModelTrait; } ``` Do not apply the trait blindly to models that only mention a school year in joined tables or helper methods. Confirm the model’s base table owns either: - `school_year_id`, or - `school_year` Prefer `school_year_id` for new schema work. Continue supporting name-based legacy tables during migration. Do not perform database schema introspection inside every `forSchoolYear()` call. Each scoped model should declare its year column explicitly, for example: ```php protected string $schoolYearColumn = 'school_year'; ``` The trait should fail loudly when no declared year column exists. Silent guessing or filtering a nonexistent `school_year_id` column turns a safety mechanism into theater. ### Query rule Every query against a year-owned table must include one of: ```sql WHERE school_year_id = :selected_id: ``` or: ```sql WHERE school_year = :selected_name: ``` No school-year-aware list query may rely on the absence of duplicate rows across years. ### Join rule When joining multiple year-owned tables, scope every relevant table or ensure the join keys and source table guarantee the same year. Do not filter one table and assume the others follow politely. Example: ```php $builder ->where('student_class.school_year', $year) ->where('teacher_class.school_year', $year); ``` --- ## Services, Libraries, Listeners, CLI, and Scheduled Work The context migration extends beyond controllers and models. Audit and update: - `GlobalConfigHelper::getSchoolYear()` - `FeeCalculationService` - `SemesterScoreService` - tuition forecast services - attendance tracking defaults - `SchoolEventListener` - all report, notification, email, and calculation services that read active configuration - CLI commands such as exam-draft reminders and payment notifications Rules: - Browser-driven services receive `SchoolYearContext` explicitly from the controller or request-scoped service. - Event payloads include `school_year_id` or immutable year name. Listeners must not re-read the current active configuration and assume it matches the event. - CLI commands require `--school-year-id` when operating on a historical year. Active-year jobs may explicitly resolve the active row, but never use session state. - Queued or deferred jobs must persist the year ID in the job payload. A job executed after the active year changes must still operate on the originally requested year. - Scheduled jobs that should only affect the active year must assert that policy before writing. ## Data-Integrity Preflight and Historical Accuracy Before enabling the selector, run a read-only audit over every year-owned table: - null or empty school-year values - values not present in `school_years` - malformed year strings - duplicate business keys that omit school year - joins whose related rows belong to different years - missing indexes on year filters - rows whose date falls outside the declared school-year range, where that rule applies Do not silently backfill unknown rows into the active year. Produce an exception report and resolve or quarantine ambiguous records. The team must also decide what “historical data” means for master data that can change. A historical grade joined to a currently renamed class, section, student, fee rule, or event may not reproduce what users saw at the time. For legally or operationally significant reports, use effective-dated master data or snapshots rather than live mutable joins. ## AJAX and API Requests All page AJAX requests must inherit the current context. Preferred approach: - session selection for authenticated same-origin browser requests - explicit `school_year_id` for report endpoints, exports, and requests that may be opened independently - an explicit `X-School-Year-ID` header or documented query parameter for JWT APIs that do not use browser sessions The resolver must not treat a posted business-data field as the authoritative context. API context belongs in the route, query, or dedicated header; the business payload is then forced to that resolved context. Apply `schoolYear` validation to relevant API/AJAX route groups. JSON school-year write endpoints must return HTTP `409 Conflict` for historical context: ```json { "status": 409, "error": "School year is read-only", "school_year": "2024-2025" } ``` Do not return a successful response with no mutation. Silent failure is not protection; it is future debugging folklore. --- ## Caching, Browser History, and Generated Artifacts Any server-side or client-side cache containing school-year data must include the selected year ID and user/permission scope in its cache key. For school-year-aware HTML responses: - send appropriate HTTP `Cache-Control` headers, not merely `` tags; - test browser back/forward cache behavior after switching years; - ensure DataTables, AJAX caches, local storage, and saved filters do not reuse active-year responses in a historical context. Exports and generated files must include the selected year in their filename and metadata. Historical generation must not overwrite an existing artifact for another year. When generation writes an audit log, that log is an allowed operational side effect; when generation updates a year-owned business record or replaces an official historical artifact, it requires explicit policy and normally remains blocked. ## Observability and Audit Log at least: - year selection and reset events - invalid, conflicting, or unauthorized year requests - blocked historical write attempts, including user, route, method, selected year, and target record type/ID when safe - active-year integrity failures - uses of legacy configuration/session/request fallbacks during the migration period Add a temporary migration metric showing how many requests still read the active year from legacy sources. The feature is not done while that count remains nonzero on school-year-aware routes. ## UI Read-Only Rules Create a reusable helper or partial for action availability instead of scattering status comparisons everywhere. ### Required UI behavior in historical mode Disable or omit: - Create/Add/New buttons - Edit buttons - Delete/Archive row actions unrelated to school-year lifecycle - Save/Submit/Approve/Publish buttons - payment collection and refund actions - student enrollment/withdrawal mutation - class and teacher assignment mutation - attendance entry or correction - score entry, grading, and finalization - imports that write records - bulk actions - drag-and-drop mutations - inline editable controls Keep available: - View/details - filtering/search - pagination - sorting - export - print - PDF generation - audit history Use actual disabled form controls where possible, but continue enforcing the server guard. ### Historical banner In addition to the compact navbar label, display a page-width warning on application content pages: ```html ``` The warning should be rendered once by the shared layout, not copied into every view. --- ## Exemptions The global selector and read-only banner must not appear in: - public landing pages - login/logout pages - account activation and password reset - registration flow before authentication - email templates - PDFs and print fragments unless the selected year must be printed as report metadata - CLI and framework error pages - Swagger/API documentation - partial views and modal fragments rendered inside another page The selector may appear on global authenticated pages, but those pages must not claim their data is filtered when it is not. --- ## Implementation Phases ### Phase 0: Inventory, policy, and data preflight - [ ] Produce a route/action inventory with method, controller, queried tables, policy classification, migration status, and test status. - [ ] Inventory custom school-year selectors and hidden form fields. - [ ] Audit controllers, models, services, libraries, listeners, commands, helpers, navbars, and layouts. - [ ] Run the data-integrity preflight and resolve ambiguous/null year data. - [ ] Decide accessible-year policy by role/permission. - [ ] Decide historical snapshot requirements for mutable master data. - [ ] Add a feature flag; keep the global selector hidden in production. ### Phase 1: Source of truth and lifecycle integrity - [ ] Remove `BaseController::syncActiveSchoolYearSession()`. - [ ] Make `school_years` authoritative and configuration a compatibility mirror only. - [ ] Prevent renaming in-use/non-draft years. - [ ] Enforce exactly one active year with service checks, locking, and a database guard where supported. - [ ] Add domain-specific exceptions and consistent HTML/JSON responses. - [ ] Clear explicit selection on login/account change and revalidate on role switch. ### Phase 2: Context, selection, and dependent state - [ ] Change global historical determination to non-active = domain-read-only. - [ ] Preserve separate lifecycle metadata-lock semantics. - [ ] Allow valid authorized non-active session selections. - [ ] Add selectable-year listing through an access policy. - [ ] Add selection and reset methods. - [ ] Selecting active clears the explicit override. - [ ] Add safe redirect validation and strip all legacy year query aliases. - [ ] Clear or revalidate class, section, student, and semester session state. - [ ] Add stale-form context tokens. ### Phase 3: Read scoping before public UI Migrate high-risk domains first: 1. financial, invoice, payment, refund, and reimbursement 2. enrollment, promotion, student-class, and teacher-class assignment 3. attendance, late slips, and dismissals 4. grading, scores, decisions, report cards, and certificates 5. events, charges, calendar, and communications 6. inventory, class preparation, progress, and dashboards For every action: - [ ] classify it as global/year-read/year-write/operational/lifecycle - [ ] resolve and authorize context - [ ] replace direct configuration/session year reads - [ ] scope base queries, joins, subqueries, unions, counts, and totals - [ ] scope exports, PDFs, print views, dashboard widgets, and DataTables count queries - [ ] scope AJAX child endpoints - [ ] migrate services, listeners, helpers, and calculations used by the action - [ ] add isolation tests before marking the action complete ### Phase 4: Write protection and route hygiene - [ ] Add `SchoolYearWritableFilter` to known year-owned mutation routes. - [ ] Add `assertSchoolYearWritable()` at controller and domain-service boundaries. - [ ] Verify submitted context token and target-record ownership. - [ ] Force inserted year fields from context. - [ ] Reject posted mismatched year fields. - [ ] Convert legacy GET mutations to POST/DELETE with CSRF. - [ ] Guard bulk actions, imports, background-trigger endpoints, listeners, and commands. - [ ] Define allowed operational side effects and block historical artifact replacement. ### Phase 5: Shared UI and custom-selector migration - [ ] Inject request-scoped shared context data. - [ ] Create selector and read-only banner partials. - [ ] Update all shared layouts, navbars, counters, and class switchers. - [ ] Remove or repurpose all duplicate school-year selectors, not only `academic_filter.php`. - [ ] Disable/hide year-owned write actions while preserving permission checks. - [ ] Add global JavaScript context metadata for AJAX consumers. - [ ] Verify mobile and accessibility behavior. The global feature flag remains off during this phase. ### Phase 6: Caching, observability, and compatibility removal - [ ] Vary caches by year and authorization scope. - [ ] Add HTTP cache headers and browser-history tests. - [ ] Log selections, invalid requests, blocked writes, and active-year integrity failures. - [ ] Add metrics for remaining legacy year-source usage. - [ ] Migrate `schoolYear`, `school_year`, `year`, and session-name compatibility paths. - [ ] Remove the legacy `selected_school_year` name key and direct config/session reads. ### Phase 7: Release and regression review - [ ] Run the full required test matrix. - [ ] Verify every inventoried school-year route is migrated or explicitly global. - [ ] Enable the selector in a staging environment. - [ ] Perform cross-tab, back-button, role-switch, and stale-form testing. - [ ] Enable production access by domain or role only after coverage is complete. - [ ] Monitor blocked-write and legacy-fallback metrics. - [ ] Remove the feature flag only after the selector cannot misrepresent query scope. ## Required Test Matrix ### Context resolution | Case | Expected result | |---|---| | No request/session selection | Active year | | Valid active session selection | Active year, writable | | Valid closed session selection | Closed year, read-only | | Valid archived request ID | Archived year, read-only | | Invalid request ID | 400 validation response | | Conflicting ID and name | 400 validation response | | Deleted session year | Clear session and use active year | | No active year and no valid selection | Clear configuration error | ### Read scoping Create fixtures with identical student/class/parent relationships in two years and verify: - active selection returns only active-year rows - historical selection returns only historical rows - totals and counts change with selection - pagination does not mix years - exports/PDFs use the selected year - dashboard widgets use the selected year or are explicitly marked global ### Write protection For each high-risk domain, test: - active year write succeeds with permission - closed year write fails - archived year write fails - closing year write fails - draft global-context write fails - manually posted `school_year=active` while historical context is selected still fails - active context cannot update a historical record by ID ### Session and concurrency Verify: - selecting the active year clears the explicit override - activating a new year automatically changes the default for users without an override - switching year clears or revalidates class and semester state - switching roles invalidates unauthorized draft selections - a stale write form opened in another tab fails with 409 after context changes - browser back/forward navigation does not display a mismatched year and dataset ### Non-HTTP execution Verify: - CLI commands require or deliberately resolve a year - queued/deferred work preserves the original year ID after active-year transition - listeners use the event's year rather than current configuration - scheduled active-only jobs reject historical writes ### Data integrity Verify: - multiple active rows cause a hard integrity failure - an in-use school-year name cannot be changed - unknown/null legacy year rows are reported rather than silently assigned - compound uniqueness prevents duplicates across and within years as intended - cache keys prevent cross-year response reuse ### UI Verify: - selector appears once, not twice - active year is default - selected historical year persists across pages - `READ-ONLY` label and warning appear for non-active years - action buttons disappear or disable - view/export/print actions remain available - mobile navbar remains usable --- ## Acceptance Criteria The feature is complete only when all of the following are true: 1. Every authenticated application layout displays one global school-year selector. 2. The active year is selected by default. 3. A user can select an allowed historical year and the selection persists across navigation. 4. Every school-year-aware page reads data only from the selected year. 5. Global pages remain global and do not pretend to be year-filtered. 6. A non-active year displays a button-style **READ-ONLY** label and a warning banner. 7. No school-year-owned record in a non-active year can be mutated through HTML, AJAX, API, direct URL, forged POST data, record-ID substitution, CLI, listeners, or scheduled work. 8. Existing page-level school-year dropdowns are removed or converted to non-year filters. 9. Exports, PDFs, print views, totals, and dashboard metrics use the same selected context. 10. Automated tests cover resolution, persistence, isolation, and write rejection. 11. School-year lifecycle administration continues to work through its dedicated controller and is not blocked by the global read-only UI. 12. No query silently falls back to unscoped data when context resolution fails. 13. Selecting the active year clears the session override, so future activation changes the default automatically. 14. Changing year cannot reuse an invalid class, section, student, or semester selection. 15. Global writes and allowed operational logs remain governed by their own permissions and are not accidentally blocked. 16. School-year-owned writes are blocked from HTML, AJAX, API, CLI, listeners, scheduled jobs, stale tabs, and forged IDs. 17. Exactly one active year is enforced and an in-use year name is immutable. 18. No navbar, dashboard counter, class switcher, export, or generated report uses a different year from the visible context. 19. All custom school-year selectors are removed, intentionally retained for comparison/reporting, or documented as lifecycle controls. 20. Legacy configuration/session/request sources are removed from school-year-aware execution paths. 21. The global selector is not released until all exposed routes are scoped and tested. --- ## Definition of Done Checklist by File ### Existing files to modify - `app/Support/SchoolYear/SchoolYearContext.php` - `app/Services/SchoolYearContextService.php` - `app/Services/SchoolYearWriteGuard.php` - `app/Controllers/BaseController.php` - `app/Config/Services.php` - `app/Config/Routes.php` - `app/Config/Filters.php` - `app/Views/layout/management_layout.php` - `app/Views/layout/main_layout.php` - `app/Views/layout/main.php` - `app/Views/partials/academic_filter.php` - `app/Views/partials/navbar.php` - `app/Views/teacher/teacher_navbar.php` - `app/Helpers/GlobalConfigHelper.php` - `app/Controllers/AuthController.php` - school-year-aware services, libraries, listeners, and commands - all custom school-year selector views - all school-year-aware controllers - all models that directly own a school-year column - migrations enforcing active-year/name integrity and required indexes/constraints ### New files recommended - `app/Controllers/View/SchoolYearSelectionController.php` - `app/Filters/SchoolYearWritableFilter.php` - `app/Policies/SchoolYearAccessPolicy.php` - school-year domain exception classes - `app/Services/SchoolYearViewDataService.php` - `app/Views/partials/school_year_selector.php` - `app/Views/partials/school_year_readonly_banner.php` - `tests/Unit/SchoolYearContextTest.php` - `tests/Unit/SchoolYearContextServiceTest.php` - `tests/Feature/SchoolYearSelectionTest.php` - `tests/Feature/HistoricalSchoolYearWriteGuardTest.php` - domain-specific school-year isolation feature tests --- ## Final Engineering Rule There must be exactly one authoritative selected school-year context per request. The following are not independent sources of truth and must not compete: - configuration value - session `school_year` - session `selected_school_year` - GET `school_year` - GET `school_year_id` - POST `school_year` - controller defaults - model defaults All of them must resolve into one validated `SchoolYearContext`, and every school-year-aware read or write must use that object. Anything less will eventually display one year in the dropdown while querying another year in the database, which is the sort of bug humans call “intermittent” when they mean “we built several sources of truth.” ## Review Gate Before Coding Coding should not begin with the dropdown. The first deliverable is the route/data inventory and the removal of the session-sync behavior that currently destroys historical selection. The second is a working context and write guard proven on one high-risk vertical, preferably invoices or attendance. Only then should the shared layouts expose the selector. A convincing demo is not “the dropdown changes and one list refreshes.” A convincing demo is: the year changes, every visible count and child request changes with it, historical writes fail through every path, stale tabs are rejected, and returning to active restores normal writes without carrying invalid class or semester state.