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']); $examples[] = [ 'parent_user_id' => (int) $family['parent_user_id'], 'parent_name' => (string) $family['name'], 'recipients' => $message['recipients'], 'student_names' => array_map( static fn (array $student): string => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . (int) ($student['id'] ?? 0), $family['students'] ), '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->evaluate($studentId, $previousYear, $schoolYearName, 'parent'); $studentRows[] = $this->studentRow($student, $evaluation, $opens, $deadline, $schoolYear); $studentIds[] = $studentId; $hasReEnrollmentEligibleStudent = $hasReEnrollmentEligibleStudent || $this->canCompleteParentReEnrollment($evaluation); } $financial = $this->financialSection((int) $family['parent_user_id'], $schoolYear, $previousYear); $subject = 'Registration for ' . $schoolYearName . ' Is Now Open'; $bodyHtml = '

Dear ' . esc($family['name']) . ',

' . '

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) . '.

' . $this->studentsTable($studentRows) . $financial . $this->registrationStepsSection($opens, $deadline, $hasReEnrollmentEligibleStudent) . '

For assistance, please contact the school administration.

' . '

Sincerely,
Al Rahma Sunday School
School Administration

'; $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['parent_enrollment_allowed'] ?? false); } private function registrationStepsSection(string $opens, string $deadline, bool $include): string { if (! $include) { return ''; } return '

Re-enrollment Steps

' . '

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.

' . '
  1. Sign in to the parent portal.
  2. Review/update student and contact information.
  3. Acknowledge school policies.
  4. Review tuition, fees and any carry-over balance.
  5. Submit Enrollment.
' . '

Registration portal: ' . esc(site_url('parent/enroll_classes')) . '

'; } private function studentsTable(array $studentRows): string { if ($studentRows === []) { return '

No eligible linked students were found for this email.

'; } return '
' . implode('', $studentRows) . '
'; } 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 ($requiredAction !== '') { $nextStepHtml .= '
' . esc($requiredAction) . ''; } return '' . '' . '' . '' . '' . $this->studentDetailRow('Decision', esc($decision ?: 'Pending')) . $this->studentDetailRow('Registration Status', '' . esc($status) . '
' . esc($placement)) . $this->studentDetailRow('Next Step', $nextStepHtml) . '
' . '' . esc($name) . '' . '
'; } private function studentDetailRow(string $label, string $valueHtml): string { return '' . '' . '
' . esc($label) . '
' . '
' . $valueHtml . '
' . '' . ''; } 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 ? '' . $escaped . '' : $escaped; } return implode('
', $lines); } private function decisionMessage(string $name, array $evaluation, string $opens, string $deadline, array $schoolYear = []): string { if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) { $grade = $this->currentGradeText($evaluation); return $name . ' has successfully passed ' . $grade . '.'; } if (($evaluation['blockers'] ?? []) !== []) { return implode(' ', array_map('strval', $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 { $carry = $previousYear !== null ? $this->invoiceBalance($parentId, $previousYear) : 0.0; $registrationFee = (float) ($schoolYear['registration_fee'] ?? 0); $tuition = (float) ($schoolYear['tuition_due_at_registration'] ?? 0); $mandatory = (float) ($schoolYear['mandatory_fees'] ?? 0); $total = max(0, $carry) + $registrationFee + $tuition + $mandatory; 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 '

Family Account Information

' . '

Carry-over balance: $' . number_format($carry, 2) . '
' . 'Total currently due: $' . number_format($total, 2) . '

' . '

' . esc($message) . '

'; } 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 ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) { return 'Eligible'; } if (($evaluation['blockers'] ?? []) !== []) { return ($evaluation['adult_student'] ?? false) ? 'Student Action Required' : 'Not Eligible'; } return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM ? 'Eligible with pending placement' : 'Eligible'; } private function placementText(array $evaluation): string { return 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 => 'Pending', }; } private function requiredAction(array $evaluation, string $deadline, string $name = 'The student'): string { if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) { return 'Complete re-enrollment before ' . $deadline . '.'; } if (($evaluation['blockers'] ?? []) !== []) { return ($evaluation['adult_student'] ?? false) ? 'Student must complete the authorized adult-student process or contact administration.' : 'Contact the school administration.'; } return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM ? '' : 'Complete re-enrollment before ' . $deadline . '.'; } 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; } return preg_match('/^grade\b/i', $grade) === 1 ? $grade : 'Grade ' . $grade; } }