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, ]); } } }