diff --git a/app/Controllers/View/EnrollmentAdminController.php b/app/Controllers/View/EnrollmentAdminController.php index 57981f0..ae12d05 100644 --- a/app/Controllers/View/EnrollmentAdminController.php +++ b/app/Controllers/View/EnrollmentAdminController.php @@ -95,10 +95,13 @@ class EnrollmentAdminController extends BaseController return redirect()->back()->with('error', 'Registration launch is not ready: ' . implode(', ', $missing)); } - $force = (bool) $this->request->getPost('force_resend'); - $result = service('enrollmentRegistrationEmail')->sendForSchoolYearName($schoolYear, $force); + $failedOnly = (bool) $this->request->getPost('failed_only'); + $force = ! $failedOnly && (bool) $this->request->getPost('force_resend'); + $result = service('enrollmentRegistrationEmail')->sendForSchoolYearName($schoolYear, $force, $failedOnly); $message = sprintf( - 'Registration emails processed for %s: %d sent, %d failed, %d skipped.', + $failedOnly + ? 'Failed registration emails retried for %s: %d sent, %d failed, %d skipped.' + : 'Registration emails processed for %s: %d sent, %d failed, %d skipped.', $schoolYear, (int) ($result['sent'] ?? 0), (int) ($result['failed'] ?? 0), diff --git a/app/Services/EnrollmentRegistrationEmailService.php b/app/Services/EnrollmentRegistrationEmailService.php index bdbaca1..338da0c 100644 --- a/app/Services/EnrollmentRegistrationEmailService.php +++ b/app/Services/EnrollmentRegistrationEmailService.php @@ -11,6 +11,7 @@ use DateTimeInterface; final class EnrollmentRegistrationEmailService { public const TEMPLATE_VERSION = 'phase5_consolidated_v3'; + private const SEND_DELAY_SECONDS = 2; public function __construct( private readonly BaseConnection $db, @@ -50,7 +51,7 @@ final class EnrollmentRegistrationEmailService return $summary; } - public function sendForSchoolYear(array $schoolYear, ?string $testEmail = null, bool $dryRun = false, bool $force = false): array + public function sendForSchoolYear(array $schoolYear, ?string $testEmail = null, bool $dryRun = false, bool $force = false, bool $failedOnly = false): array { $summary = ['recipients' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 0, 'messages' => []]; if (! $dryRun && empty($schoolYear['registration_launch_approved_at'])) { @@ -65,12 +66,28 @@ final class EnrollmentRegistrationEmailService return $summary; } + $failedParentIds = $failedOnly ? $this->failedParentIdsForSchoolYear((string) $schoolYear['name']) : null; + if ($failedOnly && $failedParentIds === []) { + $summary['messages'][] = 'No failed registration emails found for ' . (string) ($schoolYear['name'] ?? '') . '.'; + return $summary; + } + + $sentAttempted = false; foreach ($families as $family) { - if ($testEmail === null && ! $force && $this->alreadySent((string) $schoolYear['name'], (int) $family['parent_user_id'])) { + if ($failedParentIds !== null && ! in_array((int) $family['parent_user_id'], $failedParentIds, true)) { $summary['skipped']++; continue; } + if ($failedParentIds === null && $testEmail === null && ! $force && $this->alreadySent((string) $schoolYear['name'], (int) $family['parent_user_id'])) { + $summary['skipped']++; + continue; + } + + if ($sentAttempted && ! $dryRun) { + sleep(self::SEND_DELAY_SECONDS); + } + $message = $this->buildMessage($schoolYear, $family); if ($testEmail !== null) { $message['subject'] = '[TEST] ' . $message['subject']; @@ -85,6 +102,8 @@ final class EnrollmentRegistrationEmailService if (! $dryRun && $recordId !== null) { $this->recordDelivery($recordId, $sent, $sent ? null : 'Email send failed'); } + + $sentAttempted = true; } return $summary; @@ -153,14 +172,14 @@ final class EnrollmentRegistrationEmailService return $examples; } - public function sendForSchoolYearName(string $schoolYearName, bool $force = false): array + public function sendForSchoolYearName(string $schoolYearName, bool $force = false, bool $failedOnly = 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); + return $this->sendForSchoolYear($schoolYear, null, false, $force, $failedOnly); } private function buildMessage(array $schoolYear, array $family): array @@ -478,6 +497,39 @@ final class EnrollmentRegistrationEmailService ->countAllResults() > 0; } + /** + * @return list + */ + private function failedParentIdsForSchoolYear(string $schoolYear): array + { + if (! $this->db->tableExists('enrollment_email_records')) { + return []; + } + + $rows = $this->db->table('enrollment_email_records') + ->select('parent_user_id, delivery_status') + ->where('school_year', $schoolYear) + ->orderBy('created_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + $latestByParent = []; + foreach ($rows as $row) { + $parentId = (int) ($row['parent_user_id'] ?? 0); + if ($parentId <= 0 || array_key_exists($parentId, $latestByParent)) { + continue; + } + + $latestByParent[$parentId] = (string) ($row['delivery_status'] ?? ''); + } + + return array_values(array_map( + 'intval', + array_keys(array_filter($latestByParent, static fn (string $status): bool => $status === 'failed')) + )); + } + private function latestEmailRecord(string $schoolYear, int $parentId): ?array { if (! $this->db->tableExists('enrollment_email_records')) { diff --git a/app/Support/Enrollment/EnrollmentEligibility.php b/app/Support/Enrollment/EnrollmentEligibility.php index 3d5806a..9c4f25f 100644 --- a/app/Support/Enrollment/EnrollmentEligibility.php +++ b/app/Support/Enrollment/EnrollmentEligibility.php @@ -9,7 +9,7 @@ final class EnrollmentEligibility public const DEFERRED_MESSAGE = 'Re-enrollment cannot currently be completed because the final deliberation decision is deferred. Please contact the school administration for the next required step.'; public const KG_MISSING_DECISION_ELIGIBLE_MESSAGE = 'KG students may complete registration now. Their new-year grade placement will be based on the school age-placement rule.'; public const MISSING_DECISION_MESSAGE = 'Re-enrollment cannot currently be completed because no final deliberation decision is recorded for the student. Registration will become available after the school records a final decision.'; - public const ADULT_STUDENT_MESSAGE = 'This student will be 18 years old or older on September 1 of the selected school year. A parent or guardian cannot complete registration for this student. Please contact school administration.'; + public const ADULT_STUDENT_MESSAGE = 'This student will be 18 years old or older on September 1 of the selected school year. The student can no longer enroll in the school.'; public const ADULT_STUDENT_PARENT_PORTAL_MESSAGE = self::ADULT_STUDENT_MESSAGE; public const WITHDRAWN_PORTAL_MESSAGE = 'This student is currently marked as Withdrawn and cannot be enrolled at this time. Please contact school administration.'; public const SIBLING_PORTAL_MESSAGE = 'Enrollment cannot continue because the family record requires administrative review. Please contact school administration.'; diff --git a/app/Views/administrator/enrollment_admin_dashboard.php b/app/Views/administrator/enrollment_admin_dashboard.php index d5f4540..34172ba 100644 --- a/app/Views/administrator/enrollment_admin_dashboard.php +++ b/app/Views/administrator/enrollment_admin_dashboard.php @@ -246,6 +246,7 @@ foreach (($activeExceptions ?? []) as $exceptionRow) { } } $emailExampleCount = count($emailExamples ?? []); +$failedEmailCount = count(array_filter($emailExamples ?? [], static fn ($example): bool => ($example['delivery_status'] ?? '') === 'failed')); $launchReady = empty($launchState['missing']); $launchApproved = !empty($launchState['approved']); $defaultTab = 'work'; @@ -726,7 +727,15 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes : - +
+ +
+ +
+ + + +
diff --git a/app/Views/rolepermission/assign_role.php b/app/Views/rolepermission/assign_role.php index 9d0bad3..29ffa7d 100644 --- a/app/Views/rolepermission/assign_role.php +++ b/app/Views/rolepermission/assign_role.php @@ -23,7 +23,7 @@
- +
@@ -78,53 +78,6 @@ section('scripts') ?>
User ID