-
-
Notifications
You must be signed in to change notification settings - Fork 555
Add automated birthday greeting emails #9329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
eliassanchez173
wants to merge
4
commits into
ChurchCRM:master
Choose a base branch
from
eliassanchez173:feature/birthday-emails-8979
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
94edf90
Add automated birthday email feature (#8979)
eliassanchez173 cf5677e
Add admin preview/test-send for birthday emails (#8979)
eliassanchez173 8f99cde
Merge branch 'master' into feature/birthday-emails-8979
DawoudIO b865681
fix: address birthday email review feedback
eliassanchez173 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| <?php | ||
|
|
||
| namespace ChurchCRM\Emails\notifications; | ||
|
|
||
| use ChurchCRM\dto\SystemConfig; | ||
| use ChurchCRM\Emails\BaseEmail; | ||
| use ChurchCRM\model\ChurchCRM\Person; | ||
|
|
||
| class BirthdayEmail extends BaseEmail | ||
| { | ||
| private Person $person; | ||
|
|
||
| /** | ||
| * @param string[] $toAddresses | ||
| */ | ||
| public function __construct(array $toAddresses, Person $person) | ||
| { | ||
| $this->person = $person; | ||
| parent::__construct($toAddresses); | ||
| $this->mail->Subject = SystemConfig::getValue('sChurchName') . ': ' . $this->getSubSubject(); | ||
| $this->mail->isHTML(true); | ||
| $this->mail->msgHTML($this->buildMessage()); | ||
| } | ||
|
|
||
| protected function getSubSubject(): string | ||
| { | ||
| return gettext('Happy Birthday') . ', ' . $this->person->getFullName() . '!'; | ||
| } | ||
|
|
||
| public function getTokens(): array | ||
| { | ||
| $ageString = $this->person->getAge(); | ||
| $age = ($ageString !== null && ctype_digit($ageString)) ? (int) $ageString : null; | ||
|
|
||
| $body = gettext('Happy Birthday') . ', ' . $this->person->getFullName() . '!'; | ||
| $body .= "\n\n"; | ||
| if ($age !== null) { | ||
| $body .= sprintf(gettext('Wishing you a wonderful %d%s birthday!'), $age, $this->getOrdinalSuffix($age)); | ||
| } else { | ||
| $body .= gettext('Wishing you a wonderful birthday!'); | ||
| } | ||
| $body .= "\n\n" . SystemConfig::getValue('sChurchName') . ' ' . gettext('is thinking of you today.'); | ||
|
|
||
| $myTokens = [ | ||
| 'toName' => $this->person->getFullName(), | ||
| 'body' => $body, | ||
| ]; | ||
|
|
||
| return array_merge($this->getCommonTokens(), $myTokens); | ||
| } | ||
|
|
||
| private function getOrdinalSuffix(int $number): string | ||
| { | ||
| if ($number % 100 >= 11 && $number % 100 <= 13) { | ||
| return 'th'; | ||
| } | ||
|
|
||
| switch ($number % 10) { | ||
| case 1: | ||
| return 'st'; | ||
| case 2: | ||
| return 'nd'; | ||
| case 3: | ||
| return 'rd'; | ||
| default: | ||
| return 'th'; | ||
| } | ||
| } | ||
|
|
||
| protected function getFullURL(): string | ||
| { | ||
| return ''; | ||
| } | ||
|
|
||
| protected function getButtonText(): string | ||
| { | ||
| return ''; | ||
| } | ||
|
|
||
| protected function getPreheader(): string | ||
| { | ||
| return $this->getSubSubject(); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| <?php | ||
|
|
||
| namespace ChurchCRM\Service; | ||
|
|
||
| use ChurchCRM\dto\SystemConfig; | ||
| use ChurchCRM\Emails\notifications\BirthdayEmail; | ||
| use ChurchCRM\model\ChurchCRM\PersonQuery; | ||
| use ChurchCRM\Utils\DateTimeUtils; | ||
| use ChurchCRM\Utils\LoggerUtils; | ||
|
|
||
| class BirthdayEmailService | ||
| { | ||
| /** | ||
| * Sends birthday greeting emails to everyone whose birthday is today, | ||
| * if the feature is enabled and it has not already run today. | ||
| * | ||
| * Safe to call multiple times per day (idempotent) and safe to call | ||
| * even when the feature is disabled (no-ops immediately). | ||
| */ | ||
| public static function run(): void | ||
| { | ||
| if (!SystemConfig::getBooleanValue('bEnableBirthdayEmails')) { | ||
| return; | ||
| } | ||
|
|
||
| $tz = DateTimeUtils::getConfiguredTimezone(); | ||
| $today = new \DateTime('now', $tz); | ||
| $todayString = $today->format('Y-m-d'); | ||
|
|
||
| if (SystemConfig::getValue('sLastBirthdayEmailRunDate') === $todayString) { | ||
| // Already ran today; avoid duplicate sends. | ||
| return; | ||
| } | ||
|
|
||
| // Persist before sending so a crash cannot result in duplicate emails. | ||
| SystemConfig::setValue('sLastBirthdayEmailRunDate', $todayString); | ||
|
|
||
| $logger = LoggerUtils::getAppLogger(); | ||
| $sentCount = 0; | ||
| $skippedCount = 0; | ||
|
|
||
| $people = PersonQuery::create() | ||
| ->filterByBirthMonth((int) $today->format('n')) | ||
| ->filterByBirthDay((int) $today->format('j')) | ||
| ->find(); | ||
|
|
||
| foreach ($people as $person) { | ||
| $email = $person->getEmail(); | ||
| if (empty($email)) { | ||
| $skippedCount++; | ||
| continue; | ||
| } | ||
|
|
||
| try { | ||
| $birthdayEmail = new BirthdayEmail([$email], $person); | ||
| if ($birthdayEmail->send()) { | ||
| $sentCount++; | ||
| } else { | ||
| $logger?->warning('BirthdayEmailService: failed to send to person ID ' . $person->getId() . ': ' . $birthdayEmail->getError()); | ||
| } | ||
| } catch (\Exception $e) { | ||
| $logger?->warning('BirthdayEmailService: exception sending to person ID ' . $person->getId() . ': ' . $e->getMessage()); | ||
| } | ||
| } | ||
|
|
||
| $logger?->info("BirthdayEmailService: sent {$sentCount} birthday email(s), skipped {$skippedCount} (no email on file)"); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| <?php | ||
|
|
||
| use ChurchCRM\Authentication\AuthenticationManager; | ||
| use ChurchCRM\Emails\notifications\BirthdayEmail; | ||
| use ChurchCRM\Slim\SlimUtils; | ||
| use Psr\Http\Message\ResponseInterface as Response; | ||
| use Psr\Http\Message\ServerRequestInterface as Request; | ||
| use Slim\Routing\RouteCollectorProxy; | ||
|
|
||
| $app->group('/api/admin/birthday-emails', function (RouteCollectorProxy $group): void { | ||
|
|
||
| /** | ||
| * @OA\Post( | ||
| * path="/api/admin/birthday-emails/test", | ||
| * summary="Send a test birthday email to the current admin (Admin role required)", | ||
| * tags={"Admin"}, | ||
| * security={{"ApiKeyAuth":{}}}, | ||
| * @OA\Response(response=200, description="Test email sent"), | ||
| * @OA\Response(response=400, description="Current admin has no email on file"), | ||
| * @OA\Response(response=500, description="Test email failed to send") | ||
| * ) | ||
| */ | ||
| $group->post('/test', function (Request $request, Response $response, array $args): Response { | ||
| $person = AuthenticationManager::getCurrentUser()->getPerson(); | ||
| $email = $person->getEmail(); | ||
|
|
||
| if (empty($email)) { | ||
| return SlimUtils::renderErrorJSON($response, gettext('Your account has no email address on file. Add one to your profile to send a test.'), [], 400); | ||
| } | ||
|
|
||
| try { | ||
| $birthdayEmail = new BirthdayEmail([$email], $person); | ||
| if ($birthdayEmail->send()) { | ||
| return SlimUtils::renderJSON($response, [ | ||
| 'success' => true, | ||
| 'message' => gettext('Test email sent to') . ' ' . $email, | ||
| ]); | ||
| } | ||
|
|
||
| return SlimUtils::renderErrorJSON($response, gettext('Test email failed to send') . ': ' . $birthdayEmail->getError(), [], 500); | ||
| } catch (\Throwable $e) { | ||
| return SlimUtils::renderErrorJSON($response, gettext('Test email failed to send'), [], 500, $e, $request); | ||
| } | ||
| }); | ||
|
|
||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[LOW] Ordinal suffixes are hardcoded English — cannot be translated for non-English locales
getOrdinalSuffix()always returns one of'st','nd','rd','th'. This value is substituted as the%sargument insprintf(gettext('Wishing you a wonderful %d%s birthday!'), $age, ...). While the surrounding format string is wrapped ingettext()and can be translated, the%sslot is always filled by the hardcoded English suffix — translators cannot supply a locale-appropriate ordinal indicator.Churches using French ("ème"), German (trailing period), Spanish (gender-dependent), or other non-English locales will receive grammatically wrong or meaningless birthday text.
Suggested fix: remove the ordinal from the translatable string and pass the full age as a plain integer, for example:
Or simply omit the age from this sentence entirely and let
gettextcover the whole thing without a numeric placeholder.