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 = '
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
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.
' . '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 '| ' . '' . esc($name) . '' . ' | ' . '
Carry-over balance: $' . number_format((float) ($summary['carry_forward_balance'] ?? 0), 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 (($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; } }