Files
alrahma_sunday_school_api/app/Services/Promotions/PromotionReminderService.php
T
2026-06-09 02:32:58 -04:00

208 lines
7.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Promotions;
use App\Models\PromotionReminderLog;
use App\Models\Student;
use App\Models\StudentPromotionRecord;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Sends reminders to parents and persists a log entry per dispatch
* (plan section 14).
*
* The actual delivery is delegated to whichever notification dispatcher
* is registered. A nullable dispatcher is supported so the service can
* be used from CLI/tests without external side effects.
*/
class PromotionReminderService
{
/** @var (callable(int $userId, string $title, string $body, array $channels):void)|null */
private $dispatcher;
public function __construct(
private PromotionAuditService $audit,
?callable $dispatcher = null
) {
$this->dispatcher = $dispatcher;
}
/**
* Records a single reminder dispatch.
*/
public function send(
StudentPromotionRecord $record,
string $reminderType,
?int $userId = null,
?string $subject = null,
?string $message = null,
array $channels = ['in_app', 'email']
): PromotionReminderLog {
if (!in_array($reminderType, PromotionReminderLog::ALLOWED_TYPES, true)) {
$reminderType = PromotionReminderLog::TYPE_MANUAL;
}
$studentName = $this->studentName($record);
$deadline = $record->enrollment_deadline?->toDateString();
$level = $record->promoted_level_name ?: 'the next level';
$defaultSubject = 'Reminder: complete enrollment for the next school year';
$defaultMessage = sprintf(
'%s is eligible for promotion to %s.%s Please complete enrollment as soon as possible.',
$studentName ?: 'Your child',
$level,
$deadline ? ' Deadline: ' . $deadline . '.' : ''
);
$finalSubject = $subject ?: $defaultSubject;
$finalMessage = $message ?: $defaultMessage;
return DB::transaction(function () use (
$record,
$reminderType,
$finalSubject,
$finalMessage,
$channels,
$userId
) {
$log = PromotionReminderLog::query()->create([
'promotion_id' => (int) $record->getKey(),
'student_id' => (int) $record->student_id,
'parent_id' => (int) $record->parent_id ?: null,
'reminder_type' => $reminderType,
'channel' => implode(',', array_values(array_unique(array_filter($channels)))),
'subject' => $finalSubject,
'message' => $finalMessage,
'sent_at' => now(),
'sent_by' => $userId,
]);
if ($record->parent_id) {
$this->dispatch((int) $record->parent_id, $finalSubject, $finalMessage, $channels);
}
if (!$record->parent_notified_at) {
$record->parent_notified_at = now();
$record->save();
$this->audit->logParentNotified($record, $userId, $reminderType);
}
$this->audit->logReminderSent($record, $reminderType, $userId);
return $log;
});
}
/**
* Plan section 14 schedule-driven reminders. Sends the next due
* reminder type for every actionable record whose deadline is set.
*
* @return array{ processed:int, sent:int, skipped:int }
*/
public function dispatchScheduledReminders(?\DateTimeInterface $now = null, ?int $userId = null): array
{
$now = CarbonImmutable::instance($now ?: now());
$records = StudentPromotionRecord::query()
->whereIn('promotion_status', StudentPromotionRecord::parentActionableStatuses())
->whereNotNull('enrollment_deadline')
->get();
$processed = 0;
$sent = 0;
$skipped = 0;
foreach ($records as $record) {
$processed++;
$type = $this->resolveReminderType($record, $now);
if ($type === null) {
$skipped++;
continue;
}
$alreadySent = PromotionReminderLog::query()
->where('promotion_id', $record->getKey())
->where('reminder_type', $type)
->exists();
if ($alreadySent) {
$skipped++;
continue;
}
try {
$this->send($record, $type, $userId);
$sent++;
} catch (\Throwable $e) {
Log::error('Promotion reminder dispatch failed', [
'promotion_id' => $record->getKey(),
'exception' => $e,
]);
$skipped++;
}
}
return [
'processed' => $processed,
'sent' => $sent,
'skipped' => $skipped,
];
}
/**
* Returns the reminder type that should be sent next based on the
* deadline / current time.
*/
private function resolveReminderType(StudentPromotionRecord $record, CarbonImmutable $now): ?string
{
$deadline = $record->enrollment_deadline ? CarbonImmutable::instance($record->enrollment_deadline) : null;
if ($deadline === null) {
return null;
}
$createdAt = $record->created_at ? CarbonImmutable::instance($record->created_at) : $now;
if ($now->greaterThan($deadline)) {
return PromotionReminderLog::TYPE_EXPIRATION;
}
$totalSeconds = max(1, $deadline->getTimestamp() - $createdAt->getTimestamp());
$elapsedSeconds = max(0, $now->getTimestamp() - $createdAt->getTimestamp());
$remainingSeconds = max(0, $deadline->getTimestamp() - $now->getTimestamp());
$remainingDays = (int) floor($remainingSeconds / 86400);
if ($elapsedSeconds === 0) {
return PromotionReminderLog::TYPE_FIRST;
}
if ($remainingDays <= 3) {
return PromotionReminderLog::TYPE_FINAL;
}
if ($elapsedSeconds * 2 >= $totalSeconds) {
return PromotionReminderLog::TYPE_HALFWAY;
}
return PromotionReminderLog::TYPE_FIRST;
}
private function studentName(StudentPromotionRecord $record): ?string
{
$student = Student::query()->find($record->student_id);
if (!$student) {
return null;
}
$name = trim((string) ($student->firstname ?? '') . ' ' . (string) ($student->lastname ?? ''));
return $name !== '' ? $name : null;
}
private function dispatch(int $userId, string $subject, string $message, array $channels): void
{
if (!$this->dispatcher) {
return;
}
try {
($this->dispatcher)($userId, $subject, $message, $channels);
} catch (\Throwable $e) {
Log::error('Promotion reminder dispatcher threw', [
'user_id' => $userId,
'exception' => $e,
]);
}
}
}