fix enrollment and registration email send
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Failing after 1m20s

This commit is contained in:
root
2026-08-28 16:16:59 -04:00
parent 3133b3c584
commit d2cb159451
6 changed files with 118 additions and 73 deletions
@@ -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),
@@ -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<int>
*/
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')) {
@@ -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.';
@@ -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 :
<input class="form-check-input" type="checkbox" id="force_resend" name="force_resend" value="1">
<label class="form-check-label small" for="force_resend">Also resend emails already sent</label>
</div>
<button type="submit" class="btn btn-danger" <?= !$launchApproved || !$launchReady || $emailExampleCount === 0 ? 'disabled' : '' ?>>Send real emails</button>
<div class="d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-danger" <?= !$launchApproved || !$launchReady || $emailExampleCount === 0 ? 'disabled' : '' ?>>Send real emails</button>
</div>
</form>
<form class="mt-2" method="post" action="<?= site_url('administrator/enrollment-admin/send-registration-emails') ?>" onsubmit="return confirm('Retry only failed registration emails for <?= esc((string) ($schoolYear ?? ''), 'js') ?>?');">
<?= csrf_field() ?>
<input type="hidden" name="school_year" value="<?= esc($schoolYear ?? '') ?>">
<input type="hidden" name="failed_only" value="1">
<button type="submit" class="btn btn-outline-danger" <?= !$launchApproved || !$launchReady || $failedEmailCount === 0 ? 'disabled' : '' ?>>Send failed emails only<?= $failedEmailCount > 0 ? ' (' . (int) $failedEmailCount . ')' : '' ?></button>
</form>
</div>
</div>
+14 -64
View File
@@ -23,7 +23,7 @@
<div id="assignRoleAlert" class="alert alert-danger d-none" role="alert"></div>
<div class="table-responsive">
<table id="usersTable" class="display table table-bordered table-striped align-middle">
<table id="usersTable" class="display table table-bordered table-striped align-middle no-dt-fixedheader" data-no-dt-fixedheader>
<thead class="table-dark">
<tr>
<th>User ID</th>
@@ -78,53 +78,6 @@
<?= $this->section('scripts') ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
// --- FixedHeader helpers ---
function getFixedHeaderOffset() {
let total = 0;
const stack = [];
const header = document.querySelector('header.navbar.sticky-top, header.navbar.fixed-top');
if (header) stack.push(header);
const mgmt = document.getElementById('navbarManagement');
if (mgmt && (mgmt.classList.contains('sticky-top') || mgmt.classList.contains('fixed-top'))) stack.push(mgmt);
document.querySelectorAll('.navbar.sticky-top, .navbar.fixed-top').forEach(el => { if (!stack.includes(el)) stack.push(el); });
stack.forEach(el => { const h = el.offsetHeight || el.getBoundingClientRect().height || 0; total += Math.max(0, Math.round(h)); });
return total;
}
function loadScript(src, id) {
return new Promise((resolve, reject) => {
if (id && document.getElementById(id)) return resolve();
const s = document.createElement('script');
if (id) s.id = id;
s.src = src;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
function loadCss(href, id) {
return new Promise((resolve) => {
if (id && document.getElementById(id)) return resolve();
const l = document.createElement('link');
if (id) l.id = id;
l.rel = 'stylesheet';
l.href = href;
l.onload = resolve;
document.head.appendChild(l);
});
}
function ensureFixedHeaderAssets() {
const hasFH = !!(window.jQuery && window.jQuery.fn && window.jQuery.fn.dataTable && window.jQuery.fn.dataTable.FixedHeader);
if (hasFH) return Promise.resolve();
return Promise.all([
loadScript('https://cdn.jsdelivr.net/npm/datatables.net-fixedheader@3.4.0/js/dataTables.fixedHeader.min.js', 'dt-fixedheader'),
loadCss('https://cdn.jsdelivr.net/npm/datatables.net-fixedheader-bs5@3.4.0/css/fixedHeader.bootstrap5.min.css', 'dt-fixedheader-css')
]).catch(() => {});
}
// Start loading FixedHeader assets early
ensureFixedHeaderAssets();
const container = document.querySelector('[data-users-endpoint]');
if (!container) {
return;
@@ -149,8 +102,18 @@
const destroyDataTable = () => {
if (!hasDataTables()) return;
if (window.jQuery.fn.DataTable.isDataTable('#usersTable')) {
window.jQuery('#usersTable').DataTable().clear().destroy();
const table = document.getElementById('usersTable');
if (!table || !table.parentNode) return;
if (window.jQuery.fn.DataTable.isDataTable(table)) {
try {
const dt = window.jQuery(table).DataTable();
if (dt.fixedHeader && typeof dt.fixedHeader.destroy === 'function') {
dt.fixedHeader.destroy();
}
dt.clear().destroy();
} catch (error) {
console.warn('Unable to destroy users DataTable cleanly.', error);
}
}
};
@@ -164,20 +127,7 @@
],
};
if (window.jQuery.fn.dataTable && window.jQuery.fn.dataTable.FixedHeader) {
opts.fixedHeader = { header: true, headerOffset: getFixedHeaderOffset() };
}
const dt = window.jQuery('#usersTable').DataTable(opts);
// If plugin finished loading slightly after init, attach FixedHeader instance
if (!opts.fixedHeader) {
ensureFixedHeaderAssets().then(() => {
if (window.jQuery.fn.dataTable && window.jQuery.fn.dataTable.FixedHeader) {
try { new window.jQuery.fn.dataTable.FixedHeader(dt, { header: true, headerOffset: getFixedHeaderOffset() }); } catch (_) {}
}
});
}
window.jQuery('#usersTable').DataTable(opts);
};
const showError = (message) => {
@@ -334,6 +334,37 @@ final class EnrollmentRegistrationEmailServiceTest extends TestCase
$this->assertStringContainsString('not approved', implode(' ', $summary['messages']));
}
public function testFailedOnlyRetryUsesLatestFailedEmailRecordPerParent(): void
{
$query = new class {
public function getResultArray(): array
{
return [
['parent_user_id' => 10, 'delivery_status' => 'failed'],
['parent_user_id' => 10, 'delivery_status' => 'sent'],
['parent_user_id' => 20, 'delivery_status' => 'sent'],
['parent_user_id' => 20, 'delivery_status' => 'failed'],
['parent_user_id' => 30, 'delivery_status' => 'failed'],
['parent_user_id' => null, 'delivery_status' => 'failed'],
];
}
};
$builder = $this->createMock(BaseBuilder::class);
$builder->method('select')->willReturnSelf();
$builder->method('where')->with('school_year', '2026-2027')->willReturnSelf();
$builder->method('orderBy')->willReturnSelf();
$builder->method('get')->willReturn($query);
$db = $this->createMock(BaseConnection::class);
$db->method('tableExists')->with('enrollment_email_records')->willReturn(true);
$db->method('table')->with('enrollment_email_records')->willReturn($builder);
$service = $this->service($db);
$this->assertSame([10, 30], $this->invoke($service, 'failedParentIdsForSchoolYear', ['2026-2027']));
}
/**
* @param list<mixed> $args
*/