Files
alrahma_sunday_school/app/Services/EnrollmentRegistrationEmailService.php
T
root 9899af7b37
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m18s
fix all issues before deploy
2026-08-25 03:10:44 -04:00

701 lines
28 KiB
PHP

<?php
namespace App\Services;
use App\Models\ConfigurationModel;
use App\Support\Enrollment\DeliberationDecision;
use App\Support\Enrollment\EnrollmentEligibility;
use CodeIgniter\Database\BaseConnection;
use DateTimeInterface;
final class EnrollmentRegistrationEmailService
{
public const TEMPLATE_VERSION = 'phase5_consolidated_v3';
public function __construct(
private readonly BaseConnection $db,
private readonly EnrollmentTransitionService $transitionService,
private readonly EmailService $emailService,
private readonly ?ConfigurationModel $configurationModel = null,
) {
}
public function sendForDate(DateTimeInterface $date, bool $force = false, ?string $testEmail = null, bool $dryRun = false): array
{
$summary = [
'school_years' => 0,
'recipients' => 0,
'sent' => 0,
'failed' => 0,
'skipped' => 0,
'dry_run' => $dryRun,
'messages' => [],
];
foreach ($this->registrationYearsForDate($date, $force) as $schoolYear) {
$summary['school_years']++;
if (! $dryRun && empty($schoolYear['registration_launch_approved_at'])) {
$summary['messages'][] = 'Registration launch is not approved for ' . (string) ($schoolYear['name'] ?? '') . '.';
$summary['skipped']++;
continue;
}
$result = $this->sendForSchoolYear($schoolYear, $testEmail, $dryRun, $force);
foreach (['recipients', 'sent', 'failed', 'skipped'] as $key) {
$summary[$key] += $result[$key] ?? 0;
}
array_push($summary['messages'], ...($result['messages'] ?? []));
}
return $summary;
}
public function sendForSchoolYear(array $schoolYear, ?string $testEmail = null, bool $dryRun = false, bool $force = false): array
{
$summary = ['recipients' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 0, 'messages' => []];
if (! $dryRun && empty($schoolYear['registration_launch_approved_at'])) {
$summary['messages'][] = 'Registration launch is not approved for ' . (string) ($schoolYear['name'] ?? '') . '.';
$summary['skipped']++;
return $summary;
}
$families = $this->recipientFamilies($schoolYear, $testEmail);
if ($families === []) {
$summary['messages'][] = 'No recipient families found for ' . (string) ($schoolYear['name'] ?? '') . '.';
return $summary;
}
foreach ($families as $family) {
if ($testEmail === null && ! $force && $this->alreadySent((string) $schoolYear['name'], (int) $family['parent_user_id'])) {
$summary['skipped']++;
continue;
}
$message = $this->buildMessage($schoolYear, $family);
if ($testEmail !== null) {
$message['subject'] = '[TEST] ' . $message['subject'];
$message['recipients'] = [$testEmail];
}
$summary['recipients']++;
$recordId = $dryRun || ! $this->db->tableExists('enrollment_email_records') ? null : $this->recordGenerated($schoolYear, $family, $message);
$sent = $dryRun || $this->emailService->send($message['recipients'][0], $message['subject'], $message['body'], 'general');
$sent ? $summary['sent']++ : $summary['failed']++;
if (! $dryRun && $recordId !== null) {
$this->recordDelivery($recordId, $sent, $sent ? null : 'Email send failed');
}
}
return $summary;
}
public function previewForParent(string $schoolYearName, int $parentId): ?array
{
$schoolYear = $this->schoolYearByName($schoolYearName);
if ($schoolYear === null) {
return null;
}
foreach ($this->recipientFamilies($schoolYear, null) as $family) {
if ((int) $family['parent_user_id'] === $parentId) {
return $this->buildMessage($schoolYear, $family);
}
}
return null;
}
public function previewExamplesForSchoolYear(string $schoolYearName): array
{
$schoolYear = $this->schoolYearByName($schoolYearName);
if ($schoolYear === null) {
return [];
}
$examples = [];
foreach ($this->recipientFamilies($schoolYear, null) as $family) {
$message = $this->buildMessage($schoolYear, $family);
$latest = $this->latestEmailRecord($schoolYearName, (int) $family['parent_user_id']);
$previousYear = $this->previousSchoolYearName($schoolYearName);
$studentSummaries = [];
foreach ($family['students'] as $student) {
$studentId = (int) ($student['id'] ?? 0);
$evaluation = $studentId > 0 && $previousYear !== null
? $this->transitionService->evaluateForParent(
(int) $family['parent_user_id'],
$studentId,
$previousYear,
$schoolYearName
)
: [];
$studentSummaries[] = [
'name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . $studentId,
'adult_student' => ! empty($evaluation['adult_student']),
'issue_code' => $this->studentIssueCode($evaluation),
];
}
$examples[] = [
'parent_user_id' => (int) $family['parent_user_id'],
'parent_name' => (string) $family['name'],
'recipients' => $message['recipients'],
'students' => $studentSummaries,
'student_names' => array_column($studentSummaries, 'name'),
'subject' => (string) $message['subject'],
'delivery_status' => (string) ($latest['delivery_status'] ?? 'not sent'),
'sent_at' => $latest['sent_at'] ?? null,
'failure_reason' => $latest['failure_reason'] ?? null,
];
}
return $examples;
}
public function sendForSchoolYearName(string $schoolYearName, bool $force = false): array
{
$schoolYear = $this->schoolYearByName($schoolYearName);
if ($schoolYear === null) {
return ['recipients' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 1, 'messages' => ['School year was not found.']];
}
return $this->sendForSchoolYear($schoolYear, null, false, $force);
}
private function buildMessage(array $schoolYear, array $family): array
{
$schoolYearName = (string) ($schoolYear['name'] ?? '');
$previousYear = $this->previousSchoolYearName($schoolYearName);
$deadline = $this->dateText($schoolYear['registration_deadline_at'] ?? $schoolYear['registration_ends_on'] ?? null);
$opens = $this->dateText($schoolYear['registration_opens_at'] ?? $schoolYear['registration_starts_on'] ?? null);
$studentRows = [];
$studentIds = [];
$hasReEnrollmentEligibleStudent = false;
foreach ($family['students'] as $student) {
$studentId = (int) ($student['id'] ?? 0);
if ($studentId <= 0 || $previousYear === null) {
continue;
}
$evaluation = $this->transitionService->evaluateForParent(
(int) $family['parent_user_id'],
$studentId,
$previousYear,
$schoolYearName
);
$studentRows[] = $this->studentRow($student, $evaluation, $opens, $deadline, $schoolYear);
$studentIds[] = $studentId;
$hasReEnrollmentEligibleStudent = $hasReEnrollmentEligibleStudent || (bool) ($evaluation['can_enroll'] ?? false);
}
$financial = $this->financialSection((int) $family['parent_user_id'], $schoolYear, $previousYear);
$subject = 'Registration for ' . $schoolYearName . ' Is Now Open';
$bodyHtml = '<p>Dear ' . esc($family['name']) . ',</p>'
. '<p>Registration for the ' . esc($schoolYearName) . ' school year is now open. Please review the student(s) summary below and complete the portal steps before ' . esc($deadline) . '.</p>'
. $this->studentsTable($studentRows)
. $financial
. $this->registrationStepsSection($opens, $deadline, $hasReEnrollmentEligibleStudent)
. '<p>For assistance, please contact the school administration.</p>'
. '<p>Sincerely,<br>Al Rahma Sunday School<br>School Administration</p>';
$body = view('emails/_wrap_layout', [
'title' => $subject,
'body_html' => $bodyHtml,
], ['saveData' => true]);
return [
'subject' => $subject,
'body' => $body,
'recipients' => $family['recipients'],
'student_ids' => array_values(array_unique($studentIds)),
];
}
private function canCompleteParentReEnrollment(array $evaluation): bool
{
return (bool) ($evaluation['can_enroll'] ?? false);
}
private function registrationStepsSection(string $opens, string $deadline, bool $include): string
{
if (! $include) {
return '';
}
return '<h3>Re-enrollment Steps</h3>'
. '<p>Re-enrollment opens on ' . esc($opens) . ' and closes on ' . esc($deadline) . '. Complete re-enrollment for each eligible student before the deadline to secure their enrollment for the upcoming school year.</p>'
. '<ol><li>Sign in to the parent portal.</li><li>Review/update student and contact information.</li><li>Acknowledge school policies.</li><li>Review tuition, fees and any carry-over balance.</li><li>Submit Enrollment.</li></ol>'
. '<p>Registration portal: <a href="' . esc(site_url('parent/enroll_classes')) . '">' . esc(site_url('parent/enroll_classes')) . '</a></p>';
}
private function studentsTable(array $studentRows): string
{
if ($studentRows === []) {
return '<p>No eligible linked students were found for this email.</p>';
}
return '<div style="margin:12px 0 18px;">'
. implode('', $studentRows)
. '</div>';
}
private function studentRow(array $student, array $evaluation, string $opens, string $deadline, array $schoolYear = []): string
{
$name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student';
$decision = DeliberationDecision::display((string) ($evaluation['deliberation_decision'] ?? ''));
$status = $this->registrationStatus($evaluation);
$placement = $this->placementText($evaluation);
$requiredAction = $this->requiredAction($evaluation, $deadline, $name);
$message = $this->decisionMessage($name, $evaluation, $opens, $deadline, $schoolYear);
$nextStepHtml = $this->decisionMessageHtml($message, (string) ($evaluation['deliberation_decision'] ?? ''));
if ($this->shouldAppendRequiredAction($message, $requiredAction)) {
$nextStepHtml .= '<br><strong>' . esc($requiredAction) . '</strong>';
}
return '<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%; border:1px solid #d9e2ec; border-collapse:collapse; margin:0 0 12px; font-size:14px; line-height:1.45;">'
. '<tbody>'
. '<tr>'
. '<td style="background:#f4f8fb; border-bottom:1px solid #d9e2ec; padding:10px 12px; vertical-align:top; word-break:break-word;">'
. '<strong style="font-size:16px; color:#1f2937;">' . esc($name) . '</strong>'
. '</td>'
. '</tr>'
. $this->studentDetailRow('Decision', esc($decision ?: 'Pending'))
. $this->studentDetailRow('Registration Status', '<strong>' . esc($status) . '</strong>' . ($placement !== '' ? '<br>' . esc($placement) : ''))
. $this->studentDetailRow('Next Step', $nextStepHtml)
. '</tbody></table>';
}
private function studentDetailRow(string $label, string $valueHtml): string
{
return '<tr>'
. '<td style="border-bottom:1px solid #edf2f7; padding:10px 12px; vertical-align:top; word-break:break-word;">'
. '<div style="font-size:12px; line-height:1.3; color:#64748b; text-transform:uppercase; font-weight:bold; margin:0 0 4px;">' . esc($label) . '</div>'
. '<div style="color:#1f2937;">' . $valueHtml . '</div>'
. '</td>'
. '</tr>';
}
private function decisionMessageHtml(string $message, string $decision): string
{
if ($decision !== DeliberationDecision::MAKE_UP_EXAM) {
return nl2br(esc($message), false);
}
$lines = explode("\n", $message);
$lastIndex = count($lines) - 1;
foreach ($lines as $index => $line) {
$escaped = esc($line);
$lines[$index] = $index === $lastIndex ? '<strong>' . $escaped . '</strong>' : $escaped;
}
return implode('<br>', $lines);
}
private function decisionMessage(string $name, array $evaluation, string $opens, string $deadline, array $schoolYear = []): string
{
if (! empty($evaluation['kg_missing_decision_eligible']) && ! $this->hasNonKgMissingDecisionBlocker($evaluation)) {
$placement = $this->placementText($evaluation);
$placementText = $placement !== '' ? ' The student placement for the new school year is: ' . $placement . '.' : '';
return 'KG students may complete registration now. ' . $name . ' is eligible for re-enrollment even though no final deliberation decision is recorded.' . $placementText;
}
if (($evaluation['can_enroll'] ?? false) === false && ! empty($evaluation['primary_parent_message'])) {
return (string) $evaluation['primary_parent_message'];
}
if (($evaluation['blockers'] ?? []) !== []) {
return implode(' ', array_values(array_unique(array_filter(array_map(
static fn ($blocker): string => trim((string) $blocker),
$evaluation['blockers']
)))));
}
return match ((string) ($evaluation['deliberation_decision'] ?? '')) {
DeliberationDecision::REPEAT_CLASS => 'The deliberation decision for ' . $name . ' is to repeat the current grade. After re-enrollment is completed, the student will remain in the same grade.',
DeliberationDecision::MAKE_UP_EXAM => 'The final academic decision for ' . $name . ' is currently pending the result of a make-up exam. This exam is scheduled for ' . $this->makeupExamDateText($schoolYear) . ' from 9:30 AM to 11:00 AM at ISGL.'
. "\n" . 'The exam result will determine whether the student advances to the next grade or repeats the current class. Failure to attend the make-up exam will automatically result in the student repeating the class, as no further retake opportunities will be available.'
. "\n" . 'You must re-enroll ' . $name . ' before the make-up exam can be taken.',
default => 'Please review the registration portal for the current enrollment status.',
};
}
private function makeupExamDateText(array $schoolYear = []): string
{
$raw = trim((string) ($schoolYear['fall_makeup_exam_on'] ?? ''));
if ($raw === '') {
$configModel = $this->configurationModel ?? new ConfigurationModel();
foreach (['make-up-exam', 'make_up_exam', 'makeup_exam_day'] as $key) {
$raw = trim((string) ($configModel->getConfig($key) ?? ''));
if ($raw !== '') {
break;
}
}
}
if ($raw === '') {
return 'a date to be announced';
}
$timestamp = strtotime($raw);
if ($timestamp !== false) {
return date('m-d-Y', $timestamp);
}
return $raw;
}
private function financialSection(int $parentId, array $schoolYear, ?string $previousYear): string
{
$schoolYearName = (string) ($schoolYear['name'] ?? '');
$summary = $this->transitionService->getEnrollmentFinancialSummary(
$parentId,
$previousYear ?? '',
$schoolYearName
);
$total = (float) ($summary['total_enrollment_due'] ?? $summary['amount_due'] ?? 0);
if ($total <= 0.0) {
return '';
}
$message = trim((string) ($schoolYear['financial_policy_message'] ?? ''));
if ($message === '') {
$message = 'The balance needs to be settled with the school before the re-enrollment process can be started.';
}
return '<h3>Family Account Information</h3>'
. '<p><strong>Carry-over balance:</strong> $' . number_format((float) ($summary['carry_forward_balance'] ?? 0), 2) . '<br>'
. '<strong>Total currently due:</strong> $' . number_format($total, 2) . '</p>'
. '<p>' . esc($message) . '</p>';
}
private function recipientFamilies(array $schoolYear, ?string $testEmail): array
{
$builder = $this->db->table('users u')
->select('u.id AS parent_user_id, u.firstname, u.lastname, u.email')
->where('u.email IS NOT NULL')
->where('u.email !=', '');
if ($this->db->fieldExists('user_type', 'users')) {
$builder->where('u.user_type', 'primary');
}
if ($testEmail !== null) {
$builder->limit(1);
}
$families = [];
foreach ($builder->get()->getResultArray() as $row) {
$parentId = (int) ($row['parent_user_id'] ?? 0);
$students = $this->studentsForParent($parentId);
if ($students === []) {
continue;
}
$email = $testEmail ?? strtolower(trim((string) ($row['email'] ?? '')));
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
$name = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Parent or Guardian';
$families[$parentId] = [
'parent_user_id' => $parentId,
'family_account_id' => $parentId,
'name' => $name,
'recipients' => [$email],
'students' => $students,
];
}
return array_values($families);
}
private function studentsForParent(int $parentId): array
{
return $this->db->table('students')
->select('id, firstname, lastname, dob')
->where('parent_id', $parentId)
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->get()
->getResultArray();
}
private function recordGenerated(array $schoolYear, array $family, array $message): int
{
$now = date('Y-m-d H:i:s');
$this->db->table('enrollment_email_records')->insert([
'school_year' => (string) ($schoolYear['name'] ?? ''),
'school_year_id' => (int) ($schoolYear['id'] ?? 0) ?: null,
'family_account_id' => (int) ($family['family_account_id'] ?? 0) ?: null,
'parent_user_id' => (int) ($family['parent_user_id'] ?? 0) ?: null,
'recipient_addresses_json' => json_encode($message['recipients']),
'template_version' => self::TEMPLATE_VERSION,
'generated_subject' => $message['subject'],
'generated_body' => $message['body'],
'student_ids_included_json' => json_encode($message['student_ids']),
'generated_at' => $now,
'delivery_status' => 'generated',
'created_at' => $now,
'updated_at' => $now,
]);
return (int) $this->db->insertID();
}
private function recordDelivery(int $recordId, bool $sent, ?string $failure): void
{
$this->db->table('enrollment_email_records')
->where('id', $recordId)
->update([
'delivery_status' => $sent ? 'sent' : 'failed',
'sent_at' => $sent ? date('Y-m-d H:i:s') : null,
'failure_reason' => $failure,
'retry_count' => $sent ? 0 : 1,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
private function alreadySent(string $schoolYear, int $parentId): bool
{
if (! $this->db->tableExists('enrollment_email_records')) {
return false;
}
return $this->db->table('enrollment_email_records')
->where('school_year', $schoolYear)
->where('parent_user_id', $parentId)
->where('delivery_status', 'sent')
->countAllResults() > 0;
}
private function latestEmailRecord(string $schoolYear, int $parentId): ?array
{
if (! $this->db->tableExists('enrollment_email_records')) {
return null;
}
return $this->db->table('enrollment_email_records')
->select('delivery_status, sent_at, failure_reason')
->where('school_year', $schoolYear)
->where('parent_user_id', $parentId)
->orderBy('created_at', 'DESC')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray() ?: null;
}
private function registrationYearsForDate(DateTimeInterface $date, bool $force): array
{
$builder = $this->db->table('school_years')->where('registration_starts_on IS NOT NULL');
if (! $force) {
$builder->where('registration_starts_on', $date->format('Y-m-d'));
}
return $builder->orderBy('registration_starts_on', 'ASC')->get()->getResultArray();
}
private function schoolYearByName(string $schoolYear): ?array
{
return $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray() ?: null;
}
private function invoiceBalance(int $parentId, string $schoolYear): float
{
if (! $this->db->tableExists('invoices')) {
return 0.0;
}
$row = $this->db->table('invoices')
->select('COALESCE(SUM(balance), 0) AS balance', false)
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->get()
->getRowArray();
return round((float) ($row['balance'] ?? 0), 2);
}
private function registrationStatus(array $evaluation): string
{
if (($evaluation['can_enroll'] ?? false) === true) {
return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM
? 'Eligible with pending placement'
: 'Eligible';
}
if (! empty($evaluation['kg_missing_decision_eligible']) && ! $this->hasNonKgMissingDecisionBlocker($evaluation)) {
return 'Eligible';
}
if (
strtoupper(trim((string) ($evaluation['primary_block_reason'] ?? ''))) === 'ALREADY_ENROLLED'
|| in_array('ALREADY_ENROLLED', array_map('strval', $evaluation['blocking_rule_codes'] ?? []), true)
|| strtoupper(trim((string) ($evaluation['decision'] ?? ''))) === 'ALREADY_ENROLLED'
) {
return EnrollmentEligibility::alreadyEnrolledTitle($evaluation['enrollment_status'] ?? null);
}
if (! empty($evaluation['adult_student'])) {
return 'Not Eligible';
}
if (($evaluation['blockers'] ?? []) !== []) {
return 'Not Eligible';
}
return 'Not Eligible';
}
private function hasNonKgMissingDecisionBlocker(array $evaluation): bool
{
$ignoredCodes = ['NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION'];
$blockingCodes = array_values(array_filter(array_map(
static fn ($code): string => strtoupper(trim((string) $code)),
$evaluation['blocking_rule_codes'] ?? []
)));
$reviewCodes = array_values(array_filter(array_map(
static fn ($code): string => strtoupper(trim((string) $code)),
$evaluation['review_rule_codes'] ?? []
)));
$codes = array_values(array_unique(array_merge($blockingCodes, $reviewCodes)));
return array_values(array_diff($codes, $ignoredCodes)) !== [];
}
private function studentIssueCode(array $evaluation): string
{
if (! empty($evaluation['adult_student'])) {
return 'ADULT_STUDENT_PARENT_BLOCKED';
}
foreach (($evaluation['flags'] ?? []) as $flag) {
if (! is_array($flag)) {
continue;
}
$details = is_array($flag['details'] ?? null) ? $flag['details'] : [];
$ruleCode = strtoupper(trim((string) ($details['rule_code'] ?? '')));
if ($ruleCode !== '') {
return $ruleCode;
}
$flagType = strtoupper(trim((string) ($flag['flag_type'] ?? '')));
if ($flagType !== '') {
return $flagType;
}
}
$primaryBlockReason = strtoupper(trim((string) ($evaluation['primary_block_reason'] ?? '')));
if ($primaryBlockReason !== '') {
return $primaryBlockReason;
}
return '';
}
private function placementText(array $evaluation): string
{
$placement = match ((string) ($evaluation['placement_status'] ?? '')) {
'automatic_distribution_pending' => $this->assignedGradeText($evaluation),
'same_class_assigned' => 'Same grade and class',
'temporary_same_grade' => 'Same grade initially',
'manual_class_required' => 'Same grade',
'exit_required' => 'Completion or exit process required',
default => '',
};
if ($placement !== '') {
return $placement;
}
return ($evaluation['can_enroll'] ?? false) === true ? 'Pending' : '';
}
private function requiredAction(array $evaluation, string $deadline, string $name = 'The student'): string
{
if (($evaluation['can_enroll'] ?? false) === true) {
return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM
? ''
: 'Complete re-enrollment before ' . $deadline . '.';
}
if (! empty($evaluation['primary_parent_message'])) {
return (string) $evaluation['primary_parent_message'];
}
return 'Contact the school administration.';
}
private function shouldAppendRequiredAction(string $message, string $requiredAction): bool
{
$message = trim($message);
$requiredAction = trim($requiredAction);
if ($requiredAction === '') {
return false;
}
if ($message === '') {
return true;
}
$normalizedMessage = preg_replace('/\s+/', ' ', strtolower($message)) ?? $message;
$normalizedAction = preg_replace('/\s+/', ' ', strtolower($requiredAction)) ?? $requiredAction;
return $normalizedMessage !== $normalizedAction
&& ! str_contains($normalizedMessage, $normalizedAction)
&& ! str_contains($normalizedAction, $normalizedMessage);
}
private function previousSchoolYearName(string $schoolYear): ?string
{
return preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m) ? ((int) $m[1] - 1) . '-' . ((int) $m[2] - 1) : null;
}
private function dateText(mixed $value): string
{
$value = trim((string) $value);
if ($value === '') {
return 'the posted date';
}
try {
return (new \DateTimeImmutable($value))->format('F j, Y');
} catch (\Throwable) {
return $value;
}
}
private function currentGradeText(array $evaluation): string
{
return $this->gradeText(
$evaluation['source_grade_name'] ?? $evaluation['source_class_section_name'] ?? null,
'the current grade'
);
}
private function assignedGradeText(array $evaluation): string
{
return $this->gradeText(
$evaluation['assigned_grade_name'] ?? null,
'Grade to be assigned'
);
}
private function gradeText(mixed $value, string $fallback): string
{
$grade = trim((string) $value);
if ($grade === '') {
return $fallback;
}
if (in_array(strtoupper($grade), ['KG', 'K', 'KINDERGARTEN'], true)) {
return 'KG';
}
return preg_match('/^grade\b/i', $grade) === 1 ? $grade : 'Grade ' . $grade;
}
}