diff --git a/app/Models/Concerns/BelongsToSchoolYear.php b/app/Models/Concerns/BelongsToSchoolYear.php new file mode 100644 index 00000000..e76e622e --- /dev/null +++ b/app/Models/Concerns/BelongsToSchoolYear.php @@ -0,0 +1,18 @@ +where($this->getTable().'.school_year', $schoolYear); + } + + public function scopeForSchoolYearContext(Builder $query, object $context): Builder + { + return $query->where($this->getTable().'.school_year', $context->yearName()); + } +} diff --git a/app/Models/Concerns/HasOptionalSchoolYearContext.php b/app/Models/Concerns/HasOptionalSchoolYearContext.php new file mode 100644 index 00000000..7f3427a1 --- /dev/null +++ b/app/Models/Concerns/HasOptionalSchoolYearContext.php @@ -0,0 +1,21 @@ +where(function (Builder $q) use ($context) { + $q->whereNull($this->getTable().'.school_year') + ->orWhere($this->getTable().'.school_year', $context->yearName()); + }); + } + + public function scopeOnlyForSchoolYearContext(Builder $query, object $context): Builder + { + return $query->where($this->getTable().'.school_year', $context->yearName()); + } +} diff --git a/app/Models/Staff.php b/app/Models/Staff.php index a346562d..c9be7ca3 100644 --- a/app/Models/Staff.php +++ b/app/Models/Staff.php @@ -61,7 +61,7 @@ class Staff extends BaseModel public function scopeForSchoolYear(Builder $q, string $schoolYear): Builder { - return $q->where('school_year', $schoolYear); + return $q; } /* ============================================================ @@ -88,8 +88,7 @@ class Staff extends BaseModel } /** - * Insert or update a staff row keyed by (user_id, school_year). - * - If school_year missing: reuse latest existing school_year for that user if available. + * Insert or update a staff row keyed by user_id. * - created_at is preserved on update (unless explicitly provided). * - updated_at can be provided by caller; otherwise we set it. * @@ -103,45 +102,10 @@ class Staff extends BaseModel $userId = (int) $data['user_id']; - // Resolve school_year fallback like your legacy logic - $schoolYear = $data['school_year'] ?? null; - if ($schoolYear === null) { - $existingAny = static::query() - ->where('user_id', $userId) - ->orderByDesc('id') - ->first(); - - if ($existingAny && $existingAny->school_year) { - $schoolYear = (string) $existingAny->school_year; - $data['school_year'] = $schoolYear; - } - } - - // Find existing by key - $existing = null; - if ($schoolYear !== null) { - $existing = static::query() - ->where('user_id', $userId) - ->where('school_year', (string) $schoolYear) - ->first(); - } else { - // If no school_year, treat (user_id) as key - $existing = static::query() - ->where('user_id', $userId) - ->first(); - } - - // The table enforces unique email addresses, so a user cannot safely - // have multiple staff rows across different school years with the same - // generated email. If an exact school-year row is missing but the user - // already has any staff row, update that existing row instead of trying - // to insert a duplicate-email record. - if (! $existing) { - $existing = static::query() - ->where('user_id', $userId) - ->orderByDesc('id') - ->first(); - } + $existing = static::query() + ->where('user_id', $userId) + ->orderByDesc('id') + ->first(); $now = now(); // use UTC if app.timezone=UTC (recommended) @@ -201,7 +165,6 @@ class Staff extends BaseModel 'role_name' => ['nullable', 'string', 'max:80'], 'active_role' => ['nullable', 'string', 'max:80'], // includes 'inactive' 'status' => ['nullable', 'string', 'max:40'], - 'school_year' => ['nullable', 'string', 'max:20'], 'created_at' => ['nullable', 'date'], 'updated_at' => ['nullable', 'date'], ]; diff --git a/app/Models/Student.php b/app/Models/Student.php index bd639d8a..2e7a6d17 100644 --- a/app/Models/Student.php +++ b/app/Models/Student.php @@ -99,7 +99,12 @@ class Student extends BaseModel return $q; } - return $q->where('school_year', $schoolYear); + return $q->whereExists(function ($sub) use ($schoolYear) { + $sub->selectRaw('1') + ->from('student_class as sc') + ->whereColumn('sc.student_id', 'students.id') + ->where('sc.school_year', $schoolYear); + }); } public function scopeOrderByName(Builder $q): Builder @@ -197,9 +202,14 @@ class Student extends BaseModel $q->whereIn('students.is_new', [0, 1]); } - // school_year filter (skip if null or "all") + // Students are global identities; year rosters come from yearly class/enrollment rows. if ($schoolYear !== null && strtolower($schoolYear) !== 'all') { - $q->where('students.school_year', $schoolYear); + $q->whereExists(function ($sub) use ($schoolYear) { + $sub->selectRaw('1') + ->from('student_class as sc') + ->whereColumn('sc.student_id', 'students.id') + ->where('sc.school_year', $schoolYear); + }); } return $q->groupBy('students.id') diff --git a/app/Models/User.php b/app/Models/User.php index 12ff20ad..32c3535b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -172,10 +172,6 @@ class User extends Authenticatable implements JWTSubject $pivotColumns[] = 'deleted_at'; $relation->wherePivotNull('deleted_at'); } - if (Schema::hasColumn('user_roles', 'school_year')) { - $pivotColumns[] = 'school_year'; - } - if (! empty($pivotColumns)) { $relation->withPivot($pivotColumns); } @@ -422,7 +418,6 @@ class User extends Authenticatable implements JWTSubject ->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) ->whereNull('user_roles.deleted_at') ->get() ->map(fn ($r) => (array) $r) @@ -440,7 +435,6 @@ class User extends Authenticatable implements JWTSubject ->selectRaw('u.id, MAX(CASE WHEN r.slug NOT IN ("guest","teacher","teacher_assistant","parent") THEN 1 ELSE 0 END) as is_admin') ->join('user_roles as ur', 'ur.user_id', '=', 'u.id') ->join('roles as r', 'r.id', '=', 'ur.role_id') - ->where('u.school_year', $schoolYear) ->where('r.is_active', 1) ->whereNull('ur.deleted_at') ->groupBy('u.id') @@ -546,23 +540,11 @@ class User extends Authenticatable implements JWTSubject } if (! empty($schoolYear)) { - // Prefer ur.school_year if it exists (role is year-scoped) - $hasUrSchoolYear = false; - try { - $cols = DB::getSchemaBuilder()->getColumnListing('user_roles'); - $hasUrSchoolYear = in_array('school_year', $cols, true); - } catch (\Throwable $e) { - $hasUrSchoolYear = false; - } + $escYear = DB::getPdo()->quote($schoolYear); - if ($hasUrSchoolYear) { - $q->where('ur.school_year', $schoolYear); - } else { - $escYear = DB::getPdo()->quote($schoolYear); - - $q->where(function ($w) use ($escYear) { - // A) teacher assignment that year - $w->whereRaw(" + $q->where(function ($w) use ($escYear) { + // Staff participation in a year comes from yearly relationship tables. + $w->whereRaw(" EXISTS ( SELECT 1 FROM teacher_class tc @@ -571,20 +553,15 @@ class User extends Authenticatable implements JWTSubject ) "); - // B) OR has any non-teacher-ish, non-parent role - $w->orWhereRaw(" + $w->orWhereRaw(" EXISTS ( SELECT 1 - FROM user_roles ur2 - JOIN roles r2 ON r2.id = ur2.role_id - WHERE ur2.user_id = users.id - AND ur2.deleted_at IS NULL - AND LOWER(r2.name) NOT IN ('parent','ta') - AND LOWER(r2.name) NOT LIKE '%teacher%' + FROM staff_attendance sa + WHERE sa.user_id = users.id + AND sa.school_year = {$escYear} ) "); - }); - } + }); } $q->groupBy('users.id'); diff --git a/app/Models/UserRole.php b/app/Models/UserRole.php index 383157b1..4ab92b28 100644 --- a/app/Models/UserRole.php +++ b/app/Models/UserRole.php @@ -18,12 +18,10 @@ class UserRole extends BaseModel protected $fillable = [ 'user_id', 'role_id', - 'semester', - 'school_year', 'created_at', 'updated_at', - 'updated_by', 'deleted_at', + 'updated_by', ]; protected $casts = [ @@ -67,11 +65,7 @@ class UserRole extends BaseModel public function scopeForSchoolYear(Builder $q, ?string $schoolYear): Builder { - if ($schoolYear === null || trim($schoolYear) === '') { - return $q; - } - - return $q->where('school_year', $schoolYear); + return $q; } /* ============================================================ @@ -175,8 +169,6 @@ class UserRole extends BaseModel return [ 'user_id' => [$req, 'integer', 'min:1', 'exists:users,id'], 'role_id' => [$req, 'integer', 'min:1', 'exists:roles,id'], - 'semester' => ['nullable', 'string', 'max:20'], - 'school_year' => ['nullable', 'string', 'max:20'], 'updated_by' => ['nullable', 'integer'], ]; } diff --git a/app/Services/Badges/BadgeService.php b/app/Services/Badges/BadgeService.php index c2bb7224..23e662f7 100644 --- a/app/Services/Badges/BadgeService.php +++ b/app/Services/Badges/BadgeService.php @@ -12,7 +12,6 @@ use FPDF; use Illuminate\Http\Response; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Schema; class BadgeService { @@ -237,10 +236,6 @@ class BadgeService $query->whereIn('users.id', $selectedUserIds); } - if (! empty($schoolYear) && Schema::hasColumn('user_roles', 'school_year')) { - $query->where('user_roles.school_year', $schoolYear); - } - return array_map(static fn ($row) => (array) $row, $query->get()->all()); } diff --git a/app/Services/Badges/BadgeStudentLookupService.php b/app/Services/Badges/BadgeStudentLookupService.php index adace188..c68c30fc 100644 --- a/app/Services/Badges/BadgeStudentLookupService.php +++ b/app/Services/Badges/BadgeStudentLookupService.php @@ -42,17 +42,17 @@ class BadgeStudentLookupService 'students.lastname', 'students.registration_grade', 'students.school_id', - 'students.school_year', + DB::raw('COALESCE(MAX(sc.school_year), ?) as school_year'), DB::raw($classSectionAggregate), ]) + ->addBinding((string) ($schoolYear ?? ''), 'select') ->where('students.is_active', 1) ->groupBy( 'students.id', 'students.firstname', 'students.lastname', 'students.registration_grade', - 'students.school_id', - 'students.school_year' + 'students.school_id' ) ->orderBy('students.lastname') ->orderBy('students.firstname'); @@ -60,10 +60,7 @@ class BadgeStudentLookupService if (! empty($selectedStudentIds)) { $query->whereIn('students.id', $selectedStudentIds); } elseif (! empty($schoolYear)) { - $query->where(function ($q) use ($schoolYear) { - $q->where('students.school_year', $schoolYear) - ->orWhereNotNull('sc.student_id'); - }); + $query->whereNotNull('sc.student_id'); } $rows = array_map(static fn ($row) => (array) $row, $query->get()->all()); diff --git a/app/Services/Badges/BadgeUserLookupService.php b/app/Services/Badges/BadgeUserLookupService.php index adf46516..f4813c0d 100644 --- a/app/Services/Badges/BadgeUserLookupService.php +++ b/app/Services/Badges/BadgeUserLookupService.php @@ -206,22 +206,6 @@ class BadgeUserLookupService } catch (\Throwable) { } - if (empty($schoolYears)) { - try { - if (Schema::hasTable('user_roles') && Schema::hasColumn('user_roles', 'school_year')) { - $schoolYears = DB::table('user_roles') - ->selectRaw('DISTINCT school_year') - ->whereNotNull('school_year') - ->orderByDesc('school_year') - ->pluck('school_year') - ->filter() - ->values() - ->all(); - } - } catch (\Throwable) { - } - } - return $schoolYears; } diff --git a/app/Services/SchoolYears/SchoolYearContextService.php b/app/Services/SchoolYears/SchoolYearContextService.php index 3094b0d8..080b391f 100644 --- a/app/Services/SchoolYears/SchoolYearContextService.php +++ b/app/Services/SchoolYears/SchoolYearContextService.php @@ -4,6 +4,7 @@ namespace App\Services\SchoolYears; use App\Models\Configuration; use App\Models\SchoolYear; +use App\Support\SchoolYear\SchoolYearTableRegistry; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -143,15 +144,17 @@ class SchoolYearContextService private function legacySchoolYearNames(): array { $tables = [ - 'students', 'parents', 'users', 'staff', 'student_class', 'teacher_class', - 'events', 'calendar_events', 'scan_log', 'exam_drafts', 'inventory_items', - 'inventory_movements', 'certificate_records', 'semester_scores', 'final_scores', + 'student_class', 'teacher_class', + 'events', 'calendar_events', 'exam_drafts', + 'certificate_records', 'semester_scores', 'final_score', 'student_decisions', 'below_sixty_decisions', 'whatsapp_group_links', ]; $names = []; foreach ($tables as $table) { - if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'school_year')) { + if (! SchoolYearTableRegistry::isYearScoped($table) + || ! Schema::hasTable($table) + || ! Schema::hasColumn($table, 'school_year')) { continue; } diff --git a/app/Support/SchoolYear/SchoolYearTableRegistry.php b/app/Support/SchoolYear/SchoolYearTableRegistry.php new file mode 100644 index 00000000..5a34fc87 --- /dev/null +++ b/app/Support/SchoolYear/SchoolYearTableRegistry.php @@ -0,0 +1,133 @@ +where('school_year', $context->yearName()); +``` + +Long-term preferred rule: + +```php +$query->where('school_year_id', $context->id()); +``` + +### 2. Global Tables + +These tables must not be school-year filtered. + +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` +- `laravel_migrations` +- `cache` +- `cache_locks` +- `sessions` +- `personal_access_tokens` +- `password_resets` +- `password_reset_requests` + +Rule: + +```php +// No school_year filter here. +``` + +These are global because they define identity, authorization, app configuration, or infrastructure state. + +### 3. Global Identity Tables With Yearly Relationship Tables + +These records are global, but their participation in a school year must be tracked through bridge or enrollment tables. + +Global identity tables: + +- `families` +- `parents` +- `students` +- `teachers` +- `staff` +- `family_guardians` +- `family_students` +- `emergency_contacts` +- `student_allergies` +- `student_medical_conditions` + +Do not filter the identity table directly by school year. + +Instead, use yearly relationship tables: + +```text +student_enrollments / 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 +``` + +Example query: + +```php +Student::query() + ->whereHas('enrollments', function ($query) use ($context) { + $query->where('school_year', $context->yearName()); + }); +``` + +Not this: + +```php +Student::query()->where('school_year', $context->yearName()); +``` + +That second version is how next year's data becomes a hostage negotiation. + +### 4. Nullable Context Tables + +These tables are not owned by a school year, but some rows may have school-year context. + +Examples: + +- `audit_logs` +- `communication_logs` +- `notifications` +- `notification_recipients` +- `user_notifications` +- `messages` +- `support_requests` +- `contactus` +- `finance_notification_logs` +- `payment_notification_logs` + +Rule: + +- Do not require `school_year` on every row. +- Do not hide all rows by active-year filtering. +- When displaying a year-specific page, filter only where context exists and where the feature requires it. +- Keep `school_year` nullable. + +Example: + +```php +AuditLog::query() + ->when($context->isYearSpecificView(), function ($query) use ($context) { + $query->where(function ($q) use ($context) { + $q->whereNull('school_year') + ->orWhere('school_year', $context->yearName()); + }); + }); +``` + +For strict year reports, use: + +```php +AuditLog::query()->where('school_year', $context->yearName()); +``` + +Only do that on pages explicitly scoped to a school year. + +--- + +## Phase 1: Add Central School-Year Table Metadata + +Create one central class that defines table behavior. + +Suggested file: + +```text +app/Support/SchoolYear/SchoolYearTableRegistry.php +``` + +Example: + +```php +where($this->getTable() . '.school_year', $schoolYear); + } + + public function scopeForSchoolYearContext(Builder $query, $context): Builder + { + return $query->where($this->getTable() . '.school_year', $context->yearName()); + } +} +``` + +Apply only to year-owned models. + +Do not add this trait to: + +- `User` +- `Role` +- `Permission` +- `Setting` +- `SchoolYear` +- `Student` +- `Family` +- `Parent` +- `Teacher` +- `Staff` + +### Trait for nullable context models + +Create: + +```text +app/Models/Concerns/HasOptionalSchoolYearContext.php +``` + +```php +where(function ($q) use ($context) { + $q->whereNull($this->getTable() . '.school_year') + ->orWhere($this->getTable() . '.school_year', $context->yearName()); + }); + } + + public function scopeOnlyForSchoolYearContext(Builder $query, $context): Builder + { + return $query->where($this->getTable() . '.school_year', $context->yearName()); + } +} +``` + +Apply only to logs/notifications/messages where nullable context is valid. + +--- + +## Phase 4: Replace Raw Queries and Controller Filters + +Search the codebase for direct filters: + +```bash +rg "school_year" app routes database tests +rg "where\(['\"]school_year" app routes database tests +rg "request\(['\"]school_year" app routes database tests +rg "school_year_id" app routes database tests +``` + +Classify each usage: + +1. Correct year-owned table filter: keep but route through context service. +2. Global table filter: remove. +3. Identity table filter: replace with `whereHas()` through enrollment/relationship table. +4. Context/log table filter: make optional or strict depending on page. +5. Raw SQL: rewrite to include explicit table aliases and correct table category. + +Bad: + +```php +User::where('school_year', $currentYear)->get(); +``` + +Fix: + +```php +User::query()->get(); +``` + +Bad: + +```php +Student::where('school_year', $currentYear)->get(); +``` + +Fix: + +```php +Student::whereHas('enrollments', function ($query) use ($context) { + $query->forSchoolYearContext($context); +})->get(); +``` + +Bad: + +```php +DB::table('families') + ->where('school_year', $currentYear) + ->get(); +``` + +Fix: + +```php +DB::table('families') + ->join('enrollments', 'families.id', '=', 'enrollments.family_id') + ->where('enrollments.school_year', $context->yearName()) + ->select('families.*') + ->distinct() + ->get(); +``` + +Use the correct relationship table for your schema. Do not blindly copy the join if the real relation goes through `family_students` and `students`, because copying code without checking schema is how bugs reproduce. + +--- + +## Phase 5: Update Finance Queries First + +Finance is the highest-risk area because it affects overdue balances and school-year closure reports. + +Update these service areas first: + +- Parent/family balance calculation +- Invoice listing +- Payment listing +- Manual payment creation +- Refund calculation +- Carry-forward calculation +- School-year closing report +- Parent balance transfers +- Payment allocation logic + +Rules: + +1. Active-year balance must use only active-year invoices/payments/adjustments. +2. Old debt must not leak into active-year overdue balance unless explicitly carried forward. +3. Carry-forward must be stored as a separate transaction in the target year. +4. Closing reports must use the selected year, not active year by accident. + +Correct pattern: + +```php +$invoices = Invoice::query() + ->forSchoolYearContext($context) + ->where('family_id', $familyId) + ->get(); + +$payments = Payment::query() + ->forSchoolYearContext($context) + ->where('family_id', $familyId) + ->get(); +``` + +Wrong pattern: + +```php +$balance = Invoice::where('family_id', $familyId)->sum('amount') + - Payment::where('family_id', $familyId)->sum('amount'); +``` + +That query is not a balance. It is a time machine with bad accounting. + +--- + +## Phase 6: Update Academic Queries + +Update these areas: + +- Class list pages +- Student placement +- Teacher class assignments +- Attendance pages +- Class progress reports +- Exam drafts +- Semester/final score pages +- Promotion logic +- Report card generation + +Rules: + +1. `classes`, `sections`, `classSection`, `teacher_class`, and `student_class` are year-owned. +2. `students` and `teachers` are global identities. +3. Lists of active-year students must be derived through enrollment or class assignment tables. +4. Report card and progress pages must use selected school year. + +Correct pattern: + +```php +ClassModel::query() + ->forSchoolYearContext($context) + ->with(['students' => function ($query) use ($context) { + $query->whereHas('enrollments', function ($q) use ($context) { + $q->forSchoolYearContext($context); + }); + }]) + ->get(); +``` + +Better pattern: + +Keep student filtering in a repository/service instead of nesting query logic inside controllers like a bowl of spaghetti pretending to be architecture. + +--- + +## Phase 7: Update Identity and Profile Pages + +Update these pages carefully: + +- Parent profiles +- Family management +- Student profiles +- Teacher profiles +- Staff pages + +Rules: + +1. Profile detail pages can load global identity records directly. +2. Year-specific tabs must filter their child data by school year. +3. Active-year rosters must use enrollment/relationship tables. +4. Do not hide a family/person globally just because they are not active this year. + +Example: + +```php +$family = Family::query()->findOrFail($familyId); + +$currentYearInvoices = Invoice::query() + ->forSchoolYearContext($context) + ->where('family_id', $family->id) + ->get(); +``` + +This keeps the profile global while making the related data year-specific. + +--- + +## Phase 8: Update Global Tables to Remove School-Year Logic + +Remove school-year filtering from: + +- Login/auth code +- User management +- Role management +- Permission checks +- Navigation/sidebar building +- Settings loading +- Email template loading unless explicitly year-specific +- Password reset flows +- Token validation +- Sessions/cache logic + +Bad: + +```php +Permission::where('school_year', $context->yearName())->get(); +``` + +Fix: + +```php +Permission::query()->get(); +``` + +Bad: + +```php +Setting::where('school_year', $context->yearName())->first(); +``` + +Fix: + +```php +Setting::query()->first(); +``` + +If a setting really needs year-specific override, create a separate table: + +```text +school_year_settings +- school_year_id +- key +- value +``` + +Do not mutate the global `settings` table into a half-global, half-year table. That is not flexibility. That is future confusion with columns. + +--- + +## Phase 9: Update APIs and Routes + +Preferred route structure: + +```text +/api/v1/school-years/current/classes +/api/v1/school-years/current/families +/api/v1/school-years/current/reports/closing +/api/v1/school-years/{schoolYear}/classes +/api/v1/school-years/{schoolYear}/families +/api/v1/school-years/{schoolYear}/reports/closing +``` + +For global resources: + +```text +/api/v1/users +/api/v1/roles +/api/v1/permissions +/api/v1/settings +/api/v1/school-years +``` + +Do not put global resources under school-year routes unless they are returning year-specific related data. + +Bad: + +```text +/api/v1/school-years/4/users +``` + +Better: + +```text +/api/v1/users +/api/v1/school-years/4/teacher-assignments +``` + +--- + +## Phase 10: Add Write Guard + +All writes to year-owned tables must validate the selected school year is editable. + +Create/update: + +```text +app/Services/SchoolYearWriteGuard.php +``` + +Rules: + +```text +active: writable +closing: restricted +closed: read-only +archived: read-only +draft: admin-only writable +``` + +Usage: + +```php +$schoolYearWriteGuard->assertWritable($context); +``` + +Apply to: + +- Create/update/delete invoice +- Create/update/delete payment +- Attendance updates +- Exam draft edits +- Score edits +- Class assignments +- Teacher assignments +- Event creation +- WhatsApp group updates +- Report card acknowledgement edits + +Do not apply it to global auth/config operations unless the operation explicitly modifies school-year-owned data. + +--- + +## Phase 11: Update Frontend Data Flow + +Frontend should not manually invent school-year filters on every page. + +Create one school-year context provider: + +```text +SchoolYearProvider +- activeSchoolYear +- selectedSchoolYear +- selectedSchoolYearId +- selectedSchoolYearName +- isReadonly +``` + +API client behavior: + +1. Year-owned pages call year-owned endpoints. +2. Global pages call global endpoints. +3. Selected school year is preserved in navigation. +4. Read-only years disable mutation buttons. + +Examples: + +Year-owned: + +```ts +api.get(`/school-years/${selectedSchoolYearId}/classes`) +api.get(`/school-years/${selectedSchoolYearId}/reports/closing`) +api.get(`/school-years/${selectedSchoolYearId}/families/${familyId}/balance`) +``` + +Global: + +```ts +api.get('/users') +api.get('/roles') +api.get('/settings') +api.get('/school-years') +``` + +Profile with year-related children: + +```ts +api.get(`/families/${familyId}`) +api.get(`/school-years/${selectedSchoolYearId}/families/${familyId}/invoices`) +api.get(`/school-years/${selectedSchoolYearId}/families/${familyId}/payments`) +``` + +--- + +## Phase 12: Add Tests + +### Backend tests + +Add tests for each table category. + +#### Year-owned tables + +- `classes` returns only selected-year classes. +- `attendance_record` returns only selected-year attendance. +- `invoices` returns only selected-year invoices. +- `payments` returns only selected-year payments. +- `class_progress_reports` returns only selected-year reports. + +#### Global tables + +- `users` loads regardless of selected school year. +- `roles` loads regardless of selected school year. +- `permissions` loads regardless of selected school year. +- `settings` loads regardless of selected school year. +- `school_years` loads all school years. + +#### Identity tables + +- Student profile loads even if selected year changes. +- Student active-year class list changes by selected year. +- Family profile loads globally. +- Family balance changes by selected year. + +#### Context tables + +- Audit logs can show global rows. +- Year-specific audit view can filter selected-year rows. +- Notifications do not disappear incorrectly because of active-year filtering. + +#### Write guard + +- Writing to active year succeeds. +- Writing to closed year fails. +- Writing to global table is not blocked by school-year guard. + +### Frontend tests + +- Active-year dashboard shows active-year records only. +- Changing selected year updates classes, balances, progress, and reports. +- Global settings page does not break when selected school year changes. +- Closed year disables edit/create/delete buttons. +- Parent with old-year debt but zero current-year balance does not appear overdue in current year. + +--- + +## Phase 13: Database Cleanup After Code Is Safe + +After code has stopped referencing `school_year` on global tables, remove the wrongly added columns. Do not remove context-table `school_year` columns if they are intentionally nullable context fields. The cleanup target is global/system/identity/config tables only. + +Candidate columns to remove: + +```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 +laravel_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 +``` + +Cleanup order: + +1. Search code for each column reference. +2. Remove bad filters from code. +3. Deploy code. +4. Run database backup. +5. Drop columns in a migration. +6. Run smoke tests. + +Do not drop first and debug later. That is not engineering. That is live-action gambling. + +--- + +## Phase 14: Migration Toward `school_year_id` + +The current `school_year` text field is useful for emergency tagging, but the final design should use `school_year_id` foreign keys. + +Long-term migration: + +1. Add nullable `school_year_id` to year-owned tables. +2. Backfill from text `school_year` by matching `school_years.name`. +3. Update code to write both `school_year` and `school_year_id` temporarily. +4. Switch reads to `school_year_id`. +5. Add indexes and foreign keys. +6. Make `school_year_id` required on year-owned tables. +7. Remove old text `school_year` only after all code uses IDs. + +Example: + +```sql +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; +``` + +Add indexes: + +```sql +CREATE INDEX invoices_school_year_id_index ON invoices (school_year_id); +CREATE INDEX payments_school_year_id_index ON payments (school_year_id); +CREATE INDEX attendance_record_school_year_id_index ON attendance_record (school_year_id); +CREATE INDEX class_progress_reports_school_year_id_index ON class_progress_reports (school_year_id); +``` + +--- + +## Acceptance Criteria + +This work is complete only when all of these are true: + +- No global table is filtered by `school_year`. +- All year-owned table reads use selected school-year context. +- All year-owned table writes validate school-year editability. +- Identity tables load globally, while their year-specific children are filtered through relationship tables. +- Finance balances never mix years unless using explicit carry-forward records. +- Closed-year views are read-only. +- Frontend uses centralized school-year context. +- Tests prove old-year records do not leak into active-year pages. +- Tests prove global auth/config pages do not break when selected school year changes. +- No endpoint silently returns all rows when school-year context is required. + +--- + +## Implementation Order + +Recommended order: + +1. Add `SchoolYearTableRegistry`. +2. Add/update `SchoolYearContextService`. +3. Add `BelongsToSchoolYear` and `HasOptionalSchoolYearContext` traits. +4. Add automated checks that block school-year filtering on global tables. +5. Fix finance queries. +6. Fix school-year closing report queries. +7. Fix academic/class/progress queries. +8. Fix identity/profile pages. +9. Remove bad global table filters. +10. Ensure context tables are nullable and are not automatically stamped with active year. +11. Add write guard to year-owned writes. +12. Update frontend API routing and context. +13. Add backend tests. +14. Add frontend tests. +15. Deploy code. +16. Clean database columns from global tables. +17. Plan migration from text `school_year` to foreign key `school_year_id`. + +--- + +## Non-Negotiable Rule + +Never decide whether to apply school-year filtering by checking only whether a column exists. + +A table may have `school_year` because of a bad migration or emergency script. That does not mean the table is logically year-owned. + +Use table classification, not column existence. + +Column existence tells you what happened. + +Table classification tells you what should happen. diff --git a/tests/Unit/Support/SchoolYearTableRegistryTest.php b/tests/Unit/Support/SchoolYearTableRegistryTest.php new file mode 100644 index 00000000..21076c08 --- /dev/null +++ b/tests/Unit/Support/SchoolYearTableRegistryTest.php @@ -0,0 +1,34 @@ +assertTrue(SchoolYearTableRegistry::isGlobal($table), $table); + $this->assertFalse(SchoolYearTableRegistry::isYearScoped($table), $table); + } + } + + public function test_identity_tables_are_classified_separately_from_year_owned_tables(): void + { + foreach (['students', 'families', 'parents', 'teachers', 'staff'] as $table) { + $this->assertTrue(SchoolYearTableRegistry::isIdentityWithYearRelation($table), $table); + $this->assertFalse(SchoolYearTableRegistry::isYearScoped($table), $table); + $this->assertFalse(SchoolYearTableRegistry::isGlobal($table), $table); + } + } + + public function test_year_owned_and_context_tables_are_explicit(): void + { + $this->assertTrue(SchoolYearTableRegistry::isYearScoped('student_class')); + $this->assertTrue(SchoolYearTableRegistry::isYearScoped('payments')); + $this->assertTrue(SchoolYearTableRegistry::isContext('notifications')); + $this->assertTrue(SchoolYearTableRegistry::isContext('payment_notification_logs')); + } +}