fix enrollment for the new school-year
Tests / PHPUnit (push) Successful in 1m26s

This commit is contained in:
root
2026-08-07 23:43:31 -04:00
parent b6f3b14e7b
commit 1ef2800f12
66 changed files with 8997 additions and 498 deletions
@@ -0,0 +1,633 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
use Throwable;
class EnrollmentAdminController extends BaseController
{
protected BaseConnection $db;
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
parent::initController($request, $response, $logger);
$this->db = \Config\Database::connect();
}
public function dashboard()
{
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->currentSchoolYearName((string) ($this->schoolYear ?? ''))));
$status = trim((string) ($this->request->getGet('status') ?? 'open'));
$flagType = trim((string) ($this->request->getGet('flag_type') ?? ''));
$assignedTo = trim((string) ($this->request->getGet('assigned_to') ?? ''));
$flags = $this->enrollmentFlags($schoolYear, $status, $flagType, $assignedTo);
return view('administrator/enrollment_admin_dashboard', [
'flags' => $flags,
'schoolYear' => $schoolYear,
'status' => $status,
'flagType' => $flagType,
'assignedTo' => $assignedTo,
'flagTypes' => $this->flagTypes(),
'schoolYears' => $this->schoolYears(),
'classSections' => $this->classSections($schoolYear),
'admins' => $this->adminUsers(),
'enrollmentFollowups' => $this->enrollmentFollowups($schoolYear),
'auditRows' => $this->auditRows($schoolYear),
'launchState' => $this->launchState($schoolYear),
'previewParentId' => $this->firstParentWithStudents(),
'emailExamples' => service('enrollmentRegistrationEmail')->previewExamplesForSchoolYear($schoolYear),
]);
}
public function approveLaunch()
{
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? ''));
if ($schoolYear === '') {
return redirect()->back()->with('error', 'School year is required.');
}
if (! $this->launchConfigurationComplete($schoolYear, $missing)) {
return redirect()->back()->with('error', 'Registration launch is not ready: ' . implode(', ', $missing));
}
$this->db->table('school_years')
->where('name', $schoolYear)
->update([
'registration_launch_approved_at' => date('Y-m-d H:i:s'),
'registration_launch_approved_by' => $this->userId(),
'registration_email_template_version' => \App\Services\EnrollmentRegistrationEmailService::TEMPLATE_VERSION,
'updated_at' => date('Y-m-d H:i:s'),
]);
return redirect()->back()->with('success', 'Registration launch approved for ' . $schoolYear . '.');
}
public function sendRegistrationEmails()
{
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? ''));
if ($schoolYear === '') {
return redirect()->back()->with('error', 'School year is required.');
}
if (! $this->launchConfigurationComplete($schoolYear, $missing)) {
return redirect()->back()->with('error', 'Registration launch is not ready: ' . implode(', ', $missing));
}
$force = (bool) $this->request->getPost('force_resend');
$result = service('enrollmentRegistrationEmail')->sendForSchoolYearName($schoolYear, $force);
$message = sprintf(
'Registration emails processed for %s: %d sent, %d failed, %d skipped.',
$schoolYear,
(int) ($result['sent'] ?? 0),
(int) ($result['failed'] ?? 0),
(int) ($result['skipped'] ?? 0)
);
$details = array_filter(array_map('strval', $result['messages'] ?? []));
if ($details !== []) {
$message .= ' ' . implode(' ', $details);
}
return redirect()->back()->with(((int) ($result['failed'] ?? 0) > 0) ? 'error' : 'success', $message);
}
public function previewEmail()
{
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
$parentId = (int) ($this->request->getGet('parent_id') ?? 0);
if ($schoolYear === '' || $parentId <= 0) {
return redirect()->back()->with('error', 'School year and parent are required for preview.');
}
$message = service('enrollmentRegistrationEmail')->previewForParent($schoolYear, $parentId);
if ($message === null) {
return redirect()->back()->with('error', 'No preview email could be generated for that parent.');
}
return view('administrator/enrollment_email_preview', [
'schoolYear' => $schoolYear,
'parentId' => $parentId,
'subject' => $message['subject'],
'body' => $message['body'],
]);
}
public function resolveFlag(int $id)
{
try {
$flag = $this->requireFlag($id);
$notes = trim((string) ($this->request->getPost('resolution_notes') ?? ''));
if ($notes === '') {
return redirect()->back()->with('error', 'Resolution notes are required.');
}
$this->resolveFlagRow($flag, $notes, 'flag_resolved');
return redirect()->back()->with('success', 'Enrollment flag resolved.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function assignClass(int $id)
{
try {
$flag = $this->requireFlag($id);
$sectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
$notes = trim((string) ($this->request->getPost('resolution_notes') ?? ''));
if ($sectionId <= 0) {
return redirect()->back()->with('error', 'Select a target class section.');
}
if ($notes === '') {
return redirect()->back()->with('error', 'Resolution notes are required.');
}
$this->applyClassSection((int) $flag['student_id'], (string) $flag['school_year'], $sectionId, 'manual_class_assignment');
$this->resolveFlagRow($flag, $notes, 'manual_class_assignment', ['class_section_id' => $sectionId]);
return redirect()->back()->with('success', 'Class assignment applied.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function confirmMakeupPromotion(int $id)
{
try {
$flag = $this->requireFlag($id);
if ((string) ($flag['flag_type'] ?? '') !== 'PENDING_MAKE_UP_EXAM_PROMOTION') {
return redirect()->back()->with('error', 'This flag is not a make-up exam promotion flag.');
}
$sectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
$examResult = trim((string) ($this->request->getPost('exam_result') ?? ''));
$notes = trim((string) ($this->request->getPost('resolution_notes') ?? ''));
if (! in_array($examResult, ['passed', 'failed'], true)) {
return redirect()->back()->with('error', 'Select whether the make-up exam was passed or failed.');
}
if ($notes === '') {
return redirect()->back()->with('error', 'Resolution notes are required.');
}
if ($examResult === 'passed') {
if ($sectionId <= 0) {
return redirect()->back()->with('error', 'Select the promoted class section.');
}
$this->applyClassSection((int) $flag['student_id'], (string) $flag['school_year'], $sectionId, 'make_up_exam_promotion_completed', 'promotion_completed');
$this->resolveFlagRow($flag, $notes, 'make_up_exam_promotion_completed', [
'exam_result' => $examResult,
'class_section_id' => $sectionId,
]);
} else {
$this->updateEnrollmentPlacementStatus((int) $flag['student_id'], (string) $flag['school_year'], 'no_promotion_required');
$this->resolveFlagRow($flag, $notes, 'make_up_exam_no_promotion_required', [
'exam_result' => $examResult,
]);
}
return redirect()->back()->with('success', 'Make-up exam follow-up resolved.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function approveException(int $id)
{
try {
$flag = $this->requireFlag($id);
$reason = trim((string) ($this->request->getPost('reason') ?? ''));
if ($reason === '') {
return redirect()->back()->with('error', 'Approval reason is required.');
}
$this->db->table('enrollments')
->where('student_id', (int) $flag['student_id'])
->where('school_year', (string) $flag['school_year'])
->update([
'exception_required' => 0,
'exception_reason' => $reason,
'updated_at' => date('Y-m-d H:i:s'),
]);
$this->resolveFlagRow($flag, $reason, 'enrollment_exception_approved');
return redirect()->back()->with('success', 'Enrollment exception approved.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
private function enrollmentFlags(string $schoolYear, string $status, string $flagType, string $assignedTo): array
{
if (! $this->db->tableExists('enrollment_flags')) {
return [];
}
$builder = $this->db->table('enrollment_flags ef')
->select('ef.*')
->select('s.firstname, s.lastname, s.school_id')
->select('u.firstname AS assignee_firstname, u.lastname AS assignee_lastname')
->join('students s', 's.id = ef.student_id', 'left')
->join('users u', 'u.id = ef.assigned_to', 'left')
->orderBy('ef.created_at', 'DESC')
->orderBy('ef.id', 'DESC');
if ($schoolYear !== '') {
$builder->where('ef.school_year', $schoolYear);
}
if ($status !== '') {
$builder->where('ef.status', $status);
}
if ($flagType !== '') {
$builder->where('ef.flag_type', $flagType);
}
if (is_numeric($assignedTo) && (int) $assignedTo > 0) {
$builder->where('ef.assigned_to', (int) $assignedTo);
}
$rows = $builder->get()->getResultArray();
foreach ($rows as &$row) {
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
$row['assignee_name'] = trim((string) ($row['assignee_firstname'] ?? '') . ' ' . (string) ($row['assignee_lastname'] ?? ''));
$row['details'] = json_decode((string) ($row['details_json'] ?? ''), true) ?: [];
}
unset($row);
return $rows;
}
private function enrollmentFollowups(string $schoolYear): array
{
if (! $this->db->tableExists('enrollments')) {
return [];
}
$fields = $this->db->getFieldNames('enrollments');
$select = [
'e.id',
'e.student_id',
'e.school_year',
'e.enrollment_status',
'e.class_section_id',
'e.updated_at',
's.firstname',
's.lastname',
's.school_id',
'cs.class_section_name',
];
foreach ([
'deliberation_decision',
'placement_status',
'exception_required',
'exception_reason',
'source_school_year',
'assigned_class_section_id',
'age_on_reference_date',
] as $field) {
if (in_array($field, $fields, true)) {
$select[] = 'e.' . $field;
}
}
$builder = $this->db->table('enrollments e')
->select(implode(', ', $select))
->join('students s', 's.id = e.student_id', 'left')
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
->orderBy('e.updated_at', 'DESC')
->orderBy('e.id', 'DESC')
->limit(200);
if ($schoolYear !== '') {
$builder->where('e.school_year', $schoolYear);
}
$builder->groupStart()
->whereIn('e.enrollment_status', [
'review & decision',
'admission under review',
'waitlist',
'denied',
'Review & Decision',
'Admission Under Review',
'Waitlist',
'Denied',
]);
if (in_array('placement_status', $fields, true)) {
$builder->orWhereIn('e.placement_status', [
'temporary_same_grade',
'temporary_manual_class_required',
'manual_class_required',
'exit_required',
'automatic_distribution_pending',
]);
}
if (in_array('exception_required', $fields, true)) {
$builder->orWhere('e.exception_required', 1);
}
if (in_array('deliberation_decision', $fields, true)) {
$builder->orWhereIn('e.deliberation_decision', [
'make_up_exam',
'repeat_class',
'deferred',
'expelled',
'withdrawn',
]);
}
$rows = $builder->groupEnd()->get()->getResultArray();
foreach ($rows as &$row) {
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
}
unset($row);
return $rows;
}
private function requireFlag(int $id): array
{
if ($id <= 0 || ! $this->db->tableExists('enrollment_flags')) {
throw new \RuntimeException('Enrollment flag was not found.');
}
$flag = $this->db->table('enrollment_flags')->where('id', $id)->limit(1)->get()->getRowArray();
if ($flag === null) {
throw new \RuntimeException('Enrollment flag was not found.');
}
return $flag;
}
private function resolveFlagRow(array $flag, string $notes, string $auditAction, array $metadata = []): void
{
$this->db->transStart();
$original = $flag;
$this->db->table('enrollment_flags')
->where('id', (int) $flag['id'])
->update([
'status' => 'resolved',
'resolved_at' => date('Y-m-d H:i:s'),
'resolution_notes' => $notes,
]);
$this->audit((int) $flag['student_id'], (string) $flag['school_year'], (string) ($flag['source_school_year'] ?? ''), $auditAction, $original, array_merge($metadata, [
'flag_id' => (int) $flag['id'],
'flag_type' => (string) $flag['flag_type'],
'resolution_notes' => $notes,
]), $notes);
$this->db->transComplete();
if ($this->db->transStatus() === false) {
throw new \RuntimeException('Unable to resolve enrollment flag.');
}
}
private function applyClassSection(int $studentId, string $schoolYear, int $sectionId, string $auditAction, string $placementStatus = 'manual_class_assigned'): void
{
$section = $this->db->table('classSection')
->select('class_section_id, class_id, class_section_name')
->where('class_section_id', $sectionId)
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
if ($section === null) {
throw new \RuntimeException('Selected class section was not found.');
}
$originalEnrollment = $this->latestEnrollment($studentId, $schoolYear);
$payload = [
'class_section_id' => $sectionId,
'assigned_class_section_id' => $sectionId,
'assigned_grade_id' => (int) ($section['class_id'] ?? 0) ?: null,
'placement_status' => $placementStatus,
'updated_at' => date('Y-m-d H:i:s'),
];
$this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->update($payload);
$studentClass = $this->db->table('student_class')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
$studentClassPayload = [
'student_id' => $studentId,
'class_section_id' => $sectionId,
'school_year' => $schoolYear,
'updated_by' => $this->userId(),
'updated_at' => date('Y-m-d H:i:s'),
];
if ($studentClass !== null) {
$this->db->table('student_class')->where('id', (int) $studentClass['id'])->update($studentClassPayload);
} else {
$studentClassPayload['created_at'] = date('Y-m-d H:i:s');
$this->db->table('student_class')->insert($studentClassPayload);
}
$this->audit($studentId, $schoolYear, (string) ($originalEnrollment['source_school_year'] ?? ''), $auditAction, $originalEnrollment, $payload, 'Class section assigned by administrator.');
}
private function updateEnrollmentPlacementStatus(int $studentId, string $schoolYear, string $placementStatus): void
{
$this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->update([
'placement_status' => $placementStatus,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
private function latestEnrollment(int $studentId, string $schoolYear): ?array
{
return $this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray() ?: null;
}
private function audit(int $studentId, string $schoolYear, string $sourceSchoolYear, string $action, ?array $original, array $new, string $reason): void
{
if (! $this->db->tableExists('enrollment_transition_audits')) {
return;
}
$this->db->table('enrollment_transition_audits')->insert([
'student_id' => $studentId,
'school_year' => $schoolYear,
'source_school_year' => $sourceSchoolYear !== '' ? $sourceSchoolYear : null,
'action' => $action,
'performed_by' => $this->userId(),
'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null,
'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES),
'reason' => $reason,
'created_at' => date('Y-m-d H:i:s'),
]);
}
private function auditRows(string $schoolYear): array
{
if (! $this->db->tableExists('enrollment_transition_audits')) {
return [];
}
$builder = $this->db->table('enrollment_transition_audits eta')
->select('eta.*')
->select('s.firstname, s.lastname')
->select('u.firstname AS user_firstname, u.lastname AS user_lastname')
->join('students s', 's.id = eta.student_id', 'left')
->join('users u', 'u.id = eta.performed_by', 'left')
->orderBy('eta.created_at', 'DESC')
->limit(50);
if ($schoolYear !== '') {
$builder->where('eta.school_year', $schoolYear);
}
$rows = $builder->get()->getResultArray();
foreach ($rows as &$row) {
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
$row['performed_by_name'] = trim((string) ($row['user_firstname'] ?? '') . ' ' . (string) ($row['user_lastname'] ?? '')) ?: ((int) ($row['performed_by'] ?? 0) > 0 ? 'User #' . (int) $row['performed_by'] : '');
}
unset($row);
return $rows;
}
private function launchState(string $schoolYear): array
{
if ($schoolYear === '' || ! $this->db->tableExists('school_years')) {
return ['approved' => false, 'approved_at' => null, 'missing' => ['school year']];
}
$row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray();
$this->launchConfigurationComplete($schoolYear, $missing);
return [
'approved' => ! empty($row['registration_launch_approved_at'] ?? null),
'approved_at' => $row['registration_launch_approved_at'] ?? null,
'missing' => $missing,
];
}
private function launchConfigurationComplete(string $schoolYear, ?array &$missing = null): bool
{
$missing = [];
$row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray();
if ($row === null) {
$missing[] = 'school year record';
return false;
}
foreach (['registration_starts_on' => 'registration opening date', 'registration_ends_on' => 'registration deadline'] as $field => $label) {
if (empty($row[$field])) {
$missing[] = $label;
}
}
if (! $this->db->tableExists('email_templates')) {
$missing[] = 'email templates table';
} else {
$fields = $this->db->getFieldNames('email_templates');
$keyField = in_array('code', $fields, true) ? 'code' : 'template_key';
$template = $this->db->table('email_templates')
->where($keyField, 'registration_opening')
->where('is_active', 1)
->countAllResults();
if ($template <= 0) {
$missing[] = 'approved registration email template';
}
}
return $missing === [];
}
private function firstParentWithStudents(): ?int
{
if (! $this->db->tableExists('students')) {
return null;
}
$row = $this->db->table('students')
->select('parent_id')
->where('parent_id IS NOT NULL', null, false)
->orderBy('parent_id', 'ASC')
->limit(1)
->get()
->getRowArray();
return is_numeric($row['parent_id'] ?? null) ? (int) $row['parent_id'] : null;
}
private function flagTypes(): array
{
if (! $this->db->tableExists('enrollment_flags')) {
return [];
}
return array_column($this->db->table('enrollment_flags')->select('flag_type')->distinct()->orderBy('flag_type')->get()->getResultArray(), 'flag_type');
}
private function schoolYears(): array
{
if (! $this->db->tableExists('school_years')) {
return [];
}
return $this->db->table('school_years')->select('name')->orderBy('name', 'DESC')->get()->getResultArray();
}
private function classSections(string $schoolYear): array
{
if (! $this->db->tableExists('classSection')) {
return [];
}
$builder = $this->db->table('classSection')->select('class_section_id, class_section_name')->orderBy('class_section_name', 'ASC');
if ($schoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) {
$builder->where('school_year', $schoolYear);
}
return $builder->get()->getResultArray();
}
private function adminUsers(): array
{
if (! $this->db->tableExists('users')) {
return [];
}
return $this->db->table('users')
->select('id, firstname, lastname, user_type')
->whereIn('user_type', ['administrator', 'admin', 'principal', 'administrative staff'])
->orderBy('lastname', 'ASC')
->get()
->getResultArray();
}
private function userId(): ?int
{
$id = session('user_id') ?? session('id');
return is_numeric($id) ? (int) $id : null;
}
}