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());
}
}
+2 -39
View File
@@ -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();
}
$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'],
];
+13 -3
View File
@@ -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')
+4 -27
View File
@@ -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,22 +540,10 @@ 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;
}
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
// Staff participation in a year comes from yearly relationship tables.
$w->whereRaw("
EXISTS (
SELECT 1
@@ -571,21 +553,16 @@ class User extends Authenticatable implements JWTSubject
)
");
// B) OR has any non-teacher-ish, non-parent role
$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');
+1 -9
View File
@@ -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,13 +65,9 @@ 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);
}
/* ============================================================
* legacy-compatible methods (converted)
* ============================================================
@@ -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'],
];
}
-5
View File
@@ -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());
}
@@ -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());
@@ -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;
}
@@ -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;
}
@@ -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'));
}
}