This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
class RegistrationOpeningEmailService
|
||||
{
|
||||
public const TEMPLATE_KEY = 'registration_opening';
|
||||
|
||||
private BaseConnection $db;
|
||||
private EmailService $emailService;
|
||||
|
||||
public function __construct(?BaseConnection $db = null, ?EmailService $emailService = null)
|
||||
{
|
||||
$this->db = $db ?? \Config\Database::connect();
|
||||
$this->emailService = $emailService ?? new 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']++;
|
||||
|
||||
$recipients = $testEmail !== null
|
||||
? [[
|
||||
'user_id' => null,
|
||||
'family_id' => null,
|
||||
'email' => $testEmail,
|
||||
'name' => 'Parent',
|
||||
]]
|
||||
: $this->parentEmailRecipients();
|
||||
|
||||
if ($recipients === []) {
|
||||
$summary['messages'][] = sprintf('No parent emails found for %s.', $schoolYear['name']);
|
||||
continue;
|
||||
}
|
||||
|
||||
[$subject, $body] = $this->composeEmail($schoolYear);
|
||||
if ($testEmail !== null) {
|
||||
$subject = '[TEST] ' . $subject;
|
||||
}
|
||||
|
||||
foreach ($recipients as $recipient) {
|
||||
if ($testEmail === null && ! $dryRun && $this->alreadySentForRecipient($schoolYear, $recipient['email'])) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['recipients']++;
|
||||
$personalizedBody = $this->replaceTokens($body, $schoolYear, $recipient);
|
||||
$personalizedSubject = $this->replaceTokens($subject, $schoolYear, $recipient);
|
||||
|
||||
$sent = $dryRun || $this->emailService->send($recipient['email'], $personalizedSubject, $personalizedBody, 'general');
|
||||
$sent ? $summary['sent']++ : $summary['failed']++;
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->logAttempt($schoolYear, $recipient, $personalizedSubject, $personalizedBody, $sent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
public function registrationYearsForDate(\DateTimeInterface $date, bool $force = false): 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();
|
||||
}
|
||||
|
||||
public function parentEmailRecipients(): array
|
||||
{
|
||||
$recipients = [];
|
||||
|
||||
$hasFamilyGuardians = $this->db->tableExists('family_guardians');
|
||||
|
||||
if ($this->db->tableExists('roles') && $this->db->tableExists('user_roles')) {
|
||||
$builder = $this->db->table('users u')
|
||||
->select($hasFamilyGuardians ? 'u.id AS user_id, fg.family_id, u.firstname, u.lastname, u.email' : 'u.id AS user_id, NULL AS family_id, u.firstname, u.lastname, u.email')
|
||||
->join('user_roles ur', 'ur.user_id = u.id', 'inner')
|
||||
->join('roles r', 'r.id = ur.role_id', 'inner')
|
||||
->where('LOWER(r.name)', 'parent')
|
||||
->where('u.email IS NOT NULL')
|
||||
->where('u.email !=', '');
|
||||
|
||||
if ($hasFamilyGuardians) {
|
||||
$builder->join('family_guardians fg', 'fg.user_id = u.id', 'left')
|
||||
->groupStart()
|
||||
->where('fg.id IS NULL')
|
||||
->orWhere('fg.receive_emails', 1)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
if ($this->hasField('user_roles', 'deleted_at')) {
|
||||
$builder->where('ur.deleted_at', null);
|
||||
}
|
||||
|
||||
foreach ($builder->get()->getResultArray() as $row) {
|
||||
$this->addRecipient($recipients, $row);
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasFamilyGuardians) {
|
||||
$guardianBuilder = $this->db->table('family_guardians fg')
|
||||
->select('u.id AS user_id, fg.family_id, u.firstname, u.lastname, u.email')
|
||||
->join('users u', 'u.id = fg.user_id', 'inner')
|
||||
->where('fg.receive_emails', 1)
|
||||
->where('u.email IS NOT NULL')
|
||||
->where('u.email !=', '');
|
||||
|
||||
foreach ($guardianBuilder->get()->getResultArray() as $row) {
|
||||
$this->addRecipient($recipients, $row);
|
||||
}
|
||||
}
|
||||
|
||||
uasort($recipients, static fn(array $a, array $b): int => strcasecmp($a['email'], $b['email']));
|
||||
|
||||
return array_values($recipients);
|
||||
}
|
||||
|
||||
public function alreadySentForSchoolYear(array $schoolYear): bool
|
||||
{
|
||||
$schoolYearId = (int) ($schoolYear['id'] ?? 0);
|
||||
$schoolYearName = (string) ($schoolYear['name'] ?? '');
|
||||
|
||||
$builder = $this->db->table('communication_logs')
|
||||
->where('template_key', self::TEMPLATE_KEY)
|
||||
->where('status', 'sent')
|
||||
->groupStart();
|
||||
|
||||
if ($schoolYearId > 0) {
|
||||
$builder->like('metadata', '"school_year_id":' . $schoolYearId);
|
||||
}
|
||||
|
||||
if ($schoolYearName !== '') {
|
||||
if ($schoolYearId > 0) {
|
||||
$builder->orLike('metadata', '"school_year":"' . $this->escapeJsonLikeValue($schoolYearName) . '"');
|
||||
} else {
|
||||
$builder->like('metadata', '"school_year":"' . $this->escapeJsonLikeValue($schoolYearName) . '"');
|
||||
}
|
||||
}
|
||||
|
||||
$builder->groupEnd();
|
||||
|
||||
return $builder->countAllResults() > 0;
|
||||
}
|
||||
|
||||
public function alreadySentForRecipient(array $schoolYear, string $email): bool
|
||||
{
|
||||
$schoolYearId = (int) ($schoolYear['id'] ?? 0);
|
||||
$schoolYearName = (string) ($schoolYear['name'] ?? '');
|
||||
|
||||
$builder = $this->db->table('communication_logs')
|
||||
->where('template_key', self::TEMPLATE_KEY)
|
||||
->where('status', 'sent')
|
||||
->like('recipients', '"' . strtolower(trim($email)) . '"')
|
||||
->groupStart();
|
||||
|
||||
if ($schoolYearId > 0) {
|
||||
$builder->like('metadata', '"school_year_id":' . $schoolYearId);
|
||||
}
|
||||
|
||||
if ($schoolYearName !== '') {
|
||||
if ($schoolYearId > 0) {
|
||||
$builder->orLike('metadata', '"school_year":"' . $this->escapeJsonLikeValue($schoolYearName) . '"');
|
||||
} else {
|
||||
$builder->like('metadata', '"school_year":"' . $this->escapeJsonLikeValue($schoolYearName) . '"');
|
||||
}
|
||||
}
|
||||
|
||||
$builder->groupEnd();
|
||||
|
||||
return $builder->countAllResults() > 0;
|
||||
}
|
||||
|
||||
private function addRecipient(array &$recipients, array $row): void
|
||||
{
|
||||
$email = strtolower(trim((string) ($row['email'] ?? '')));
|
||||
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
|
||||
$recipients[$email] = [
|
||||
'user_id' => isset($row['user_id']) ? (int) $row['user_id'] : null,
|
||||
'family_id' => isset($row['family_id']) ? (int) $row['family_id'] : null,
|
||||
'email' => $email,
|
||||
'name' => $name !== '' ? $name : 'Parent',
|
||||
];
|
||||
}
|
||||
|
||||
private function composeEmail(array $schoolYear): array
|
||||
{
|
||||
$template = $this->loadTemplate();
|
||||
|
||||
$subject = $template['subject'] ?? 'Registration is now open for {{school_year}}';
|
||||
$bodyHtml = $template['body'] ?? $this->defaultBody();
|
||||
$bodyHtml = $this->replaceTokens($bodyHtml, $schoolYear, ['name' => 'Parent', 'email' => '']);
|
||||
|
||||
$body = view('emails/_wrap_layout', [
|
||||
'title' => $this->replaceTokens($subject, $schoolYear, ['name' => 'Parent', 'email' => '']),
|
||||
'body_html' => $bodyHtml,
|
||||
], ['saveData' => true]);
|
||||
|
||||
return [$subject, $body];
|
||||
}
|
||||
|
||||
private function loadTemplate(): ?array
|
||||
{
|
||||
if (! $this->db->tableExists('email_templates')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fields = $this->db->getFieldNames('email_templates');
|
||||
$keyField = in_array('code', $fields, true) ? 'code' : 'template_key';
|
||||
$bodyField = in_array('body_html', $fields, true) ? 'body_html' : 'body';
|
||||
|
||||
$row = $this->db->table('email_templates')
|
||||
->select('subject, ' . $bodyField . ' AS body')
|
||||
->where($keyField, self::TEMPLATE_KEY)
|
||||
->where('is_active', 1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
private function replaceTokens(string $text, array $schoolYear, array $recipient): string
|
||||
{
|
||||
$registrationEnds = trim((string) ($schoolYear['registration_ends_on'] ?? ''));
|
||||
$deadline = $registrationEnds !== '' ? $registrationEnds : 'the posted deadline';
|
||||
|
||||
return strtr($text, [
|
||||
'{{name}}' => (string) ($recipient['name'] ?? 'Parent'),
|
||||
'{{parent_name}}' => (string) ($recipient['name'] ?? 'Parent'),
|
||||
'{{school_year}}' => (string) ($schoolYear['name'] ?? ''),
|
||||
'{{registration_starts_on}}' => (string) ($schoolYear['registration_starts_on'] ?? ''),
|
||||
'{{registration_ends_on}}' => $registrationEnds,
|
||||
'{{registration_deadline}}' => $deadline,
|
||||
'{{registration_url}}' => site_url('user'),
|
||||
'{{login_url}}' => site_url('login'),
|
||||
'{{school_name}}' => 'Al Rahma Sunday School',
|
||||
]);
|
||||
}
|
||||
|
||||
private function defaultBody(): string
|
||||
{
|
||||
return <<<'HTML'
|
||||
<p>Dear {{name}},</p>
|
||||
<p>Registration is now open for the {{school_year}} school year.</p>
|
||||
<p>Please complete your registration through the school portal. The registration deadline is {{registration_deadline}}.</p>
|
||||
<p><a href="{{registration_url}}">Start registration</a></p>
|
||||
<p>If you already have an account, you can also log in here: <a href="{{login_url}}">{{login_url}}</a></p>
|
||||
<p>Regards,<br>{{school_name}}</p>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private function logAttempt(array $schoolYear, array $recipient, string $subject, string $body, bool $sent): void
|
||||
{
|
||||
$this->db->table('communication_logs')->insert([
|
||||
'student_id' => 0,
|
||||
'family_id' => $recipient['family_id'] ?: null,
|
||||
'student_name' => 'All Families',
|
||||
'template_key' => self::TEMPLATE_KEY,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'recipients' => json_encode([$recipient['email']]),
|
||||
'status' => $sent ? 'sent' : 'failed',
|
||||
'error_message' => $sent ? null : 'Email send failed',
|
||||
'sent_by' => null,
|
||||
'metadata' => json_encode([
|
||||
'campaign' => self::TEMPLATE_KEY,
|
||||
'school_year_id' => (int) ($schoolYear['id'] ?? 0),
|
||||
'school_year' => (string) ($schoolYear['name'] ?? ''),
|
||||
'registration_starts_on' => $schoolYear['registration_starts_on'] ?? null,
|
||||
'registration_ends_on' => $schoolYear['registration_ends_on'] ?? null,
|
||||
'recipient_user_id' => $recipient['user_id'],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function hasField(string $table, string $field): bool
|
||||
{
|
||||
return in_array($field, $this->db->getFieldNames($table), true);
|
||||
}
|
||||
|
||||
private function escapeJsonLikeValue(string $value): string
|
||||
{
|
||||
return trim(json_encode($value), '"');
|
||||
}
|
||||
}
|
||||
@@ -129,11 +129,21 @@ final class SchoolYearContextService
|
||||
{
|
||||
$active = $this->schoolYearModel->active();
|
||||
|
||||
if ($active === null) {
|
||||
if ($active !== null) {
|
||||
return $this->fromRow($active, false);
|
||||
}
|
||||
|
||||
$closing = $this->schoolYearModel
|
||||
->where('status', SchoolYearStatus::CLOSING)
|
||||
->orderBy('closing_started_at', 'DESC')
|
||||
->orderBy('id', 'DESC')
|
||||
->findAll(2);
|
||||
|
||||
if (count($closing) !== 1) {
|
||||
throw new SchoolYearConfigurationException('Exactly one active school year must be configured.');
|
||||
}
|
||||
|
||||
return $this->fromRow($active, false);
|
||||
return $this->fromRow($closing[0], false);
|
||||
}
|
||||
|
||||
private function normalizeInt(mixed $value): ?int
|
||||
|
||||
@@ -26,6 +26,7 @@ final class SchoolYearManagementService
|
||||
public function createDraft(array $payload, ?int $userId = null): int
|
||||
{
|
||||
$payload = $this->metadataPayload($payload);
|
||||
$payload['previous_school_year_id'] = $this->previousYearIdForDraft((string) $payload['name']);
|
||||
$payload['status'] = SchoolYearStatus::DRAFT;
|
||||
$payload['created_by'] = $userId;
|
||||
$payload['updated_by'] = $userId;
|
||||
@@ -35,6 +36,7 @@ final class SchoolYearManagementService
|
||||
$this->db->transStart();
|
||||
$id = $this->schoolYearModel->insert($payload, true);
|
||||
if ($id !== false) {
|
||||
$this->syncConfigurationFromSchoolYear($payload);
|
||||
$this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create', $userId);
|
||||
}
|
||||
$this->db->transComplete();
|
||||
@@ -62,6 +64,7 @@ final class SchoolYearManagementService
|
||||
$this->db->transStart();
|
||||
$updated = $this->schoolYearModel->update($id, $payload);
|
||||
if ($updated !== false) {
|
||||
$this->syncConfigurationFromSchoolYear($payload);
|
||||
$this->log($id, $status, $status, 'metadata_update', $userId);
|
||||
}
|
||||
$this->db->transComplete();
|
||||
@@ -104,7 +107,7 @@ final class SchoolYearManagementService
|
||||
'activated_at' => $now,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$this->configurationModel->setConfigValueByKey('school_year', (string) $year['name']);
|
||||
$this->syncConfigurationFromSchoolYear($year);
|
||||
$this->syncActiveYearSession((string) $year['name']);
|
||||
$this->log($id, $from, SchoolYearStatus::ACTIVE, 'activate', $userId);
|
||||
|
||||
@@ -255,10 +258,69 @@ final class SchoolYearManagementService
|
||||
'description' => trim((string) ($payload['description'] ?? '')) ?: null,
|
||||
'registration_starts_on' => $this->nullableDate($payload['registration_starts_on'] ?? null),
|
||||
'registration_ends_on' => $this->nullableDate($payload['registration_ends_on'] ?? null),
|
||||
'fall_makeup_exam_on' => $this->nullableDate($payload['fall_makeup_exam_on'] ?? null),
|
||||
'previous_school_year_id' => $this->nullableInt($payload['previous_school_year_id'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
private function syncConfigurationFromSchoolYear(array $schoolYear): void
|
||||
{
|
||||
$name = trim((string) ($schoolYear['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Unable to update school-year configuration: school year name is missing.');
|
||||
}
|
||||
|
||||
$ageReferenceDate = $this->ageReferenceDateForSchoolYear($name);
|
||||
$configValues = [
|
||||
'school_year' => $name,
|
||||
'date_age_reference' => $ageReferenceDate,
|
||||
'refund_deadline' => $ageReferenceDate,
|
||||
'enrollment_deadline' => (string) ($schoolYear['registration_ends_on'] ?? ''),
|
||||
'fall_semester_start' => (string) ($schoolYear['starts_on'] ?? ''),
|
||||
'last_day_of_school' => (string) ($schoolYear['ends_on'] ?? ''),
|
||||
];
|
||||
|
||||
foreach ($configValues as $key => $value) {
|
||||
if (! $this->configurationModel->setConfigValueByKey($key, $value)) {
|
||||
throw new RuntimeException("Unable to update configuration value for {$key}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function ageReferenceDateForSchoolYear(string $schoolYearName): string
|
||||
{
|
||||
if (! preg_match('/^(\d{4})-\d{4}$/', $schoolYearName, $matches)) {
|
||||
throw new RuntimeException('Unable to update school-year configuration: invalid school year format.');
|
||||
}
|
||||
|
||||
return $matches[1] . '-12-31';
|
||||
}
|
||||
|
||||
private function previousYearIdForDraft(string $name): ?int
|
||||
{
|
||||
if (preg_match('/^(\d{4})-(\d{4})$/', $name, $matches)) {
|
||||
$previousName = ((int) $matches[1] - 1) . '-' . (int) $matches[1];
|
||||
$previousYear = $this->schoolYearModel
|
||||
->where('name', $previousName)
|
||||
->first();
|
||||
|
||||
if ($previousYear !== null) {
|
||||
return (int) ($previousYear['id'] ?? 0) ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
$activeYear = $this->schoolYearModel->active();
|
||||
if ($activeYear !== null) {
|
||||
return (int) ($activeYear['id'] ?? 0) ?: null;
|
||||
}
|
||||
|
||||
$latestYear = $this->schoolYearModel
|
||||
->orderBy('name', 'DESC')
|
||||
->first();
|
||||
|
||||
return $latestYear !== null ? ((int) ($latestYear['id'] ?? 0) ?: null) : null;
|
||||
}
|
||||
|
||||
private function nullableDate(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
@@ -40,6 +40,8 @@ final class SchoolYearValidationService
|
||||
if ($registrationStarts !== null && $registrationEnds !== null && $registrationStarts > $registrationEnds) {
|
||||
throw new InvalidArgumentException('Registration start date must be on or before the registration end date.');
|
||||
}
|
||||
|
||||
$this->dateOrNull($payload['fall_makeup_exam_on'] ?? null);
|
||||
}
|
||||
|
||||
public function isValidYearName(string $value): bool
|
||||
|
||||
Reference in New Issue
Block a user