fix tables to have school year, and remove school year from other tables.
API CI/CD / Validate (composer + pint) (push) Successful in 3m6s
API CI/CD / Test (PHPUnit) (push) Failing after 5m48s
API CI/CD / Build frontend assets (push) Successful in 1m2s
API CI/CD / Security audit (push) Failing after 1m33s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped

This commit is contained in:
root
2026-07-06 22:27:21 -04:00
parent 304fa6bfd0
commit 58726ee0e9
13 changed files with 1343 additions and 120 deletions
@@ -0,0 +1,18 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToSchoolYear
{
public function scopeForSchoolYear(Builder $query, string $schoolYear): Builder
{
return $query->where($this->getTable().'.school_year', $schoolYear);
}
public function scopeForSchoolYearContext(Builder $query, object $context): Builder
{
return $query->where($this->getTable().'.school_year', $context->yearName());
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait HasOptionalSchoolYearContext
{
public function scopeForOptionalSchoolYearContext(Builder $query, object $context): Builder
{
return $query->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());
}
}
+6 -43
View File
@@ -61,7 +61,7 @@ class Staff extends BaseModel
public function scopeForSchoolYear(Builder $q, string $schoolYear): Builder 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). * Insert or update a staff row keyed by user_id.
* - If school_year missing: reuse latest existing school_year for that user if available.
* - created_at is preserved on update (unless explicitly provided). * - created_at is preserved on update (unless explicitly provided).
* - updated_at can be provided by caller; otherwise we set it. * - updated_at can be provided by caller; otherwise we set it.
* *
@@ -103,45 +102,10 @@ class Staff extends BaseModel
$userId = (int) $data['user_id']; $userId = (int) $data['user_id'];
// Resolve school_year fallback like your legacy logic $existing = static::query()
$schoolYear = $data['school_year'] ?? null; ->where('user_id', $userId)
if ($schoolYear === null) { ->orderByDesc('id')
$existingAny = static::query() ->first();
->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();
}
$now = now(); // use UTC if app.timezone=UTC (recommended) $now = now(); // use UTC if app.timezone=UTC (recommended)
@@ -201,7 +165,6 @@ class Staff extends BaseModel
'role_name' => ['nullable', 'string', 'max:80'], 'role_name' => ['nullable', 'string', 'max:80'],
'active_role' => ['nullable', 'string', 'max:80'], // includes 'inactive' 'active_role' => ['nullable', 'string', 'max:80'], // includes 'inactive'
'status' => ['nullable', 'string', 'max:40'], 'status' => ['nullable', 'string', 'max:40'],
'school_year' => ['nullable', 'string', 'max:20'],
'created_at' => ['nullable', 'date'], 'created_at' => ['nullable', 'date'],
'updated_at' => ['nullable', 'date'], 'updated_at' => ['nullable', 'date'],
]; ];
+13 -3
View File
@@ -99,7 +99,12 @@ class Student extends BaseModel
return $q; 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 public function scopeOrderByName(Builder $q): Builder
@@ -197,9 +202,14 @@ class Student extends BaseModel
$q->whereIn('students.is_new', [0, 1]); $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') { 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') return $q->groupBy('students.id')
+9 -32
View File
@@ -172,10 +172,6 @@ class User extends Authenticatable implements JWTSubject
$pivotColumns[] = 'deleted_at'; $pivotColumns[] = 'deleted_at';
$relation->wherePivotNull('deleted_at'); $relation->wherePivotNull('deleted_at');
} }
if (Schema::hasColumn('user_roles', 'school_year')) {
$pivotColumns[] = 'school_year';
}
if (! empty($pivotColumns)) { if (! empty($pivotColumns)) {
$relation->withPivot($pivotColumns); $relation->withPivot($pivotColumns);
} }
@@ -422,7 +418,6 @@ class User extends Authenticatable implements JWTSubject
->join('user_roles', 'user_roles.user_id', '=', 'users.id') ->join('user_roles', 'user_roles.user_id', '=', 'users.id')
->join('roles', 'roles.id', '=', 'user_roles.role_id') ->join('roles', 'roles.id', '=', 'user_roles.role_id')
->where('roles.name', $roleName) ->where('roles.name', $roleName)
->where('users.school_year', $schoolYear)
->whereNull('user_roles.deleted_at') ->whereNull('user_roles.deleted_at')
->get() ->get()
->map(fn ($r) => (array) $r) ->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') ->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('user_roles as ur', 'ur.user_id', '=', 'u.id')
->join('roles as r', 'r.id', '=', 'ur.role_id') ->join('roles as r', 'r.id', '=', 'ur.role_id')
->where('u.school_year', $schoolYear)
->where('r.is_active', 1) ->where('r.is_active', 1)
->whereNull('ur.deleted_at') ->whereNull('ur.deleted_at')
->groupBy('u.id') ->groupBy('u.id')
@@ -546,23 +540,11 @@ class User extends Authenticatable implements JWTSubject
} }
if (! empty($schoolYear)) { if (! empty($schoolYear)) {
// Prefer ur.school_year if it exists (role is year-scoped) $escYear = DB::getPdo()->quote($schoolYear);
$hasUrSchoolYear = false;
try {
$cols = DB::getSchemaBuilder()->getColumnListing('user_roles');
$hasUrSchoolYear = in_array('school_year', $cols, true);
} catch (\Throwable $e) {
$hasUrSchoolYear = false;
}
if ($hasUrSchoolYear) { $q->where(function ($w) use ($escYear) {
$q->where('ur.school_year', $schoolYear); // Staff participation in a year comes from yearly relationship tables.
} else { $w->whereRaw("
$escYear = DB::getPdo()->quote($schoolYear);
$q->where(function ($w) use ($escYear) {
// A) teacher assignment that year
$w->whereRaw("
EXISTS ( EXISTS (
SELECT 1 SELECT 1
FROM teacher_class tc 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 ( EXISTS (
SELECT 1 SELECT 1
FROM user_roles ur2 FROM staff_attendance sa
JOIN roles r2 ON r2.id = ur2.role_id WHERE sa.user_id = users.id
WHERE ur2.user_id = users.id AND sa.school_year = {$escYear}
AND ur2.deleted_at IS NULL
AND LOWER(r2.name) NOT IN ('parent','ta')
AND LOWER(r2.name) NOT LIKE '%teacher%'
) )
"); ");
}); });
}
} }
$q->groupBy('users.id'); $q->groupBy('users.id');
+2 -10
View File
@@ -18,12 +18,10 @@ class UserRole extends BaseModel
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'role_id', 'role_id',
'semester',
'school_year',
'created_at', 'created_at',
'updated_at', 'updated_at',
'updated_by',
'deleted_at', 'deleted_at',
'updated_by',
]; ];
protected $casts = [ protected $casts = [
@@ -67,11 +65,7 @@ class UserRole extends BaseModel
public function scopeForSchoolYear(Builder $q, ?string $schoolYear): Builder public function scopeForSchoolYear(Builder $q, ?string $schoolYear): Builder
{ {
if ($schoolYear === null || trim($schoolYear) === '') { return $q;
return $q;
}
return $q->where('school_year', $schoolYear);
} }
/* ============================================================ /* ============================================================
@@ -175,8 +169,6 @@ class UserRole extends BaseModel
return [ return [
'user_id' => [$req, 'integer', 'min:1', 'exists:users,id'], 'user_id' => [$req, 'integer', 'min:1', 'exists:users,id'],
'role_id' => [$req, 'integer', 'min:1', 'exists:roles,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'], 'updated_by' => ['nullable', 'integer'],
]; ];
} }
-5
View File
@@ -12,7 +12,6 @@ use FPDF;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class BadgeService class BadgeService
{ {
@@ -237,10 +236,6 @@ class BadgeService
$query->whereIn('users.id', $selectedUserIds); $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()); return array_map(static fn ($row) => (array) $row, $query->get()->all());
} }
@@ -42,17 +42,17 @@ class BadgeStudentLookupService
'students.lastname', 'students.lastname',
'students.registration_grade', 'students.registration_grade',
'students.school_id', 'students.school_id',
'students.school_year', DB::raw('COALESCE(MAX(sc.school_year), ?) as school_year'),
DB::raw($classSectionAggregate), DB::raw($classSectionAggregate),
]) ])
->addBinding((string) ($schoolYear ?? ''), 'select')
->where('students.is_active', 1) ->where('students.is_active', 1)
->groupBy( ->groupBy(
'students.id', 'students.id',
'students.firstname', 'students.firstname',
'students.lastname', 'students.lastname',
'students.registration_grade', 'students.registration_grade',
'students.school_id', 'students.school_id'
'students.school_year'
) )
->orderBy('students.lastname') ->orderBy('students.lastname')
->orderBy('students.firstname'); ->orderBy('students.firstname');
@@ -60,10 +60,7 @@ class BadgeStudentLookupService
if (! empty($selectedStudentIds)) { if (! empty($selectedStudentIds)) {
$query->whereIn('students.id', $selectedStudentIds); $query->whereIn('students.id', $selectedStudentIds);
} elseif (! empty($schoolYear)) { } elseif (! empty($schoolYear)) {
$query->where(function ($q) use ($schoolYear) { $query->whereNotNull('sc.student_id');
$q->where('students.school_year', $schoolYear)
->orWhereNotNull('sc.student_id');
});
} }
$rows = array_map(static fn ($row) => (array) $row, $query->get()->all()); $rows = array_map(static fn ($row) => (array) $row, $query->get()->all());
@@ -206,22 +206,6 @@ class BadgeUserLookupService
} catch (\Throwable) { } 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; return $schoolYears;
} }
@@ -4,6 +4,7 @@ namespace App\Services\SchoolYears;
use App\Models\Configuration; use App\Models\Configuration;
use App\Models\SchoolYear; use App\Models\SchoolYear;
use App\Support\SchoolYear\SchoolYearTableRegistry;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -143,15 +144,17 @@ class SchoolYearContextService
private function legacySchoolYearNames(): array private function legacySchoolYearNames(): array
{ {
$tables = [ $tables = [
'students', 'parents', 'users', 'staff', 'student_class', 'teacher_class', 'student_class', 'teacher_class',
'events', 'calendar_events', 'scan_log', 'exam_drafts', 'inventory_items', 'events', 'calendar_events', 'exam_drafts',
'inventory_movements', 'certificate_records', 'semester_scores', 'final_scores', 'certificate_records', 'semester_scores', 'final_score',
'student_decisions', 'below_sixty_decisions', 'whatsapp_group_links', 'student_decisions', 'below_sixty_decisions', 'whatsapp_group_links',
]; ];
$names = []; $names = [];
foreach ($tables as $table) { 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; continue;
} }
@@ -0,0 +1,133 @@
<?php
namespace App\Support\SchoolYear;
final class SchoolYearTableRegistry
{
public const YEAR_SCOPED = [
'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',
];
public const GLOBAL = [
'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',
];
public const IDENTITY_WITH_YEAR_RELATION = [
'families',
'parents',
'students',
'teachers',
'staff',
'family_guardians',
'family_students',
'emergency_contacts',
'student_allergies',
'student_medical_conditions',
];
public const CONTEXT = [
'audit_logs',
'communication_logs',
'notifications',
'notification_recipients',
'user_notifications',
'messages',
'support_requests',
'contactus',
'finance_notification_logs',
'payment_notification_logs',
];
public static function categoryFor(string $table): ?string
{
if (self::isYearScoped($table)) {
return 'year_scoped';
}
if (self::isGlobal($table)) {
return 'global';
}
if (self::isIdentityWithYearRelation($table)) {
return 'identity_with_year_relation';
}
if (self::isContext($table)) {
return 'context';
}
return null;
}
public static function isYearScoped(string $table): bool
{
return in_array($table, self::YEAR_SCOPED, true);
}
public static function isGlobal(string $table): bool
{
return in_array($table, self::GLOBAL, true);
}
public static function isIdentityWithYearRelation(string $table): bool
{
return in_array($table, self::IDENTITY_WITH_YEAR_RELATION, true);
}
public static function isContext(string $table): bool
{
return in_array($table, self::CONTEXT, true);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
<?php
namespace Tests\Unit\Support;
use App\Support\SchoolYear\SchoolYearTableRegistry;
use PHPUnit\Framework\TestCase;
class SchoolYearTableRegistryTest extends TestCase
{
public function test_global_tables_are_not_year_scoped(): void
{
foreach (['users', 'roles', 'permissions', 'settings', 'school_years', 'user_roles'] as $table) {
$this->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'));
}
}