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
{
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'],
];
+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')
+9 -32
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,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');
+2 -10
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,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'],
];
}