487 lines
21 KiB
PHP
487 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_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 student’s 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;
|
||
}
|
||
}
|