fix first production issues
API CI/CD / Validate (composer + pint) (push) Failing after 1m11s
API CI/CD / Test (PHPUnit) (push) Failing after 2m30s
API CI/CD / Build frontend assets (push) Successful in 1m14s
API CI/CD / Security audit (push) Failing after 48s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped

This commit is contained in:
root
2026-07-10 03:43:50 -04:00
parent 02ba2fe6b6
commit e9c10ae376
11 changed files with 687 additions and 564 deletions
BIN
View File
Binary file not shown.
+10 -7
View File
@@ -6,10 +6,17 @@ class LoginActivity extends BaseModel
{
protected $table = 'login_activity';
// ✅ legacy: useTimestamps = true (created_at/updated_at)
public $timestamps = true;
protected $fillable = ['user_id', 'email', 'login_time', 'logout_time', 'ip_address', 'user_agent', 'created_at', 'updated_at', 'semester', 'school_year'];
protected $fillable = [
'user_id',
'email',
'login_time',
'logout_time',
'ip_address',
'user_agent',
'school_year',
];
protected $casts = [
'user_id' => 'integer',
@@ -17,15 +24,11 @@ class LoginActivity extends BaseModel
'logout_time' => 'datetime',
];
/* Optional relationship */
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Equivalent of legacy getLastActivities()
*/
public static function getLastActivities(int $limit = 4)
{
return static::query()
@@ -33,4 +36,4 @@ class LoginActivity extends BaseModel
->limit(max(1, $limit))
->get();
}
}
}
+74 -44
View File
@@ -132,25 +132,36 @@ class Student extends BaseModel
/**
* legacy: getNewStudentsWithParentsAndEmergency()
* Picks one emergency contact via MIN(...) with groupBy student.
* Aggregates emergency contacts by parent before joining, avoiding
* duplicate student rows and ONLY_FULL_GROUP_BY violations.
*/
public static function getNewStudentsWithParentsAndEmergency(): array
{
$emergencyContacts = DB::table('emergency_contacts')
->select([
'parent_id',
DB::raw('MIN(emergency_contact_name) AS emergency_name'),
DB::raw('MIN(relation) AS emergency_relationship'),
DB::raw('MIN(cellphone) AS emergency_phone'),
])
->groupBy('parent_id');
return DB::table('students')
->selectRaw('
students.*,
u.firstname AS parent_firstname,
u.lastname AS parent_lastname,
u.email AS parent_email,
u.cellphone AS parent_phone,
MIN(ec.emergency_contact_name) AS emergency_name,
MIN(ec.relation) AS emergency_relationship,
MIN(ec.cellphone) AS emergency_phone
')
->leftJoin('users as u', 'u.id', '=', 'students.parent_id')
->leftJoin('emergency_contacts as ec', 'ec.parent_id', '=', 'students.parent_id')
->leftJoinSub($emergencyContacts, 'ec', function ($join): void {
$join->on('ec.parent_id', '=', 'students.parent_id');
})
->select([
'students.*',
'u.firstname as parent_firstname',
'u.lastname as parent_lastname',
'u.email as parent_email',
'u.cellphone as parent_phone',
'ec.emergency_name',
'ec.emergency_relationship',
'ec.emergency_phone',
])
->where('students.is_new', 1)
->groupBy('students.id')
->orderBy('students.lastname', 'asc')
->orderBy('students.firstname', 'asc')
->get()
@@ -163,46 +174,65 @@ class Student extends BaseModel
*/
public static function getStudentsWithParentsAndEmergency(?string $schoolYear = null, ?int $isNew = null): array
{
$q = DB::table('students')
->selectRaw('
students.*,
u.firstname AS parent_firstname,
u.lastname AS parent_lastname,
u.email AS parent_email,
u.cellphone AS parent_phone,
MIN(ec.emergency_contact_name) AS emergency_name,
MIN(ec.relation) AS emergency_relationship,
MIN(ec.cellphone) AS emergency_phone
')
->leftJoin('users as u', 'u.id', '=', 'students.parent_id')
->leftJoin('emergency_contacts as ec', 'ec.parent_id', '=', 'students.parent_id');
$emergencyContacts = DB::table('emergency_contacts')
->select([
'parent_id',
DB::raw('MIN(emergency_contact_name) AS emergency_name'),
DB::raw('MIN(relation) AS emergency_relationship'),
DB::raw('MIN(cellphone) AS emergency_phone'),
])
->groupBy('parent_id');
$q = DB::table('students')
->leftJoin('users as u', 'u.id', '=', 'students.parent_id')
->leftJoinSub($emergencyContacts, 'ec', function ($join): void {
$join->on('ec.parent_id', '=', 'students.parent_id');
})
->select([
'students.*',
'u.firstname as parent_firstname',
'u.lastname as parent_lastname',
'u.email as parent_email',
'u.cellphone as parent_phone',
'ec.emergency_name',
'ec.emergency_relationship',
'ec.emergency_phone',
]);
// is_new filter
if ($isNew === 0 || $isNew === 1) {
$q->where('students.is_new', $isNew);
} else {
$q->whereIn('students.is_new', [0, 1]);
}
// Students are global identities; year rosters come from yearly class/enrollment rows.
if ($schoolYear !== null && strtolower($schoolYear) !== 'all') {
$q->where(function ($yearQuery) use ($schoolYear) {
$yearQuery->whereExists(function ($sub) use ($schoolYear) {
$sub->selectRaw('1')
->from('student_class as sc')
->whereColumn('sc.student_id', 'students.id')
->where('sc.school_year', $schoolYear);
})->orWhereExists(function ($sub) use ($schoolYear) {
$sub->selectRaw('1')
->from('enrollments as e')
->whereColumn('e.student_id', 'students.id')
->where('e.school_year', $schoolYear);
});
$normalizedSchoolYear = $schoolYear !== null
? trim($schoolYear)
: null;
if (
$normalizedSchoolYear !== null
&& $normalizedSchoolYear !== ''
&& strtolower($normalizedSchoolYear) !== 'all'
) {
$q->where(function ($yearQuery) use ($normalizedSchoolYear): void {
$yearQuery
->whereExists(function ($sub) use ($normalizedSchoolYear): void {
$sub->selectRaw('1')
->from('student_class as sc')
->whereColumn('sc.student_id', 'students.id')
->where('sc.school_year', $normalizedSchoolYear);
})
->orWhereExists(function ($sub) use ($normalizedSchoolYear): void {
$sub->selectRaw('1')
->from('enrollments as e')
->whereColumn('e.student_id', 'students.id')
->where('e.school_year', $normalizedSchoolYear);
});
});
}
return $q->groupBy('students.id')
->orderByDesc('students.is_new') // new first
return $q
->orderByDesc('students.is_new')
->orderBy('students.lastname', 'asc')
->orderBy('students.firstname', 'asc')
->get()
@@ -403,4 +433,4 @@ class Student extends BaseModel
'is_active' => ['nullable', 'boolean'],
];
}
}
}
+72 -26
View File
@@ -22,7 +22,10 @@ class ApiLoginSecurityService
public function isIpBlocked(string $ip): bool
{
$row = IpAttempt::query()->where('ip_address', $ip)->first();
$row = IpAttempt::query()
->where('ip_address', $ip)
->first();
if (! $row || ! $row->blocked_until) {
return false;
}
@@ -35,14 +38,16 @@ class ApiLoginSecurityService
$now = CarbonImmutable::now('UTC');
$nowStr = $now->toDateTimeString();
$attempt = IpAttempt::query()->where('ip_address', $ip)->first();
$attempt = IpAttempt::query()
->where('ip_address', $ip)
->first();
if ($attempt) {
$previouslyBlocked = $attempt->blocked_until
? CarbonImmutable::parse($attempt->blocked_until, 'UTC')->isFuture()
: false;
// If the previous block has already expired, start a fresh window
// so we don't perma-ban an IP after its first 10 lifetime failures.
// If the previous block has expired, begin a fresh failure window.
$attempts = $previouslyBlocked
? ((int) $attempt->attempts) + 1
: 1;
@@ -64,14 +69,19 @@ class ApiLoginSecurityService
'ip_address' => $ip,
'attempts' => 1,
'last_attempt_at' => $nowStr,
'blocked_until' => null,
]);
}
public function handleFailedLogin(User $user, string $email, string $ip): void
{
public function handleFailedLogin(
User $user,
string $email,
string $ip
): void {
$user->refresh();
$failedAttempts = ((int) ($user->failed_attempts ?? 0)) + 1;
$data = [
'failed_attempts' => $failedAttempts,
'last_failed_at' => utc_now(),
@@ -79,8 +89,14 @@ class ApiLoginSecurityService
if ($failedAttempts >= 3) {
$data['is_suspended'] = true;
// Password-reset delivery can be wired here when the suspension workflow is finalized.
Log::notice('api_login: account suspended after failed attempts', ['email' => $email, 'user_id' => $user->id]);
Log::notice(
'api_login: account suspended after failed attempts',
[
'email' => $email,
'user_id' => $user->id,
]
);
}
$user->fill($data);
@@ -96,10 +112,16 @@ class ApiLoginSecurityService
$user->save();
}
public function logSuccessfulLogin(User $user, Request $request): void
{
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? date('Y')));
$semester = trim((string) (Configuration::getConfig('semester') ?? 'Fall'));
public function logSuccessfulLogin(
User $user,
Request $request
): void {
$schoolYear = trim(
(string) (
Configuration::getConfig('school_year')
?? date('Y')
)
);
LoginActivity::query()->create([
'user_id' => $user->id,
@@ -107,21 +129,24 @@ class ApiLoginSecurityService
'login_time' => utc_now(),
'ip_address' => $request->ip(),
'user_agent' => (string) ($request->userAgent() ?? ''),
'school_year' => $schoolYear,
'semester' => $semester !== '' ? $semester : 'Fall',
'school_year' => $schoolYear !== ''
? $schoolYear
: date('Y'),
]);
}
/**
* Returns roles as an object map {"RoleName": true}.
* Returns roles as an object map:
* {"RoleName": true}
*
* @param list<string> $roleNames
* @param list<string> $roleNames
*/
public function rolesToObjectMap(array $roleNames): object
{
$rolesMap = [];
foreach ($roleNames as $r) {
$rolesMap[$r] = true;
foreach ($roleNames as $roleName) {
$rolesMap[$roleName] = true;
}
return (object) $rolesMap;
@@ -130,19 +155,40 @@ class ApiLoginSecurityService
/**
* Top-level JSON body for API login success responses.
*
* @return array{status: true, token: string, access_token: string, token_type: string, expires_in: int, user: array{id: int, name: string, firstname: ?string, lastname: ?string, email: ?string, roles: object, class_section_id: ?int, class_section_name: ?string}}
* @return array{
* status: true,
* token: string,
* access_token: string,
* token_type: string,
* expires_in: int,
* user: array{
* id: int,
* name: string,
* firstname: ?string,
* lastname: ?string,
* email: ?string,
* roles: object,
* class_section_id: ?int,
* class_section_name: ?string
* }
* }
*/
public function buildLoginResponse(User $user, string $jwtToken): array
{
public function buildLoginResponse(
User $user,
string $jwtToken
): array {
$roleNames = $user->roleNames();
$fullName = trim(($user->firstname ?? '').' '.($user->lastname ?? ''));
$fullName = trim(
($user->firstname ?? '')
.' '.
($user->lastname ?? '')
);
$teacherContext = $user->teacherSessionContext();
return [
'status' => true,
// Keep both names. Older clients read `token`; JWT-aware clients often read
// `access_token`. Removing either one just makes login fail in a new and
// irritatingly avoidable way.
'token' => $jwtToken,
'access_token' => $jwtToken,
'token_type' => 'bearer',
@@ -170,4 +216,4 @@ class ApiLoginSecurityService
return (int) ($ttl ?? 60) * 60;
}
}
}
@@ -4,7 +4,9 @@ namespace App\Services\Families;
use App\Models\StudentClass;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class ParentProfileAdminQueryService
{
@@ -12,7 +14,53 @@ class ParentProfileAdminQueryService
public function index(string $schoolYear, string $search = '', int $perPage = 25): LengthAwarePaginator
{
$familyCounts = DB::table('family_guardians')
->select('user_id', DB::raw('COUNT(DISTINCT family_id) AS families_count'))
->groupBy('user_id');
$familyStudentMap = DB::table('family_guardians as pfg')
->join('family_students as pfs', 'pfs.family_id', '=', 'pfg.family_id')
->joinSub($this->schoolYearStudentIds($schoolYear), 'pys', function ($join): void {
$join->on('pys.student_id', '=', 'pfs.student_id');
})
->selectRaw('pfg.user_id AS parent_id, pfs.student_id AS student_id');
$directStudentMap = DB::table('students as ps')
->joinSub($this->schoolYearStudentIds($schoolYear), 'dys', function ($join): void {
$join->on('dys.student_id', '=', 'ps.id');
})
->whereNotNull('ps.parent_id')
->selectRaw('ps.parent_id AS parent_id, ps.id AS student_id');
$parentStudentCounts = DB::query()
->fromSub(
$familyStudentMap->union($directStudentMap),
'parent_students'
)
->select(
'parent_id',
DB::raw('COUNT(DISTINCT student_id) AS selected_year_students_count')
)
->groupBy('parent_id');
$invoiceBalances = DB::table('invoices')
->select('parent_id', DB::raw('COALESCE(SUM(balance), 0) AS balance'))
->whereNotNull('parent_id');
$this->applySchoolYearFilter($invoiceBalances, 'school_year', $schoolYear);
$invoiceBalances->groupBy('parent_id');
$query = DB::table('users as u')
->leftJoinSub($familyCounts, 'fc', function ($join): void {
$join->on('fc.user_id', '=', 'u.id');
})
->leftJoinSub($parentStudentCounts, 'psc', function ($join): void {
$join->on('psc.parent_id', '=', 'u.id');
})
->leftJoinSub($invoiceBalances, 'ib', function ($join): void {
$join->on('ib.parent_id', '=', 'u.id');
})
->select(
'u.id',
'u.firstname',
@@ -20,40 +68,52 @@ class ParentProfileAdminQueryService
'u.email',
'u.cellphone',
'u.status',
DB::raw('COUNT(DISTINCT fg.family_id) as families_count'),
DB::raw('COUNT(DISTINCT s.id) as selected_year_students_count'),
DB::raw('COALESCE(SUM(DISTINCT i.balance), 0) as balance')
DB::raw('COALESCE(fc.families_count, 0) AS families_count'),
DB::raw('COALESCE(psc.selected_year_students_count, 0) AS selected_year_students_count'),
DB::raw('COALESCE(ib.balance, 0) AS balance')
)
->leftJoin('family_guardians as fg', 'fg.user_id', '=', 'u.id')
->leftJoin('family_students as fs', 'fs.family_id', '=', 'fg.family_id')
->leftJoin('students as s', function ($join) use ($schoolYear) {
$join->on('s.id', '=', 'fs.student_id')
->where('s.school_year', '=', $schoolYear);
})
->leftJoin('invoices as i', function ($join) use ($schoolYear) {
$join->on('i.parent_id', '=', 'u.id')
->where('i.school_year', '=', $schoolYear);
})
->where($this->relevantParentConstraint($schoolYear))
->groupBy('u.id', 'u.firstname', 'u.lastname', 'u.email', 'u.cellphone', 'u.status')
->orderBy('u.lastname')
->orderBy('u.firstname');
$search = trim($search);
if ($search !== '') {
$needle = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$query->where(function ($inner) use ($needle) {
$inner->whereRaw("CONCAT_WS(' ', u.firstname, u.lastname) LIKE ?", [$needle])
$query->where(function ($inner) use ($needle, $schoolYear): void {
$inner
->whereRaw("CONCAT_WS(' ', u.firstname, u.lastname) LIKE ?", [$needle])
->orWhere('u.email', 'like', $needle)
->orWhere('u.cellphone', 'like', $needle)
->orWhereExists(function ($exists) use ($needle) {
->orWhereExists(function ($exists) use ($needle, $schoolYear): void {
$exists->selectRaw('1')
->from('family_guardians as sfg')
->join('family_students as sfs', 'sfs.family_id', '=', 'sfg.family_id')
->join('students as ss', 'ss.id', '=', 'sfs.student_id')
->whereColumn('sfg.user_id', 'u.id')
->where(function ($student) use ($needle) {
$student->whereRaw("CONCAT_WS(' ', ss.firstname, ss.lastname) LIKE ?", [$needle]);
});
->whereRaw("CONCAT_WS(' ', ss.firstname, ss.lastname) LIKE ?", [$needle]);
$this->constrainStudentToSchoolYear(
$exists,
'ss.id',
$schoolYear
);
})
->orWhereExists(function ($exists) use ($needle, $schoolYear): void {
$exists->selectRaw('1')
->from('students as direct_search_students')
->whereColumn('direct_search_students.parent_id', 'u.id')
->whereRaw(
"CONCAT_WS(' ', direct_search_students.firstname, direct_search_students.lastname) LIKE ?",
[$needle]
);
$this->constrainStudentToSchoolYear(
$exists,
'direct_search_students.id',
$schoolYear
);
});
});
}
@@ -76,7 +136,18 @@ class ParentProfileAdminQueryService
public function show(int $parentId, string $schoolYear): ?array
{
$parent = DB::table('users')
->select('id', 'firstname', 'lastname', 'email', 'cellphone', 'address_street', 'city', 'state', 'zip', 'status')
->select(
'id',
'firstname',
'lastname',
'email',
'cellphone',
'address_street',
'city',
'state',
'zip',
'status'
)
->where('id', $parentId)
->first();
@@ -91,13 +162,14 @@ class ParentProfileAdminQueryService
'lastname' => $parent->lastname,
],
];
$finance = $this->finance->loadFinancialsForParents([$parentId], $schoolYear);
return [
'parent' => (array) $parent,
'families' => $this->familiesForParent($parentId, $schoolYear),
'students' => $this->studentsForParent($parentId, $schoolYear),
'emergency_contacts' => $this->emergencyContactsForParents($guardians, $schoolYear),
'emergency_contacts' => $this->emergencyContactsForParents($guardians),
'invoices' => $finance['invoices'],
'payments' => $finance['payments'],
'finance_summary' => $finance['summary'],
@@ -131,6 +203,7 @@ class ParentProfileAdminQueryService
public function parentSummary(array $row, string $schoolYear): array
{
$balance = (float) ($row['balance'] ?? 0);
$primaryFamily = DB::table('family_guardians as fg')
->join('families as f', 'f.id', '=', 'fg.family_id')
->where('fg.user_id', (int) ($row['id'] ?? 0))
@@ -159,35 +232,66 @@ class ParentProfileAdminQueryService
private function relevantParentConstraint(string $schoolYear): callable
{
return function ($query) use ($schoolYear) {
$query->whereExists(function ($exists) use ($schoolYear) {
$exists->selectRaw('1')
->from('family_guardians as rfg')
->join('family_students as rfs', 'rfs.family_id', '=', 'rfg.family_id')
->join('students as rs', 'rs.id', '=', 'rfs.student_id')
->whereColumn('rfg.user_id', 'u.id')
->where('rs.school_year', $schoolYear);
})->orWhereExists(function ($exists) use ($schoolYear) {
$exists->selectRaw('1')
->from('students as direct_students')
->whereColumn('direct_students.parent_id', 'u.id')
->where('direct_students.school_year', $schoolYear);
})->orWhereExists(function ($exists) use ($schoolYear) {
$exists->selectRaw('1')
->from('invoices as ri')
->whereColumn('ri.parent_id', 'u.id')
->where('ri.school_year', $schoolYear);
})->orWhereExists(function ($exists) use ($schoolYear) {
$exists->selectRaw('1')
->from('parent_accounts as pa')
->whereColumn('pa.parent_id', 'u.id')
->where('pa.school_year', $schoolYear);
})->orWhereExists(function ($exists) use ($schoolYear) {
$exists->selectRaw('1')
->from('emergency_contacts as ec')
->whereColumn('ec.parent_id', 'u.id')
->where('ec.school_year', $schoolYear);
});
$schoolYearId = $this->schoolYearId($schoolYear);
return function ($query) use ($schoolYear, $schoolYearId): void {
$query
->whereExists(function ($exists) use ($schoolYear): void {
$exists->selectRaw('1')
->from('family_guardians as rfg')
->join('family_students as rfs', 'rfs.family_id', '=', 'rfg.family_id')
->whereColumn('rfg.user_id', 'u.id');
$this->constrainStudentToSchoolYear(
$exists,
'rfs.student_id',
$schoolYear
);
})
->orWhereExists(function ($exists) use ($schoolYear): void {
$exists->selectRaw('1')
->from('students as direct_students')
->whereColumn('direct_students.parent_id', 'u.id');
$this->constrainStudentToSchoolYear(
$exists,
'direct_students.id',
$schoolYear
);
})
->orWhereExists(function ($exists) use ($schoolYear): void {
$exists->selectRaw('1')
->from('invoices as ri')
->whereColumn('ri.parent_id', 'u.id');
$this->applySchoolYearFilter(
$exists,
'ri.school_year',
$schoolYear
);
});
if (! Schema::hasTable('parent_accounts')) {
return;
}
if ($this->isAllSchoolYears($schoolYear)) {
$query->orWhereExists(function ($exists): void {
$exists->selectRaw('1')
->from('parent_accounts as pa')
->whereColumn('pa.parent_id', 'u.id');
});
} elseif (
$schoolYearId !== null
&& Schema::hasColumn('parent_accounts', 'school_year_id')
) {
$query->orWhereExists(function ($exists) use ($schoolYearId): void {
$exists->selectRaw('1')
->from('parent_accounts as pa')
->whereColumn('pa.parent_id', 'u.id')
->where('pa.school_year_id', $schoolYearId);
});
}
};
}
@@ -196,15 +300,25 @@ class ParentProfileAdminQueryService
return DB::table('family_guardians as fg')
->join('families as f', 'f.id', '=', 'fg.family_id')
->where('fg.user_id', $parentId)
->whereExists(function ($exists) use ($schoolYear) {
->whereExists(function ($exists) use ($schoolYear): void {
$exists->selectRaw('1')
->from('family_students as fs')
->join('students as s', 's.id', '=', 'fs.student_id')
->whereColumn('fs.family_id', 'f.id')
->where('s.school_year', $schoolYear);
->whereColumn('fs.family_id', 'f.id');
$this->constrainStudentToSchoolYear(
$exists,
'fs.student_id',
$schoolYear
);
})
->orderBy('f.household_name')
->select('f.*', 'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms')
->select(
'f.*',
'fg.relation',
'fg.is_primary',
'fg.receive_emails',
'fg.receive_sms'
)
->get()
->map(fn ($row) => (array) $row)
->all();
@@ -212,19 +326,27 @@ class ParentProfileAdminQueryService
private function studentsForParent(int $parentId, string $schoolYear): array
{
$rows = DB::table('students as s')
$rowsQuery = DB::table('students as s')
->select('s.id', 's.firstname', 's.lastname', 's.parent_id')
->where('s.school_year', $schoolYear)
->where(function ($query) use ($parentId) {
$query->where('s.parent_id', $parentId)
->orWhereExists(function ($exists) use ($parentId) {
->where(function ($query) use ($parentId): void {
$query
->where('s.parent_id', $parentId)
->orWhereExists(function ($exists) use ($parentId): void {
$exists->selectRaw('1')
->from('family_guardians as fg')
->join('family_students as fs', 'fs.family_id', '=', 'fg.family_id')
->whereColumn('fs.student_id', 's.id')
->where('fg.user_id', $parentId);
});
})
});
$this->constrainStudentToSchoolYear(
$rowsQuery,
's.id',
$schoolYear
);
$rows = $rowsQuery
->orderBy('s.lastname')
->orderBy('s.firstname')
->get()
@@ -233,9 +355,11 @@ class ParentProfileAdminQueryService
foreach ($rows as &$row) {
$studentId = (int) ($row['id'] ?? 0);
$classSectionName = $studentId > 0
? (string) (StudentClass::getClassSectionsByStudentId($studentId, $schoolYear) ?? '')
: '';
$row['class_section_name'] = $classSectionName;
$row['grade'] = $classSectionName;
}
@@ -244,10 +368,10 @@ class ParentProfileAdminQueryService
return $rows;
}
private function emergencyContactsForParents(array $guardians, string $schoolYear): array
private function emergencyContactsForParents(array $guardians): array
{
$parentIds = array_values(array_filter(array_map(
static fn ($g) => (int) ($g['user_id'] ?? 0),
static fn ($guardian) => (int) ($guardian['user_id'] ?? 0),
$guardians
)));
@@ -256,12 +380,105 @@ class ParentProfileAdminQueryService
}
return DB::table('emergency_contacts')
->select('id', 'parent_id', 'emergency_contact_name', 'relation', 'cellphone', 'email', 'school_year', 'semester', 'created_at', 'updated_at')
->select(
'id',
'parent_id',
'emergency_contact_name',
'relation',
'cellphone',
'email',
'created_at',
'updated_at'
)
->whereIn('parent_id', $parentIds)
->where('school_year', $schoolYear)
->orderByDesc('updated_at')
->get()
->map(fn ($row) => (array) $row)
->all();
}
}
private function schoolYearStudentIds(string $schoolYear): QueryBuilder
{
$studentClass = DB::table('student_class')
->select('student_id');
$enrollments = DB::table('enrollments')
->select('student_id');
if (! $this->isAllSchoolYears($schoolYear)) {
$studentClass->where('school_year', $schoolYear);
$enrollments->where('school_year', $schoolYear);
}
return $studentClass->union($enrollments);
}
private function constrainStudentToSchoolYear(
QueryBuilder $query,
string $studentIdColumn,
string $schoolYear
): void {
if ($this->isAllSchoolYears($schoolYear)) {
return;
}
$query->where(function ($yearQuery) use ($studentIdColumn, $schoolYear): void {
$yearQuery
->whereExists(function ($exists) use ($studentIdColumn, $schoolYear): void {
$exists->selectRaw('1')
->from('student_class as year_sc')
->whereColumn('year_sc.student_id', $studentIdColumn)
->where('year_sc.school_year', $schoolYear);
})
->orWhereExists(function ($exists) use ($studentIdColumn, $schoolYear): void {
$exists->selectRaw('1')
->from('enrollments as year_e')
->whereColumn('year_e.student_id', $studentIdColumn)
->where('year_e.school_year', $schoolYear);
});
});
}
private function schoolYearId(string $schoolYear): ?int
{
if ($this->isAllSchoolYears($schoolYear) || ! Schema::hasTable('school_years')) {
return null;
}
$schoolYear = trim($schoolYear);
foreach (['school_year', 'name', 'year', 'label'] as $column) {
if (! Schema::hasColumn('school_years', $column)) {
continue;
}
$id = DB::table('school_years')
->where($column, $schoolYear)
->value('id');
if ($id !== null) {
return (int) $id;
}
}
return null;
}
private function applySchoolYearFilter(
QueryBuilder $query,
string $column,
string $schoolYear
): void {
if (! $this->isAllSchoolYears($schoolYear)) {
$query->where($column, $schoolYear);
}
}
private function isAllSchoolYears(string $schoolYear): bool
{
$schoolYear = trim($schoolYear);
return $schoolYear === '' || strtolower($schoolYear) === 'all';
}
}