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'
Dear {{name}},
Registration is now open for the {{school_year}} school year.
Please complete your registration through the school portal. The registration deadline is {{registration_deadline}}.
If you already have an account, you can also log in here: {{login_url}}
Regards,
{{school_name}}