fix enrollment and registration email send
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user