Files
alrahma_sunday_school/app/Services/EnrollmentRegistrationEmailService.php
T
root 3cbfc40934
Tests / PHPUnit (push) Failing after 58s
fix school year and enrollment steps
2026-08-15 21:15:05 -04:00

496 lines
21 KiB
PHP

<?php
namespace App\Services;
use App\Support\Enrollment\DeliberationDecision;
use CodeIgniter\Database\BaseConnection;
use DateTimeInterface;
final class EnrollmentRegistrationEmailService
{
public const TEMPLATE_VERSION = 'phase5_consolidated_v2';
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);
$studentRows = [];
$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');
$studentRows[] = $this->studentRow($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>Registration for the ' . esc($schoolYearName) . ' school year is now open. Please review the student summary below and complete the portal steps before ' . esc($deadline) . '.</p>'
. $this->studentsTable($studentRows)
. $financial
. '<h3>Registration 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 student information and upload any required documents.</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>'
. '<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 studentsTable(array $studentRows): string
{
if ($studentRows === []) {
return '<p>No eligible linked students were found for this email.</p>';
}
return '<table style="width:100%; border-collapse:collapse; margin:12px 0 18px; font-size:14px;">'
. '<tbody>'
. implode('', $studentRows)
. '</tbody></table>';
}
private function studentRow(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 '<tr>'
. '<td style="border:1px solid #ddd; padding:8px; vertical-align:top;">' . esc($name) . '</td>'
. '<td style="border:1px solid #ddd; padding:8px; vertical-align:top;">' . esc($decision ?: 'Pending') . '</td>'
. '<td style="border:1px solid #ddd; padding:8px; vertical-align:top;"><strong>' . esc($status) . '</strong><br>' . esc($placement) . '</td>'
. '<td style="border:1px solid #ddd; padding:8px; vertical-align:top;">' . esc($message) . '<br><strong>' . esc($requiredAction) . '</strong></td>'
. '</tr>';
}
private function decisionMessage(string $name, array $evaluation, string $opens, string $deadline): 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 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;
}
}