212 lines
6.9 KiB
PHP
212 lines
6.9 KiB
PHP
<?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, string, string, array):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,
|
||
]);
|
||
}
|
||
}
|
||
}
|