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
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user