Files
alrahma_sunday_school/app/Services/EnrollmentRegistrationEmailService.php
T
root 1ef2800f12
Tests / PHPUnit (push) Successful in 1m26s
fix enrollment for the new school-year
2026-08-07 23:43:31 -04:00

487 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Support\Enrollment\DeliberationDecision;
use CodeIgniter\Database\BaseConnection;
use DateTimeInterface;
final class EnrollmentRegistrationEmailService
{
public const TEMPLATE_VERSION = 'phase5_consolidated_v1';
public function __construct(
private readonly BaseConnection $db,
private readonly EnrollmentTransitionService $transitionService,
private readonly EmailService $emailService,
) {
}
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']);
$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);
$students = [];
$studentIds = [];
foreach ($family['students'] as $student) {
$studentId = (int) ($student['id'] ?? 0);
if ($studentId <= 0 || $previousYear === null) {
continue;
}
$evaluation = $this->transitionService->evaluate($studentId, $previousYear, $schoolYearName, 'parent');
$students[] = $this->studentSection($student, $evaluation, $opens, $deadline);
$studentIds[] = $studentId;
}
$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>We are pleased to welcome your family to the registration process for the ' . esc($schoolYearName) . ' school year.</p>'
. '<p>Registration opens on ' . esc($opens) . ' and closes on ' . esc($deadline) . '.</p>'
. '<p>Please review the information below for each of your children, including the deliberation decision, registration eligibility, expected academic placement, and any required action.</p>'
. implode('', $students)
. $financial
. '<p>To complete registration for each eligible student:</p>'
. '<ol><li>Sign in to the parent portal.</li><li>Review and update student information.</li><li>Upload required documents.</li><li>Review and acknowledge school policies.</li><li>Review tuition, fees, and any carry-over balance.</li><li>Submit registration before ' . esc($deadline) . '.</li></ol>'
. '<p>Registration portal: <a href="' . esc(site_url('parent/enroll_classes')) . '">' . esc(site_url('parent/enroll_classes')) . '</a></p>'
. '<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 studentSection(array $student, array $evaluation, string $opens, string $deadline): 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);
$message = $this->decisionMessage($name, $evaluation, $opens, $deadline);
return '<h3>' . esc($name) . '</h3>'
. '<p><strong>Deliberation decision:</strong> ' . esc($decision ?: 'Pending') . '<br>'
. '<strong>Registration status:</strong> ' . esc($status) . '<br>'
. '<strong>Expected placement:</strong> ' . esc($placement) . '</p>'
. '<p>' . esc($message) . '</p>'
. '<p><strong>Required action:</strong> ' . esc($requiredAction) . '</p>';
}
private function decisionMessage(string $name, array $evaluation, string $opens, string $deadline): string
{
if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) {
$grade = $this->currentGradeText($evaluation);
return 'We are pleased to inform you that ' . $name . ' has successfully passed ' . $grade . '. '
. 'Re-enrollment for the new school year will open on ' . $opens . '. Please make sure to re-enroll your child before ' . $deadline . ' to secure their enrollment for the upcoming school year. '
. 'To complete the process, sign in to the parent portal, review the students information, submit the required documents, acknowledge the school policies, and complete any applicable payment steps.';
}
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 registration is completed, the student will remain in the same grade and class when available.',
DeliberationDecision::MAKE_UP_EXAM => 'The deliberation decision for ' . $name . ' is pending the result of a make-up exam. Registration may be completed now. The student will initially remain in the same grade.',
default => 'Please review the registration portal for the current enrollment status.',
};
}
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 is shown for information and does not currently block registration.';
}
return '<h3>Family Account Information</h3>'
. '<p><strong>Carry-over balance:</strong> $' . number_format($carry, 2) . '<br>'
. '<strong>Registration fee:</strong> $' . number_format($registrationFee, 2) . '<br>'
. '<strong>New-year tuition due now:</strong> $' . number_format($tuition, 2) . '<br>'
. '<strong>Mandatory fees:</strong> $' . number_format($mandatory, 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 ((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 - administrative class assignment required',
'exit_required' => 'Completion or exit process required',
default => 'Pending',
};
}
private function requiredAction(array $evaluation, string $deadline): 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 and follow the school instructions regarding the 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;
}
}