update api and add more features

This commit is contained in:
root
2026-06-04 02:24:41 -04:00
parent fa6c9519a0
commit 4e33882ac7
131 changed files with 34596 additions and 340 deletions
@@ -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,
]);
}
}
}