update api and add more features
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user