add notifications logic and add support of both JWT and Sanctum

This commit is contained in:
root
2026-03-11 01:20:31 -04:00
parent f6be51576c
commit 182036cc41
141 changed files with 8685 additions and 648 deletions
@@ -2,7 +2,7 @@
namespace App\Services\Administrator;
use App\Libraries\StaffTimeOffLinkService;
use App\Services\Staff\StaffTimeOffLinkService;
use App\Models\StaffAttendance;
use App\Models\User;
use App\Services\SemesterRangeService;
@@ -224,4 +224,4 @@ class AdministratorAbsenceService
Log::error('Failed to send TimeOff email (admin): ' . $e->getMessage());
}
}
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Services\Attendance;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Support\Facades\DB;
class AttendanceAutoPublishJobService
{
public function __construct(private AttendanceAutoPublishService $autoPublish)
{
}
public function run(?DateTimeImmutable $now = null): array
{
$zone = $this->autoPublish->timezone();
$now = $now ? $now->setTimezone($zone) : new DateTimeImmutable('now', $zone);
$nowStr = $now->format('Y-m-d H:i:s');
$cutoffDate = $this->autoPublish->secondSundayBackwardDate($now);
$query = DB::table('attendance_day')
->where('status', 'submitted')
->where(function ($q) use ($nowStr, $cutoffDate) {
$q->where('auto_publish_at', '<=', $nowStr)
->orWhere(function ($sub) use ($cutoffDate) {
$sub->whereNull('auto_publish_at')
->where('date', '<=', $cutoffDate);
});
});
$count = (clone $query)->count();
if ($count > 0) {
$query->update([
'status' => 'published',
'published_by' => 0,
'published_at' => $nowStr,
'updated_at' => $nowStr,
]);
}
return [
'published' => $count,
'checked_at' => $nowStr,
'timezone' => $zone->getName(),
];
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Services\Attendance;
use DateTimeImmutable;
use DateTimeZone;
class AttendanceAutoPublishService
{
public function timezone(?string $tzFromCfg = null): DateTimeZone
{
$tz = $tzFromCfg ?: (class_exists(\Config\School::class)
? (config('School')->attendance['timezone'] ?? null)
: null);
return new DateTimeZone($tz ?: date_default_timezone_get());
}
public function secondSundayAfterEndOfDay(string $ymd, ?string $tz = null): string
{
$zone = $this->timezone($tz);
$day = new DateTimeImmutable($ymd . ' 00:00:00', $zone);
$weekday = (int) $day->format('w');
$daysToNextSunday = (7 - $weekday) % 7;
if ($daysToNextSunday === 0) {
$daysToNextSunday = 7;
}
$firstSunday = $day->modify("+{$daysToNextSunday} days");
$secondSunday = $firstSunday->modify('+7 days');
return $secondSunday->setTime(23, 59, 59)->format('Y-m-d H:i:s');
}
public function secondSundayBackwardDate(DateTimeImmutable $now, ?string $tz = null): string
{
$zone = $this->timezone($tz);
$inZone = $now->setTimezone($zone);
$weekday = (int) $inZone->format('w');
$thisSunday = $inZone->setTime(0, 0, 0)->modify("-{$weekday} days");
$twoBack = $thisSunday->modify('-14 days');
return $twoBack->format('Y-m-d');
}
}
@@ -0,0 +1,149 @@
<?php
namespace App\Services\Attendance;
use App\Models\AttendanceTracking;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\Log;
class AttendanceConsequenceService
{
public function __construct(
private EmailService $emailService,
private UserNotificationDispatchService $notifier
) {
}
public function sendFollowUp(array $payload): array
{
return $this->send(
'follow_up',
$payload,
'Attendance Follow Up',
'emails/follow_up',
'info'
);
}
public function sendFinalWarning(array $payload): array
{
return $this->send(
'final_warning',
$payload,
'Final Warning: Attendance Issue',
'emails/final_warning',
'warning'
);
}
public function sendDismissal(array $payload): array
{
return $this->send(
'dismissal',
$payload,
'Notice of Dismissal',
'emails/dismissal',
'error'
);
}
private function send(
string $type,
array $payload,
string $defaultSubject,
string $viewName,
string $logLevel
): array {
$studentName = trim((string) ($payload['student']['firstname'] ?? '') . ' ' . (string) ($payload['student']['lastname'] ?? ''));
$parentEmail = (string) ($payload['parent']['email'] ?? '');
$parentName = (string) ($payload['parent']['name'] ?? '');
$teacherName = trim((string) ($payload['teacher']['firstname'] ?? '') . ' ' . (string) ($payload['teacher']['lastname'] ?? ''));
$className = (string) ($payload['class']['class_section_name'] ?? '');
$semester = (string) ($payload['semester'] ?? '');
$schoolYear = (string) ($payload['school_year'] ?? '');
$dateYmd = (string) ($payload['date'] ?? '');
$dateFmt = $dateYmd !== '' ? date('m-d-Y', strtotime($dateYmd)) : '';
$sentAt = $payload['now'] ?? utc_now();
if ($parentEmail === '') {
Log::warning("Attendance {$type}: missing parent email", [
'student_id' => $payload['student_id'] ?? null,
]);
return ['ok' => false, 'message' => 'Missing parent email.'];
}
$subject = (string) ($payload['subject'] ?? $this->buildSubject($defaultSubject, $studentName, $dateFmt));
$emailData = [
'title' => $subject,
'student_name' => $studentName,
'parent_name' => $parentName,
'teacher_name' => $teacherName,
'class_section_name' => $className,
'semester' => $semester,
'school_year' => $schoolYear,
'date_fmt' => $dateFmt,
'sent_at' => $sentAt,
];
$html = trim((string) ($payload['html'] ?? ''));
if ($html === '') {
try {
$html = view($viewName, $emailData, ['saveData' => true]);
} catch (\Throwable $e) {
$html = '<p>' . e($subject) . '</p>';
}
}
$ok = false;
try {
$ok = $this->emailService->send($parentEmail, $subject, $html, 'attendance');
} catch (\Throwable $e) {
Log::error('Attendance email send failed: ' . $e->getMessage());
}
$summary = $this->buildInAppSummary($type, $studentName, $dateFmt);
$parentUserId = (int) ($payload['parent']['user_id'] ?? 0);
if ($parentUserId > 0) {
$this->notifier->notifyUser(
$parentUserId,
$subject,
$summary,
['in_app'],
'attendance',
'parent'
);
}
if (!empty($payload['tracking_id'])) {
AttendanceTracking::markAsNotified((int) $payload['tracking_id']);
}
Log::log($logLevel, "Attendance {$type} email " . ($ok ? 'sent' : 'failed'), [
'student_id' => $payload['student_id'] ?? null,
'recipient' => $parentEmail,
]);
return ['ok' => $ok];
}
private function buildSubject(string $base, string $studentName, string $dateFmt): string
{
$name = $studentName !== '' ? "{$studentName}" : '';
$date = $dateFmt !== '' ? " ({$dateFmt})" : '';
return $base . $name . $date;
}
private function buildInAppSummary(string $type, string $studentName, string $dateFmt): string
{
$who = $studentName !== '' ? $studentName : 'a student';
$when = $dateFmt !== '' ? " on {$dateFmt}" : '';
return match ($type) {
'dismissal' => "Dismissal notice sent for {$who}{$when}.",
'final_warning' => "Final warning sent for {$who}{$when}.",
default => "Follow-up notice sent for {$who}{$when}.",
};
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services\Attendance;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\DB;
class AttendanceDailySummaryService
{
public function __construct(
private UserNotificationDispatchService $notifier,
private EmailService $emailService
) {
}
public function sendAbsenteesSummary(): int
{
$rows = $this->fetchTodaySummary('absent');
return $this->notifyParents($rows, 'absent');
}
public function sendLatesSummary(): int
{
$rows = $this->fetchTodaySummary('late');
return $this->notifyParents($rows, 'late');
}
private function fetchTodaySummary(string $type): array
{
$start = now()->startOfDay();
$endEx = now()->addDay()->startOfDay();
$query = DB::table('attendance_data as ad')
->join('students as s', 's.id', '=', 'ad.student_id')
->leftJoin('users as u', 'u.id', '=', 's.parent_id')
->select(
'ad.date',
's.id as student_id',
's.firstname as student_firstname',
's.lastname as student_lastname',
'u.id as parent_id',
'u.email as parent_email',
'u.firstname as parent_firstname',
'u.lastname as parent_lastname'
)
->where('ad.date', '>=', $start)
->where('ad.date', '<', $endEx);
if ($type === 'absent') {
$query->where(function ($w) {
$w->whereIn(DB::raw("UPPER(TRIM(ad.status))"), ['ABSENT', 'ABS', 'A'])
->orWhereRaw("UPPER(TRIM(ad.status)) LIKE 'ABS%'");
});
} else {
$query->where(function ($w) {
$w->whereIn(DB::raw("UPPER(TRIM(ad.status))"), ['LATE', 'L'])
->orWhereRaw("UPPER(TRIM(ad.status)) LIKE 'LATE%'");
});
}
return $query->get()->map(fn ($row) => (array) $row)->all();
}
private function notifyParents(array $rows, string $type): int
{
$sent = 0;
foreach ($rows as $row) {
$parentId = (int) ($row['parent_id'] ?? 0);
if ($parentId <= 0) {
continue;
}
$studentName = trim((string) ($row['student_firstname'] ?? '') . ' ' . (string) ($row['student_lastname'] ?? ''));
$date = substr((string) ($row['date'] ?? ''), 0, 10);
$message = sprintf(
'Daily Attendance Update: %s was marked %s on %s.',
$studentName !== '' ? $studentName : 'Your student',
$type,
$date
);
$this->notifier->notifyUser($parentId, 'Daily Attendance Update', $message, ['in_app'], 'attendance', 'parent');
$email = (string) ($row['parent_email'] ?? '');
if ($email !== '') {
$body = '<p>' . e($message) . '</p>';
$this->emailService->send($email, 'Daily Attendance Update', $body, 'attendance');
}
$sent++;
}
return $sent;
}
}
@@ -2,6 +2,7 @@
namespace App\Services\Attendance;
use App\Models\Role;
use Illuminate\Contracts\Auth\Authenticatable;
class AttendancePolicyService
@@ -42,24 +43,14 @@ class AttendancePolicyService
public function isAdminLike(array $roles, array $permissions = []): bool
{
$roles = array_map([$this, 'normalizeRole'], $roles);
$adminTokens = $this->adminRoleTokens();
$excluded = [
'parent',
'guest',
'teacher',
'teacher_assistant',
'assistant_teacher',
'ta',
];
foreach ($roles as $role) {
if ($role !== '' && !in_array($role, $excluded, true)) {
return true;
}
if (!empty($adminTokens) && count(array_intersect($roles, $adminTokens)) > 0) {
return true;
}
foreach ($permissions as $permission) {
$permission = strtolower(trim((string)$permission));
$permission = strtolower(trim((string) $permission));
if (str_contains($permission, 'attendance.manage') || str_contains($permission, 'attendance.admin')) {
return true;
}
@@ -72,4 +63,66 @@ class AttendancePolicyService
{
return str_replace([' ', '-'], '_', strtolower(trim((string)$role)));
}
}
public function canManageEarlyDismissals(array $userRoles): bool
{
$normalized = array_map([$this, 'normalizeRole'], $userRoles);
$adminTokens = $this->adminRoleTokens();
$isAdminLike = count(array_intersect($normalized, $adminTokens)) > 0;
$isTeacher = in_array('teacher', $normalized, true) || in_array('teacher_assistant', $normalized, true);
return $isAdminLike || $isTeacher;
}
private function adminRoleTokens(): array
{
static $cache = null;
if (is_array($cache)) {
return $cache;
}
$tokens = [];
$normalize = fn (string $value): string => $this->normalizeRole($value);
try {
$rows = Role::query()
->where('is_active', 1)
->get(['name', 'slug']);
$excluded = array_map($normalize, [
'guest',
'parent',
'student',
'teacher',
'teacher_assistant',
'ta',
]);
foreach ($rows as $row) {
$name = $normalize((string) ($row->name ?? ''));
$slug = $normalize((string) ($row->slug ?? $name));
if ($name === '' && $slug === '') {
continue;
}
if (in_array($slug, $excluded, true) || in_array($name, $excluded, true)) {
continue;
}
if ($name !== '') {
$tokens[$name] = true;
}
if ($slug !== '') {
$tokens[$slug] = true;
}
}
} catch (\Throwable) {
// ignore and fall back to defaults
}
foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $fallback) {
$tokens[$normalize($fallback)] = true;
}
$cache = array_keys($tokens);
return $cache;
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Services\Attendance;
use Illuminate\Support\Facades\DB;
class AttendanceSummaryRebuildService
{
public function rebuild(): array
{
DB::table('attendance_record')->truncate();
$rows = DB::table('attendance_data')
->orderBy('student_id')
->orderBy('school_year')
->orderBy('semester')
->get()
->map(fn ($row) => (array) $row)
->all();
if (empty($rows)) {
return ['inserted' => 0];
}
$summary = [];
foreach ($rows as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$schoolYear = (string) ($row['school_year'] ?? '');
$semester = (string) ($row['semester'] ?? '');
$status = strtolower((string) ($row['status'] ?? ''));
$key = $studentId . '-' . $schoolYear . '-' . $semester;
if (!isset($summary[$key])) {
$summary[$key] = [
'student_id' => $studentId,
'school_year' => $schoolYear,
'semester' => $semester,
'class_section_id' => $row['class_section_id'] ?? null,
'school_id' => $row['school_id'] ?? null,
'total_presence' => 0,
'total_absence' => 0,
'total_late' => 0,
'total_attendance' => 0,
'created_at' => utc_now(),
'updated_at' => utc_now(),
];
}
$summary[$key]['total_attendance']++;
if ($status === 'present') {
$summary[$key]['total_presence']++;
} elseif ($status === 'absent') {
$summary[$key]['total_absence']++;
} elseif ($status === 'late') {
$summary[$key]['total_late']++;
}
}
DB::table('attendance_record')->insert(array_values($summary));
return ['inserted' => count($summary)];
}
}
@@ -2,7 +2,6 @@
namespace App\Services\Attendance;
use App\Libraries\AttendanceAutoPublish;
use App\Models\AttendanceData;
use App\Models\AttendanceDay;
use App\Models\Student;
@@ -21,6 +20,7 @@ class TeacherAttendanceSubmissionService
protected Student $student,
protected AttendancePolicyService $attendancePolicyService,
protected StudentAttendanceWriterService $studentAttendanceWriterService,
protected AttendanceAutoPublishService $attendanceAutoPublishService,
) {}
public function submit(
@@ -201,7 +201,7 @@ class TeacherAttendanceSubmissionService
]);
}
$autoPublishAt = AttendanceAutoPublish::secondSundayAfterEndOfDay($attendDate);
$autoPublishAt = $this->attendanceAutoPublishService->secondSundayAfterEndOfDay($attendDate);
$dayRow->update([
'status' => 'submitted',
@@ -217,4 +217,4 @@ class TeacherAttendanceSubmissionService
'message' => 'Attendance submitted successfully.',
];
}
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Services\Auth;
use App\Models\PasswordResetRequest;
class PasswordResetCleanupService
{
public function cleanup(int $days = 30): int
{
$threshold = now()->subDays($days)->toDateTimeString();
return PasswordResetRequest::query()
->where('requested_at', '<', $threshold)
->delete();
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Services\Grading;
use App\Services\EmailService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class BelowSixtyEmailService
{
public function __construct(private EmailService $emailService)
{
}
public function send(array $payload): array
{
$studentId = (int) ($payload['student_id'] ?? 0);
$studentName = (string) ($payload['student_name'] ?? '');
$classSection = (string) ($payload['class_section_name'] ?? '');
$semester = (string) ($payload['semester'] ?? '');
$schoolYear = (string) ($payload['school_year'] ?? '');
$scores = is_array($payload['scores'] ?? null) ? $payload['scores'] : [];
$comment = (string) ($payload['comment'] ?? '');
if ($studentId <= 0) {
Log::warning('Below60Email: missing student_id');
return ['ok' => false, 'message' => 'Missing student id.'];
}
$rows = DB::select(
"SELECT u.firstname, u.lastname, u.email, fg.is_primary
FROM family_students fs
JOIN family_guardians fg ON fg.family_id = fs.family_id
JOIN users u ON u.id = fg.user_id
WHERE fs.student_id = ?
AND fg.receive_emails = 1
AND u.email IS NOT NULL AND u.email != ''
ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
[$studentId]
);
if (empty($rows)) {
Log::warning('Below60Email: no guardian emails', ['student_id' => $studentId]);
return ['ok' => false, 'message' => 'No guardian email addresses.'];
}
$emails = [];
foreach ($rows as $row) {
$email = trim((string) ($row->email ?? ''));
if ($email !== '') {
$emails[$email] = true;
}
}
$emails = array_keys($emails);
$primary = $rows[0] ?? null;
$parentName = '';
if ($primary) {
$parentName = trim((string) ($primary->firstname ?? '') . ' ' . (string) ($primary->lastname ?? ''));
}
$subject = (string) ($payload['subject'] ?? '');
if ($subject === '') {
$subject = 'Student Performance Alert';
if ($studentName !== '') {
$subject .= ' — ' . $studentName;
}
if ($semester !== '' || $schoolYear !== '') {
$subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
}
}
$emailData = [
'title' => $subject,
'parent_name' => $parentName !== '' ? $parentName : 'Parent/Guardian',
'student_name' => $studentName !== '' ? $studentName : 'your student',
'class_section_name' => $classSection,
'semester' => $semester,
'school_year' => $schoolYear,
'scores' => $scores,
'comment' => $comment,
'sent_at' => utc_now(),
];
$html = trim((string) ($payload['html'] ?? ''));
if ($html === '') {
try {
$html = view('emails/below_sixty_performance', $emailData, ['saveData' => true]);
} catch (\Throwable $e) {
$html = '<p>' . e($subject) . '</p>';
}
}
$sent = 0;
foreach ($emails as $to) {
$ok = $this->emailService->send($to, $subject, $html, 'general');
if ($ok) {
$sent++;
}
}
Log::info('Below60Email dispatch', [
'student_id' => $studentId,
'sent' => $sent,
'recipients' => count($emails),
]);
return [
'ok' => $sent > 0,
'sent' => $sent,
'recipients' => count($emails),
];
}
}
@@ -138,7 +138,7 @@ class GradingBelowSixtyService
public function sendEmail(array $payload): void
{
Event::dispatch('below60.email', $payload);
Event::dispatch('below60.email', [$payload]);
}
public function updateStatus(int $studentId, string $semester, string $schoolYear, string $status, string $note, ?int $userId): void
@@ -0,0 +1,34 @@
<?php
namespace App\Services\Notifications;
use App\Models\Notification;
class NotificationActiveService
{
public function list(?string $targetGroup): array
{
$rows = Notification::getActiveNotifications($targetGroup);
$now = time();
$notifications = array_map(static function ($row) use ($now) {
$expiresAt = $row['expires_at'] ?? null;
$expiryTs = $expiresAt ? strtotime((string) $expiresAt) : false;
$isExpired = $expiryTs !== false && $expiryTs <= $now;
return [
'id' => $row['id'] ?? null,
'title' => $row['title'] ?? null,
'message' => $row['message'] ?? null,
'priority' => $row['priority'] ?? null,
'scheduled_at' => $row['scheduled_at'] ?? null,
'expires_at' => $expiresAt,
'created_at' => $row['created_at'] ?? null,
'target_group' => $row['target_group'] ?? null,
'isExpired' => $isExpired,
];
}, $rows ?? []);
return ['notifications' => $notifications];
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Services\Notifications;
use App\Models\Notification;
class NotificationCleanupService
{
public function cleanupExpired(): int
{
return Notification::deleteExpiredNotifications();
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Services\Notifications;
use App\Models\Notification;
class NotificationDeletedService
{
public function list(): array
{
$rows = Notification::getDeletedNotifications();
$notifications = array_map(static function ($row) {
return [
'id' => $row['id'] ?? null,
'title' => $row['title'] ?? null,
'message' => $row['message'] ?? null,
'priority' => $row['priority'] ?? null,
'scheduled_at' => $row['scheduled_at'] ?? null,
'expires_at' => $row['expires_at'] ?? null,
'deleted_at' => $row['deleted_at'] ?? null,
];
}, $rows ?? []);
return ['notifications' => $notifications];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Notifications;
use Illuminate\Support\Facades\Log;
class NotificationDispatchService
{
public function sendEmail(string $to, string $subject, string $message): void
{
Log::info('Notification email queued.', [
'to' => $to,
'subject' => $subject,
]);
}
public function sendSms(string $phone, string $message): void
{
Log::info('Notification SMS queued.', [
'phone' => $phone,
]);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Services\Notifications;
use App\Models\Notification;
class NotificationManagementService
{
public function update(int $notificationId, array $payload): array
{
$notification = Notification::query()->find($notificationId);
if (!$notification) {
return ['ok' => false, 'message' => 'Notification not found.'];
}
$notification->update([
'title' => $payload['title'] ?? $notification->title,
'message' => $payload['message'] ?? $notification->message,
'target_group' => $payload['target_group'] ?? $notification->target_group,
'delivery_channels' => $payload['channels'] ?? $notification->delivery_channels,
'priority' => $payload['priority'] ?? $notification->priority,
'status' => $payload['status'] ?? $notification->status,
'action_url' => $payload['action_url'] ?? $notification->action_url,
'attachment_path' => $payload['attachment_path'] ?? $notification->attachment_path,
'scheduled_at' => $payload['scheduled_at'] ?? $notification->scheduled_at,
'expires_at' => $payload['expires_at'] ?? $notification->expires_at,
'updated_at' => utc_now(),
]);
return ['ok' => true, 'notification' => $notification];
}
public function delete(int $notificationId): bool
{
$notification = Notification::query()->find($notificationId);
if (!$notification) {
return false;
}
return (bool) $notification->delete();
}
public function restore(int $notificationId): bool
{
return Notification::restoreNotification($notificationId);
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Services\Notifications;
use App\Models\UserNotification;
class NotificationReadService
{
public function markRead(int $userId, int $notificationId): bool
{
$row = UserNotification::query()
->where('user_id', $userId)
->where('notification_id', $notificationId)
->first();
if (!$row) {
return false;
}
$row->is_read = true;
$row->save();
return true;
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Services\Notifications;
use App\Models\User;
class NotificationRecipientService
{
public function getRecipients(string $targetGroup): array
{
$rows = User::getUsersByRole($targetGroup);
return is_array($rows) ? $rows : [];
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Services\Notifications;
use App\Models\Notification;
use App\Models\UserNotification;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class NotificationSendService
{
public function __construct(
private NotificationRecipientService $recipients,
private NotificationDispatchService $dispatcher
) {
}
public function send(array $payload, int $actorId): array
{
$channels = $payload['channels'] ?? ['in_app'];
$channels = array_values(array_unique(array_map('strtolower', (array) $channels)));
if (empty($channels)) {
$channels = ['in_app'];
}
return DB::transaction(function () use ($payload, $channels, $actorId) {
$notification = Notification::query()->create([
'title' => $payload['title'],
'message' => $payload['message'],
'target_group' => $payload['target_group'],
'delivery_channels' => $channels,
'priority' => $payload['priority'] ?? null,
'status' => $payload['status'] ?? null,
'action_url' => $payload['action_url'] ?? null,
'attachment_path' => $payload['attachment_path'] ?? null,
'scheduled_at' => $payload['scheduled_at'] ?? now(),
'sent_at' => in_array('in_app', $channels, true) ? now() : null,
'expires_at' => $payload['expires_at'] ?? null,
'school_year' => $payload['school_year'] ?? null,
'semester' => $payload['semester'] ?? null,
'created_at' => utc_now(),
'updated_at' => utc_now(),
]);
$users = $this->recipients->getRecipients((string) $payload['target_group']);
$created = 0;
foreach ($users as $user) {
UserNotification::query()->create([
'notification_id' => (int) $notification->id,
'user_id' => (int) ($user['id'] ?? 0),
'is_read' => false,
'delivered' => in_array('in_app', $channels, true),
'delivered_at' => in_array('in_app', $channels, true) ? now() : null,
'school_year' => $payload['school_year'] ?? null,
'semester' => $payload['semester'] ?? null,
]);
$created++;
if (in_array('email', $channels, true) && !empty($user['email'])) {
$this->dispatcher->sendEmail((string) $user['email'], (string) $payload['title'], (string) $payload['message']);
}
if (in_array('sms', $channels, true) && !empty($user['phone'])) {
$this->dispatcher->sendSms((string) $user['phone'], (string) $payload['message']);
}
}
Log::info('Notification dispatched.', [
'notification_id' => (int) $notification->id,
'target_group' => $payload['target_group'],
'channels' => $channels,
'recipients' => $created,
'actor_id' => $actorId,
]);
return [
'ok' => true,
'notification' => $notification,
'recipient_count' => $created,
];
});
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Services\Notifications;
use App\Models\UserNotification;
class NotificationShowService
{
public function getForUser(int $userId, int $notificationId): ?array
{
$row = UserNotification::query()
->select([
'user_notifications.*',
'notifications.title',
'notifications.message',
'notifications.priority',
'notifications.scheduled_at',
'notifications.expires_at',
'notifications.target_group',
])
->join('notifications', 'notifications.id', '=', 'user_notifications.notification_id')
->where('user_notifications.user_id', $userId)
->where('user_notifications.notification_id', $notificationId)
->first();
return $row ? $row->toArray() : null;
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Notifications;
use Illuminate\Support\Facades\Event;
class NotificationTriggerService
{
public static function trigger(string $eventType, array $payload): void
{
Event::dispatch($eventType, $payload);
}
public static function toUser(int $userId, string $title, string $message, array $channels = ['in_app']): void
{
self::trigger('customNotification', [
'user_id' => $userId,
'title' => $title,
'message' => $message,
'channels' => $channels,
]);
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Services\Notifications;
use App\Models\UserNotification;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class NotificationUserListService
{
public function listForUser(int $userId, array $filters): array
{
$perPage = (int) ($filters['per_page'] ?? 25);
$page = (int) ($filters['page'] ?? 1);
$sortBy = (string) ($filters['sort_by'] ?? 'created_at');
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
$allowedSorts = ['created_at', 'scheduled_at', 'priority'];
if (!in_array($sortBy, $allowedSorts, true)) {
$sortBy = 'created_at';
}
$query = UserNotification::query()
->select([
'user_notifications.*',
'notifications.title',
'notifications.message',
'notifications.priority',
'notifications.scheduled_at',
'notifications.expires_at',
'notifications.target_group',
])
->join('notifications', 'notifications.id', '=', 'user_notifications.notification_id')
->where('user_notifications.user_id', $userId);
if (isset($filters['read'])) {
$read = filter_var($filters['read'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($read !== null) {
$query->where('user_notifications.is_read', $read ? 1 : 0);
}
}
$paginator = $query
->orderBy('notifications.' . $sortBy, $sortDir)
->paginate($perPage, ['*'], 'page', $page);
return [
'notifications' => $paginator,
'pagination' => $this->paginationPayload($paginator),
];
}
private function paginationPayload(LengthAwarePaginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'total_pages' => $paginator->lastPage(),
];
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Services\Notifications;
use App\Models\Configuration;
use App\Models\Notification;
use App\Models\UserNotification;
class UserNotificationDispatchService
{
public function notifyUser(
int $userId,
string $title,
string $message,
array $channels = ['in_app'],
string $topic = 'general',
?string $targetGroup = null
): array {
if ($userId <= 0) {
return ['ok' => false, 'message' => 'Invalid user id.'];
}
$channels = array_values(array_unique(array_map('strtolower', (array) $channels)));
if (empty($channels)) {
$channels = ['in_app'];
}
$schoolYear = Configuration::getConfigValueByKey('school_year');
$semester = Configuration::getConfigValueByKey('semester');
$notification = Notification::query()->create([
'title' => $title,
'message' => $message,
'target_group' => $targetGroup ?: 'everyone',
'delivery_channels' => $channels,
'priority' => 'normal',
'status' => 'sent',
'scheduled_at' => now(),
'sent_at' => now(),
'school_year' => $schoolYear,
'semester' => $semester,
]);
UserNotification::query()->create([
'notification_id' => (int) $notification->id,
'user_id' => $userId,
'is_read' => 0,
'delivered' => in_array('in_app', $channels, true) ? 1 : 0,
'delivered_at' => in_array('in_app', $channels, true) ? now() : null,
'school_year' => $schoolYear,
'semester' => $semester,
]);
return [
'ok' => true,
'notification_id' => (int) $notification->id,
'topic' => $topic,
];
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Services\Payments;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\DB;
class PaymentMissedCheckService
{
public function __construct(
private UserNotificationDispatchService $notifier,
private EmailService $emailService
) {
}
public function findUsersWithMissedPayments(): array
{
$today = now()->toDateString();
return DB::table('invoices as i')
->join('users as u', 'u.id', '=', 'i.parent_id')
->select(
'u.id as user_id',
'u.firstname',
'u.lastname',
'u.email',
DB::raw('MIN(i.due_date) as due_date'),
DB::raw('SUM(i.balance) as balance')
)
->whereNotNull('i.due_date')
->where('i.due_date', '<', $today)
->where('i.balance', '>', 0)
->whereNotIn(DB::raw('LOWER(i.status)'), ['paid'])
->groupBy('u.id', 'u.firstname', 'u.lastname', 'u.email')
->get()
->map(fn ($row) => (array) $row)
->all();
}
public function sendReminders(array $users): array
{
$sent = 0;
$failed = 0;
foreach ($users as $user) {
$userId = (int) ($user['user_id'] ?? 0);
$email = (string) ($user['email'] ?? '');
$name = trim((string) ($user['firstname'] ?? '') . ' ' . (string) ($user['lastname'] ?? ''));
$this->notifier->notifyUser(
$userId,
'Payment Missed',
'You have a missed payment. Please pay to avoid penalty.',
['in_app'],
'payment',
'parent'
);
if ($email !== '') {
$subject = 'Payment Missed';
$body = '<p>Dear ' . e($name !== '' ? $name : 'Parent') . ',</p>'
. '<p>You have a missed payment. Please pay to avoid penalty.</p>';
$ok = $this->emailService->send($email, $subject, $body, 'finance');
$ok ? $sent++ : $failed++;
}
}
return [
'sent' => $sent,
'failed' => $failed,
];
}
}
@@ -0,0 +1,220 @@
<?php
namespace App\Services\Payments;
use App\Models\Configuration;
use App\Models\FamilyGuardian;
use App\Models\Invoice;
use App\Models\PaymentNotificationLog;
use App\Models\User;
use App\Services\EmailService;
use DateTimeImmutable;
use DateTimeZone;
class PaymentTestNotificationService
{
public function __construct(private EmailService $emailService)
{
}
public function send(string $email, string $type = 'no_payment'): array
{
$email = trim($email);
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'message' => 'Invalid email address.'];
}
$type = in_array($type, ['no_payment', 'installment'], true) ? $type : 'no_payment';
$tzName = $this->resolveTimezone();
$tz = new DateTimeZone($tzName);
$now = new DateTimeImmutable('now', $tz);
$year = (int) $now->format('Y');
$month = (int) $now->format('n');
$schoolYear = (string) (Configuration::getConfig('school_year') ?? $year);
$user = User::query()->where('email', $email)->first();
$parentId = (int) ($user->id ?? 0);
$invoices = $parentId > 0
? Invoice::query()->where('parent_id', $parentId)->where('school_year', $schoolYear)->get()
: collect();
$totalBalance = 0.0;
$latestInvoiceId = null;
foreach ($invoices as $invoice) {
$totalBalance += (float) ($invoice->balance ?? 0);
if ($latestInvoiceId === null) {
$latestInvoiceId = (int) ($invoice->id ?? 0);
}
}
$ccEmail = $this->findSecondaryGuardianEmail($parentId, $email);
$parentName = $user ? trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? '')) : '';
$monthYear = $now->format('F Y');
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
$balanceFmt = '$' . number_format($totalBalance, 2);
$intro = ($type === 'no_payment')
? "We noticed there are outstanding tuition charges for {$schoolYear} but no payment has been recorded yet."
: "This is your monthly installment reminder for {$schoolYear}.";
$installmentInfo = $this->computeInstallmentSuggestion($totalBalance, $tzName);
$bodyHtml = <<<HTML
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder (Test)</h2>
<p>{$greeting}</p>
<p>{$intro}</p>
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
<p>
As a friendly reminder, our tuition installments are due at the beginning of each month.
You can review your invoice and payment options by logging in to your parent portal.
</p>
<p>Thank you for your prompt attention.</p>
<p><em>Reminder Period:</em> {$monthYear}</p>
</div>
HTML;
$bodyHtml .= "<ul>"
. "<li><strong>Remaining installments:</strong> {$installmentInfo['remaining_installments']}</li>"
. "<li><strong>Suggested installment this month:</strong> {$installmentInfo['installment_due_fmt']}</li>"
. "<li><strong>Maximum installments available:</strong> {$installmentInfo['max_installments']}</li>"
. "</ul>";
if (view()->exists('emails._wrap_layout')) {
$body = view('emails._wrap_layout', [
'title' => 'Monthly Tuition Reminder',
'body_html' => $bodyHtml,
], ['saveData' => true]);
} else {
$body = $bodyHtml;
}
$ok = $this->emailService->send($email, $subject, (string) $body, 'finance');
if ($ok && $ccEmail && strcasecmp($ccEmail, $email) !== 0) {
$this->emailService->send($ccEmail, $subject, (string) $body, 'finance');
}
$existing = PaymentNotificationLog::query()
->where('parent_id', $parentId)
->where('period_year', $year)
->where('period_month', $month)
->where('type', $type)
->first();
$payload = [
'parent_id' => $parentId,
'invoice_id' => $latestInvoiceId,
'school_year' => $schoolYear,
'period_year' => $year,
'period_month' => $month,
'type' => $type,
'to_email' => $email,
'cc_email' => $ccEmail,
'head_fa_notified' => 0,
'subject' => $subject,
'body' => (string) $body,
'status' => $ok ? 'sent' : 'failed',
'error_message' => $ok ? null : 'Email send failed (see logs)',
'balance_snapshot' => $totalBalance,
'sent_at' => $now->format('Y-m-d H:i:s'),
];
if ($existing) {
$existing->fill($payload)->save();
$logId = (int) $existing->id;
} else {
$log = PaymentNotificationLog::query()->create($payload);
$logId = (int) $log->id;
}
return [
'ok' => $ok,
'log_id' => $logId,
'cc_email' => $ccEmail,
];
}
private function computeInstallmentSuggestion(float $balance, string $tzName): array
{
$installmentEndRaw = (string) (Configuration::getConfig('installment_date') ?? '');
$tz = new DateTimeZone($tzName);
$today = new DateTimeImmutable('today', $tz);
$end = null;
if ($installmentEndRaw !== '') {
try {
$end = new DateTimeImmutable($installmentEndRaw, $tz);
} catch (\Throwable $e) {
$end = null;
}
}
$remMonths = 0;
if ($end) {
$y = (int) $end->format('Y') - (int) $today->format('Y');
$m = (int) $end->format('n') - (int) $today->format('n');
$remMonths = $y * 12 + $m;
if ((int) $end->format('j') > (int) $today->format('j')) {
$remMonths += 1;
}
if ($remMonths < 0) {
$remMonths = 0;
}
}
$remainingInstallments = max(1, $remMonths);
$maxInstallments = max($remMonths ?: 0, $balance > 0 ? 2 : 0);
$installmentDue = $remainingInstallments > 0 ? ($balance / $remainingInstallments) : 0;
return [
'remaining_installments' => $remainingInstallments,
'max_installments' => $maxInstallments,
'installment_due' => round($installmentDue, 2),
'installment_due_fmt' => '$' . number_format($installmentDue, 2),
];
}
private function findSecondaryGuardianEmail(int $parentId, string $primaryEmail): ?string
{
if ($parentId <= 0) {
return null;
}
$row = FamilyGuardian::query()->where('user_id', $parentId)->first();
if (!$row || empty($row->family_id)) {
return null;
}
$others = FamilyGuardian::query()
->where('family_id', (int) $row->family_id)
->where('user_id', '!=', $parentId)
->where('receive_emails', 1)
->get();
foreach ($others as $guardian) {
$email = (string) (User::query()->where('id', (int) $guardian->user_id)->value('email') ?? '');
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) && strcasecmp($email, $primaryEmail) !== 0) {
return $email;
}
}
return null;
}
private function resolveTimezone(): string
{
$schoolConfig = config('School');
if (is_object($schoolConfig) && isset($schoolConfig->attendance['timezone'])) {
return (string) $schoolConfig->attendance['timezone'];
}
if (is_array($schoolConfig) && isset($schoolConfig['attendance']['timezone'])) {
return (string) $schoolConfig['attendance']['timezone'];
}
return (string) (config('app.timezone') ?: 'UTC');
}
}
@@ -0,0 +1,173 @@
<?php
namespace App\Services\Payments;
use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\Invoice;
use App\Models\PayPalPayment;
use App\Models\Payment;
use App\Models\Student;
use App\Models\User;
use App\Services\EmailService;
use Illuminate\Support\Facades\Log;
class PaypalPaymentSyncService
{
public function __construct(private EmailService $emailService)
{
}
public function sync(bool $dryRun = false, bool $reportOnly = false): array
{
$mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE');
$semester = (string) (Configuration::getConfig('semester') ?? '');
$schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
$entries = PayPalPayment::query()
->where('status', 'COMPLETED')
->where('synced', 0)
->where('sync_attempts', '<', 3)
->whereNotNull('transaction_id')
->get();
$synced = 0;
$failed = [];
foreach ($entries as $entry) {
if (!$reportOnly) {
$entry->sync_attempts = (int) $entry->sync_attempts + 1;
$entry->save();
}
$user = User::query()->where('school_id', $entry->parent_school_id)->first();
if (!$user) {
$failed[] = (string) $entry->transaction_id;
Log::error('PayPal sync: no user for parent_school_id', ['parent_school_id' => $entry->parent_school_id]);
continue;
}
$invoice = Invoice::query()
->where('parent_id', $user->id)
->where('school_year', $schoolYear)
->latest('created_at')
->first();
if (!$reportOnly && !$dryRun) {
if ($invoice) {
$ok = $this->applyPayment($invoice, $entry->amount, (string) $entry->transaction_id, $schoolYear, $semester, $entry->created_at);
if (!$ok) {
$failed[] = (string) $entry->transaction_id;
continue;
}
} else {
Payment::query()->create([
'parent_id' => $user->id,
'invoice_id' => 0,
'total_amount' => $entry->amount,
'paid_amount' => $entry->amount,
'balance' => 0.00,
'number_of_installments' => 1,
'transaction_id' => $entry->transaction_id,
'payment_method' => 'PayPal',
'payment_date' => optional($entry->created_at)->format('Y-m-d'),
'school_year' => $schoolYear,
'semester' => $semester,
'status' => 'Completed',
'updated_by' => null,
]);
}
$entry->synced = 1;
$entry->save();
}
$synced++;
}
$this->sendReport($mode, $synced, $failed);
return [
'mode' => $mode,
'processed' => $synced,
'failed' => $failed,
];
}
private function applyPayment(Invoice $invoice, float $amount, string $transactionId, string $schoolYear, string $semester, $createdAt): bool
{
$newPaid = (float) $invoice->paid_amount + $amount;
$newBalance = (float) $invoice->balance - $amount;
$invoice->paid_amount = $newPaid;
$invoice->balance = $newBalance;
if ($newBalance <= 0) {
$invoice->status = 'Paid';
}
if (!$invoice->save()) {
return false;
}
Payment::query()->create([
'parent_id' => $invoice->parent_id,
'invoice_id' => $invoice->id,
'total_amount' => $invoice->total_amount,
'paid_amount' => $amount,
'balance' => $newBalance,
'number_of_installments' => 1,
'transaction_id' => $transactionId,
'payment_method' => 'PayPal',
'payment_date' => $createdAt ? date('Y-m-d', strtotime((string) $createdAt)) : date('Y-m-d'),
'status' => $newBalance <= 0 ? 'Full' : 'Partial',
'check_file' => null,
'updated_by' => null,
'school_year' => $schoolYear,
'semester' => $semester,
]);
$this->updateEnrollmentStatusIfPaid($invoice->id, $schoolYear);
return true;
}
private function updateEnrollmentStatusIfPaid(int $invoiceId, string $schoolYear): void
{
$invoice = Invoice::query()->find($invoiceId);
if (!$invoice || (float) $invoice->balance > 0) {
return;
}
$students = Student::query()
->where('parent_id', $invoice->parent_id)
->where('school_year', $schoolYear)
->get();
foreach ($students as $student) {
Enrollment::query()
->where('student_id', $student->id)
->update(['enrollment_status' => 'enrolled']);
}
}
private function sendReport(string $mode, int $processed, array $failed): void
{
if ($processed === 0 && empty($failed)) {
Log::info("[{$mode}] No PayPal sync updates.");
return;
}
$subject = "[{$mode}] PayPal Sync Report - " . now()->format('Y-m-d H:i');
$body = "PayPal Sync Mode: {$mode}\n\n";
$body .= "{$processed} PayPal payments processed.\n\n";
if (!empty($failed)) {
$body .= count($failed) . " failed transactions:\n" . implode("\n", $failed);
} else {
$body .= "No failed transactions.\n";
}
$this->emailService->send('support@alrahmaisgl.org', $subject, nl2br($body), 'finance');
}
}
+324
View File
@@ -0,0 +1,324 @@
<?php
namespace App\Services\School;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Family;
use App\Models\FamilyGuardian;
use App\Models\FamilyStudent;
use App\Models\PromotionQueue;
use App\Models\Student;
use App\Models\User;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class AccountEventService
{
public function __construct(
private EmailService $emailService,
private UserNotificationDispatchService $notifier
) {
}
public function newAccountAdded(array $userData): array
{
$subject = 'Welcome to Al Rahma Sunday School!';
$message = $this->renderEmail('emails/welcome_user', ['user' => $userData], $subject);
$sent = false;
if (!empty($userData['email'])) {
$sent = $this->emailService->send(
(string) $userData['email'],
$subject,
$message,
'registration'
);
}
$supportMessage = $this->renderEmail('emails/support_new_account', ['user' => $userData], $subject);
$this->emailService->send(
'support@alrahmaisgl.org',
'New Account Created: ' . trim((string) ($userData['firstname'] ?? '') . ' ' . (string) ($userData['lastname'] ?? '')),
$supportMessage,
'notifications'
);
$this->ensureFamilyLinks($userData);
return ['ok' => $sent];
}
public function studentRegistered(array $data): array
{
if (empty($data['parents']) || !is_array($data['parents'])) {
Log::warning('StudentRegistered: missing parent data');
return ['ok' => false, 'message' => 'Missing parent data.'];
}
$studentFullName = trim((string) ($data['firstname'] ?? '') . ' ' . (string) ($data['lastname'] ?? ''));
if (!empty($data['parents']['user_id'])) {
$title = 'Your child has been registered';
$msg = "{$studentFullName} has been successfully registered to Al Rahma Sunday School.";
$this->notifier->notifyUser((int) $data['parents']['user_id'], $title, $msg, ['in_app', 'email'], 'registration', 'parent');
}
$adminMessage = $this->renderEmail('emails/admin_student_registered', ['student' => $data], 'Student Registered');
$this->emailService->send(
'registration@alrahmaisgl.org',
"New Student Registered: {$studentFullName}",
$adminMessage,
'notifications'
);
return ['ok' => true];
}
public function deleteUnverifiedUser(array $user): array
{
$this->notifier->notifyUser(
(int) ($user['id'] ?? 0),
'Account Deleted - Unverified',
'Your account was deleted because it was not verified within the time limit.',
['email'],
'notifications',
'parent'
);
$adminEmails = array_map('trim', explode(',', (string) Configuration::getConfigValueByKey('delete_email_notify')));
$adminEmails = array_filter($adminEmails, static fn ($e) => $e !== '');
foreach ($adminEmails as $adminEmail) {
$body = "Unverified account deleted: ID {$user['id']}, Email {$user['email']}";
$this->emailService->send($adminEmail, 'Unverified User Deleted', $body, 'notifications');
}
return ['ok' => true];
}
public function userRegistered(array $user): array
{
$this->notifier->notifyUser((int) ($user['id'] ?? 0), 'Welcome!', 'Thanks for registering with our school system.', ['in_app', 'email'], 'notifications', 'parent');
return ['ok' => true];
}
public function userProfileUpdated(array $user): array
{
$this->notifier->notifyUser((int) ($user['id'] ?? 0), 'Profile Updated', 'Your profile information was successfully updated.', ['in_app'], 'notifications', 'parent');
return ['ok' => true];
}
public function userDeactivated(array $user): array
{
$this->notifier->notifyUser((int) ($user['id'] ?? 0), 'Account Deactivated', 'Your account has been deactivated by the administrator.', ['in_app', 'email'], 'notifications', 'parent');
return ['ok' => true];
}
public function scoresPosted(array $data): array
{
foreach ((array) ($data['students'] ?? []) as $student) {
$this->notifier->notifyUser((int) ($student['id'] ?? 0), 'New Score Available', 'A new score has been posted.', ['in_app', 'email'], 'registration', 'student');
}
return ['ok' => true];
}
public function finalScoreReleased(array $data): array
{
foreach ((array) ($data['students'] ?? []) as $student) {
$this->notifier->notifyUser((int) ($student['id'] ?? 0), 'Final Score Released', 'Your final score for the semester is now available.', ['in_app', 'email'], 'registration', 'student');
}
$this->preparePromotionQueue();
return ['ok' => true];
}
public function studentMarkedAbsent(array $record): array
{
$msg = (string) ($record['student_name'] ?? 'Student') . ' was absent on ' . (string) ($record['date'] ?? '');
$this->notifier->notifyUser((int) ($record['parent_id'] ?? 0), 'Absence Notification', $msg, ['in_app', 'email'], 'registration', 'parent');
return ['ok' => true];
}
public function studentMarkedLate(array $record): array
{
$msg = (string) ($record['student_name'] ?? 'Student') . ' was late on ' . (string) ($record['date'] ?? '');
$this->notifier->notifyUser((int) ($record['parent_id'] ?? 0), 'Late Notification', $msg, ['in_app', 'email'], 'registration', 'parent');
return ['ok' => true];
}
public function paymentMissed(array $user): array
{
$this->notifier->notifyUser((int) ($user['id'] ?? 0), 'Payment Missed', 'You have missed a scheduled payment. Please settle your balance.', ['in_app', 'email'], 'payment', 'parent');
return ['ok' => true];
}
public function classScheduleUpdated(array $data): array
{
foreach ((array) ($data['students'] ?? []) as $student) {
$this->notifier->notifyUser((int) ($student['id'] ?? 0), 'Schedule Update', 'Your class schedule has been updated.', ['in_app'], 'notifications', 'student');
}
return ['ok' => true];
}
public function newMessageReceived(array $data): array
{
$msg = 'You have a new message from ' . (string) ($data['sender_name'] ?? '');
$this->notifier->notifyUser((int) ($data['recipient_id'] ?? 0), 'New Message Received', $msg, ['in_app'], 'notifications', 'parent');
return ['ok' => true];
}
public function systemAnnouncementPosted(array $data): array
{
$targetGroup = (string) ($data['target_group'] ?? 'everyone');
$users = (array) ($data['users'] ?? []);
if (empty($users) && $targetGroup !== '') {
$users = User::getUsersByRole($targetGroup);
}
foreach ($users as $user) {
$this->notifier->notifyUser((int) ($user['id'] ?? 0), (string) ($data['title'] ?? ''), (string) ($data['message'] ?? ''), ['in_app'], 'notifications', $targetGroup);
}
return ['ok' => true];
}
public function customNotification(array $data): array
{
$this->notifier->notifyUser(
(int) ($data['user_id'] ?? 0),
(string) ($data['title'] ?? ''),
(string) ($data['message'] ?? ''),
(array) ($data['channels'] ?? ['in_app']),
'notifications',
(string) ($data['target_group'] ?? 'everyone')
);
return ['ok' => true];
}
private function ensureFamilyLinks(array $userData): void
{
$userId = (int) ($userData['id'] ?? 0);
if ($userId <= 0) {
return;
}
DB::transaction(function () use ($userData, $userId): void {
$familyCode = 'FAM-' . $userId;
$family = Family::query()->where('family_code', $familyCode)->first();
if (!$family) {
$householdName = trim('Family of ' . ((string) ($userData['firstname'] ?? '') . ' ' . (string) ($userData['lastname'] ?? '')));
$family = Family::query()->create([
'family_code' => $familyCode,
'household_name' => $householdName,
'address_line1' => $userData['address_street'] ?? null,
'city' => $userData['city'] ?? null,
'state' => $userData['state'] ?? null,
'postal_code' => $userData['zip'] ?? null,
'primary_phone' => $userData['cellphone'] ?? null,
'is_active' => 1,
]);
}
FamilyGuardian::query()->firstOrCreate([
'family_id' => (int) $family->id,
'user_id' => $userId,
], [
'relation' => 'primary',
'is_primary' => 1,
'receive_emails' => 1,
'receive_sms' => 0,
]);
$studentIds = Student::query()
->where('parent_id', $userId)
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
foreach ($studentIds as $studentId) {
FamilyStudent::query()->firstOrCreate([
'family_id' => (int) $family->id,
'student_id' => $studentId,
], [
'is_primary_home' => 1,
]);
}
});
}
private function preparePromotionQueue(): void
{
try {
$currentYear = (string) Configuration::getConfigValueByKey('school_year');
if (!preg_match('/^(\d{4})-(\d{4})$/', $currentYear, $m)) {
return;
}
$y1 = (int) $m[1];
$y2 = (int) $m[2];
$nextYear = ($y1 + 1) . '-' . ($y2 + 1);
$rows = DB::table('students as s')
->select('s.id as student_id', 'sc.class_section_id', 'fall.semester_score as fall_score', 'spring.semester_score as spring_score')
->leftJoin('student_class as sc', function ($join) use ($currentYear) {
$join->on('sc.student_id', '=', 's.id')
->where('sc.school_year', '=', $currentYear);
})
->leftJoin('semester_scores as fall', function ($join) use ($currentYear) {
$join->on('fall.student_id', '=', 's.id')
->where('fall.semester', '=', 'fall')
->where('fall.school_year', '=', $currentYear);
})
->leftJoin('semester_scores as spring', function ($join) use ($currentYear) {
$join->on('spring.student_id', '=', 's.id')
->where('spring.semester', '=', 'spring')
->where('spring.school_year', '=', $currentYear);
})
->get();
foreach ($rows as $row) {
$fall = isset($row->fall_score) ? (float) $row->fall_score : null;
$spring = isset($row->spring_score) ? (float) $row->spring_score : null;
if ($fall === null || $spring === null) {
continue;
}
$finalAvg = ($fall + $spring) / 2.0;
if ($finalAvg < 60.0) {
continue;
}
$fromSection = (int) ($row->class_section_id ?? 0);
if ($fromSection <= 0) {
continue;
}
$fromClassId = (int) (ClassSection::getClassId($fromSection) ?? 0);
if ($fromClassId <= 0) {
continue;
}
$toClassId = $fromClassId + 1;
PromotionQueue::upsertQueue([
'student_id' => (int) $row->student_id,
'from_class_section_id' => $fromSection,
'to_class_id' => $toClassId,
'to_class_section_id' => null,
'school_year_from' => $currentYear,
'school_year_to' => $nextYear,
'status' => 'queued',
'updated_by' => null,
]);
}
} catch (\Throwable $e) {
Log::error('Promotion queue build failed: ' . $e->getMessage());
}
}
private function renderEmail(string $viewName, array $data, string $fallbackTitle): string
{
try {
return view($viewName, $data, ['saveData' => true]);
} catch (\Throwable $e) {
return '<p>' . e($fallbackTitle) . '</p>';
}
}
}
@@ -0,0 +1,418 @@
<?php
namespace App\Services\School;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\Log;
class EnrollmentEventService
{
public function __construct(
private EmailService $emailService,
private UserNotificationDispatchService $notifier
) {
}
public function admissionUnderReview(array $parentData, array $students): array
{
$parentName = $this->parentName($parentData);
$studentName = $this->studentNameData($students, $parentData);
$emailData = [
'parentName' => $parentName,
'studentName' => $studentName,
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'title' => 'Admission Application Under Review',
];
$this->notifyParent(
(int) ($parentData['user_id'] ?? 0),
'Admission Under Review',
'Your admission application is under review. We will notify you once it is approved.',
'registration'
);
return $this->sendEmail(
(string) ($parentData['email'] ?? ''),
'Admission Application Under Review',
$this->renderEmail('emails/status_admission_review', $emailData, 'Admission Application Under Review'),
'registration'
);
}
public function paymentPending(array $parentData, array $students): array
{
$parentName = $this->parentName($parentData);
$studentName = $this->studentNameSentence($students, $parentData['student_name'] ?? 'your student');
$emailData = [
'parentName' => $parentName,
'studentName' => $studentName,
'amountDue' => $parentData['amount'] ?? '',
'dueDate' => $parentData['due_date'] ?? '',
'portalLink' => $parentData['paymentLink'] ?? ($parentData['portalLink'] ?? url('/login')),
'title' => 'Admission Decision',
];
$this->notifyParent(
(int) ($parentData['user_id'] ?? 0),
'Payment Pending',
'Your payment is pending. Please check your email for details.',
'payment'
);
return $this->sendEmail(
(string) ($parentData['email'] ?? ''),
'Admission Decision',
$this->renderEmail('emails/status_payment_pending', $emailData, 'Admission Decision'),
'payment'
);
}
public function studentEnrolled(array $parentData, array $students): array
{
$parentName = $this->parentName($parentData);
$studentsDetails = $this->studentDetails($students);
$studentNameData = $this->studentNameData($students, $parentData);
$first = $studentsDetails[0] ?? [];
$emailData = [
'parentName' => $parentName,
'studentName' => $parentData['student_name'] ?? $studentNameData,
'schoolYear' => $parentData['school_year'] ?? date('Y'),
'studentId' => $parentData['student_id'] ?? ($first['studentId'] ?? ''),
'gradeLevel' => $parentData['grade_level'] ?? ($first['gradeLevel'] ?? ''),
'teacherName' => $parentData['teacher_name'] ?? ($first['teacherName'] ?? ''),
'startDate' => $parentData['start_date'] ?? ($first['startDate'] ?? ''),
'students' => count($studentsDetails) > 1 ? $studentsDetails : null,
'portalLink' => url('/login'),
'title' => 'Enrollment Confirmation',
];
$this->notifyParent(
(int) ($parentData['user_id'] ?? 0),
'Enrollment Confirmed',
'Enrollment confirmed. Please check your email for details.',
'registration'
);
return $this->sendEmail(
(string) ($parentData['email'] ?? ''),
'Enrollment Confirmation',
$this->renderEmail('emails/status_enrolled', $emailData, 'Enrollment Confirmation'),
'registration'
);
}
public function withdrawUnderReview(array $parentData, array $students): array
{
$parentName = $this->parentName($parentData);
$studentName = $this->studentNameData($students, $parentData);
$this->notifyParent(
(int) ($parentData['user_id'] ?? 0),
'Withdrawal Request Received',
'Your withdrawal request is under review. Please check your email for details.',
'registration'
);
$emailData = [
'parentName' => $parentName,
'studentName' => $parentData['student_name'] ?? $studentName,
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'title' => 'Withdrawal Under Review',
];
return $this->sendEmail(
(string) ($parentData['email'] ?? ''),
'Withdrawal Request Under Review',
$this->renderEmail('emails/status_withdraw_review', $emailData, 'Withdrawal Request Under Review'),
'registration'
);
}
public function refundPending(array $parentData, array $students): array
{
$parentName = $this->parentName($parentData);
$studentName = $this->studentNameData($students, $parentData);
$this->notifyParent(
(int) ($parentData['user_id'] ?? 0),
'Refund Pending',
'A refund is being processed. Please check your email for details.',
'payment'
);
$emailData = [
'parentName' => $parentName,
'studentName' => $studentName,
'refundAmount' => $parentData['amount'] ?? '',
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'title' => 'Refund Pending',
];
return $this->sendEmail(
(string) ($parentData['email'] ?? ''),
'Refund Pending',
$this->renderEmail('emails/status_refund_pending', $emailData, 'Refund Pending'),
'payment'
);
}
public function withdrawn(array $parentData, array $students): array
{
$parentName = $this->parentName($parentData);
$studentName = $this->studentNameData($students, $parentData);
$withdrawals = $this->withdrawalsList($students);
$this->notifyParent(
(int) ($parentData['user_id'] ?? 0),
'Withdrawal Confirmed',
'Your withdrawal has been completed. Please check your email for details.',
'registration'
);
$emailData = [
'parentName' => $parentName,
'studentName' => $studentName,
'withdrawalDate' => $parentData['withdrawal_date'] ?? ($parentData['date'] ?? ''),
'withdrawals' => !empty($withdrawals) ? $withdrawals : null,
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'title' => 'Withdrawal Confirmed',
];
return $this->sendEmail(
(string) ($parentData['email'] ?? ''),
'Withdrawal Confirmed',
$this->renderEmail('emails/status_withdrawn', $emailData, 'Withdrawal Confirmed'),
'registration'
);
}
public function denied(array $parentData, array $students): array
{
$parentName = $this->parentName($parentData);
$studentName = $this->studentNameData($students, $parentData);
$studentsWithReasons = $this->studentsWithReasons($students);
$this->notifyParent(
(int) ($parentData['user_id'] ?? 0),
'Admission Decision',
'We\'ve emailed you an update regarding your admission decision.',
'registration'
);
$emailData = [
'parentName' => $parentName,
'studentName' => $studentName,
'students' => $studentsWithReasons ?: null,
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'title' => 'Admission Decision',
];
return $this->sendEmail(
(string) ($parentData['email'] ?? ''),
'Admission Decision',
$this->renderEmail('emails/status_denied', $emailData, 'Admission Decision'),
'registration'
);
}
public function waitlist(array $parentData, array $students): array
{
$parentName = $this->parentName($parentData);
$studentName = $this->studentNameData($students, $parentData);
$this->notifyParent(
(int) ($parentData['user_id'] ?? 0),
'Waitlist Update',
'Your application is currently on the waitlist. Please check your email for details.',
'registration'
);
$emailData = [
'parentName' => $parentName,
'studentName' => $studentName,
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'title' => 'Waitlist Update',
];
return $this->sendEmail(
(string) ($parentData['email'] ?? ''),
'Admission Decision',
$this->renderEmail('emails/status_waitlist', $emailData, 'Admission Decision'),
'registration'
);
}
public function enrollmentStatusChanged(array $parentData, array $students): array
{
$parentName = $this->parentName($parentData);
$emailData = [
'parentName' => $parentName,
'schoolYear' => $parentData['school_year'] ?? '',
'changes' => $parentData['changes'] ?? [],
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'title' => 'Enrollment Status Update',
];
$summaryCount = count($emailData['changes']);
$this->notifyParent(
(int) ($parentData['user_id'] ?? 0),
'Enrollment Status Updated',
"Enrollment status updated for {$summaryCount} student(s). Check your email for details.",
'registration'
);
return $this->sendEmail(
(string) ($parentData['email'] ?? ''),
'Enrollment Status Update',
$this->renderEmail('emails/status_enrollment_batch', $emailData, 'Enrollment Status Update'),
'registration'
);
}
private function parentName(array $parentData): string
{
return trim((string) ($parentData['firstname'] ?? '') . ' ' . (string) ($parentData['lastname'] ?? ''));
}
private function studentNameData(array $studentdata, array $parentData)
{
$names = [];
foreach ($studentdata as $s) {
$nm = !empty($s['name'])
? trim((string) $s['name'])
: trim((string) ($s['firstname'] ?? '') . ' ' . (string) ($s['lastname'] ?? ''));
if ($nm !== '') {
$names[] = $nm;
}
}
if (empty($names)) {
return $parentData['student_name'] ?? 'your student';
}
if (count($names) === 1) {
return $names[0];
}
return $names;
}
private function studentNameSentence(array $studentdata, string $fallback): string
{
$names = [];
foreach ($studentdata as $s) {
$nm = !empty($s['name'])
? trim((string) $s['name'])
: trim((string) ($s['firstname'] ?? '') . ' ' . (string) ($s['lastname'] ?? ''));
if ($nm !== '') {
$names[] = $nm;
}
}
if (count($names) === 0) {
return $fallback;
}
if (count($names) === 1) {
return $names[0];
}
if (count($names) === 2) {
return $names[0] . ' and ' . $names[1];
}
$last = array_pop($names);
return implode(', ', $names) . ', and ' . $last;
}
private function studentDetails(array $studentdata): array
{
$details = [];
foreach ($studentdata as $s) {
$nm = !empty($s['name'])
? trim((string) $s['name'])
: trim((string) ($s['firstname'] ?? '') . ' ' . (string) ($s['lastname'] ?? ''));
if ($nm === '') {
continue;
}
$details[] = [
'name' => $nm,
'studentId' => $s['student_id'] ?? $s['id'] ?? '',
'gradeLevel' => $s['grade_level'] ?? $s['grade'] ?? '',
'teacherName' => $s['teacher_name'] ?? $s['teacher'] ?? '',
'startDate' => $s['start_date'] ?? '',
];
}
return $details;
}
private function withdrawalsList(array $studentdata): array
{
$withdrawals = [];
foreach ($studentdata as $s) {
$nm = !empty($s['name'])
? trim((string) $s['name'])
: trim((string) ($s['firstname'] ?? '') . ' ' . (string) ($s['lastname'] ?? ''));
if ($nm === '') {
continue;
}
$wd = $s['withdrawal_date'] ?? $s['date'] ?? null;
if ($wd) {
$withdrawals[] = [
'name' => $nm,
'withdrawalDate' => $wd,
];
}
}
return $withdrawals;
}
private function studentsWithReasons(array $studentdata): array
{
$rows = [];
foreach ($studentdata as $s) {
$nm = !empty($s['name'])
? trim((string) $s['name'])
: trim((string) ($s['firstname'] ?? '') . ' ' . (string) ($s['lastname'] ?? ''));
if ($nm === '') {
continue;
}
$rows[] = [
'name' => $nm,
'reason' => $s['reason'] ?? null,
];
}
return $rows;
}
private function notifyParent(int $userId, string $title, string $message, string $topic): void
{
if ($userId <= 0) {
return;
}
$this->notifier->notifyUser($userId, $title, $message, ['in_app'], $topic, 'parent');
}
private function sendEmail(string $to, string $subject, string $html, string $fromKey): array
{
if ($to === '') {
Log::warning('Enrollment event: missing recipient email', ['subject' => $subject]);
return ['ok' => false, 'message' => 'Missing recipient email.'];
}
$ok = $this->emailService->send($to, $subject, $html, $fromKey);
if (!$ok) {
Log::error('Enrollment event email failed', ['to' => $to, 'subject' => $subject]);
}
return ['ok' => $ok];
}
private function renderEmail(string $viewName, array $data, string $fallbackTitle): string
{
try {
return view($viewName, $data, ['saveData' => true]);
} catch (\Throwable $e) {
return '<p>' . e($fallbackTitle) . '</p>';
}
}
}
+223
View File
@@ -0,0 +1,223 @@
<?php
namespace App\Services\School;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\Log;
class PaymentEventService
{
public function __construct(
private EmailService $emailService,
private UserNotificationDispatchService $notifier
) {
}
public function paymentReceived(array $data, array $studentdata = []): array
{
$parentName = trim((string) ($data['firstname'] ?? '') . ' ' . (string) ($data['lastname'] ?? ''));
$fmtMoney = static fn ($v) => '$' . number_format((float) $v, 2);
$fmtDateTz = static function (?string $s): string {
if (!$s) {
return '';
}
$dt = new \DateTime($s, new \DateTimeZone('UTC'));
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$dt->setTimezone(new \DateTimeZone($tzName ?: 'UTC'));
return $dt->format('m-d-Y h:i A');
};
$txid = $data['transaction_id']
?? $data['transactionId']
?? $data['payment_id']
?? $data['id']
?? '';
$methodRaw = $data['payment_method'] ?? ($data['method'] ?? '');
$method = $methodRaw ? ucfirst((string) $methodRaw) : '';
$amountPaid = $fmtMoney($data['amount'] ?? 0);
$preBalance = $fmtMoney($data['pre_balance'] ?? 0);
$postBalance = $fmtMoney($data['post_balance'] ?? 0);
$invoiceTotal = $fmtMoney($data['invoice_total'] ?? 0);
$invoiceDiscountRaw = isset($data['invoice_discount']) ? (float) $data['invoice_discount'] : 0.0;
$parentYearDiscountTotalRaw = isset($data['parent_year_discount_total']) ? (float) $data['parent_year_discount_total'] : 0.0;
$emailData = [
'title' => 'Payment Receipt',
'parentName' => $parentName,
'schoolYear' => $data['school_year'] ?? '',
'semester' => $data['semester'] ?? '',
'invoiceId' => $data['invoice_id'] ?? null,
'invoiceNumber' => $data['invoice_number'] ?? null,
'transactionId' => $txid,
'paymentDate' => $fmtDateTz($data['payment_date'] ?? null),
'method' => $method,
'paymentMethod' => $method,
'amount' => $amountPaid,
'invoiceTotal' => $invoiceTotal,
'preBalance' => $preBalance,
'postBalance' => $postBalance,
'checkNumber' => $data['check_number'] ?? null,
'installmentSeq' => (int) ($data['installment_seq'] ?? 1),
'invoiceDiscount' => $invoiceDiscountRaw > 0 ? $fmtMoney($invoiceDiscountRaw) : null,
'parentYearDiscountTotal' => $parentYearDiscountTotalRaw > 0 ? $fmtMoney($parentYearDiscountTotalRaw) : null,
'portalLink' => url('/login'),
'students' => $studentdata,
];
$inAppTitle = 'Payment Received';
$inAppMsg = sprintf(
'We received %s for Invoice #%s (Installment #%d). New balance: %s. Check your email for details.',
$emailData['amount'],
(string) ($emailData['invoiceId'] ?? $emailData['invoiceNumber'] ?? ''),
(int) $emailData['installmentSeq'],
$emailData['postBalance']
);
$this->notifyUser((int) ($data['user_id'] ?? 0), $inAppTitle, $inAppMsg, 'payments');
$emailBody = $this->renderEmail('emails/payment_receipt', $emailData, 'Payment Receipt');
$subject = sprintf(
'Payment Receipt - Invoice #%s (%s) %s',
(string) ($emailData['invoiceId'] ?? $emailData['invoiceNumber'] ?? ''),
$emailData['amount'],
$txid ? "[TX: {$txid}]" : ''
);
return $this->sendEmail((string) ($data['email'] ?? ''), $subject, $emailBody, 'payments');
}
public function extraCharge(array $data, array $studentdata = []): array
{
$fmtMoney = static fn ($v) => '$' . number_format((float) $v, 2);
$fmtFromUtc = static function (?string $s, string $fmt = 'm-d-Y h:i A'): string {
if (!$s) {
return '';
}
try {
$dt = new \DateTime($s, new \DateTimeZone('UTC'));
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$dt->setTimezone(new \DateTimeZone($tzName ?: 'UTC'));
return $dt->format($fmt);
} catch (\Throwable $e) {
Log::error('extraCharge date error: ' . $e->getMessage());
return '';
}
};
$fmtDueLocal = static function (?string $s): string {
if (!$s) {
return '';
}
try {
if (strlen($s) <= 10) {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$dt = \DateTime::createFromFormat('Y-m-d', $s, new \DateTimeZone($tzName ?: 'UTC'));
return $dt ? $dt->format('m-d-Y') : '';
}
if (preg_match('/(Z|[+\-]\d{2}:\d{2})$/', $s)) {
$dt = new \DateTime($s);
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$dt->setTimezone(new \DateTimeZone($tzName ?: 'UTC'));
return $dt->format('m-d-Y h:i A');
}
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$dt = new \DateTime($s, new \DateTimeZone($tzName ?: 'UTC'));
return $dt->format('m-d-Y h:i A');
} catch (\Throwable $e) {
Log::error('extraCharge due date error: ' . $e->getMessage());
return '';
}
};
$parentName = trim((string) ($data['firstname'] ?? '') . ' ' . (string) ($data['lastname'] ?? ''));
$amountSigned = (float) ($data['amount_signed'] ?? 0);
$amountAbs = (float) ($data['amount_abs'] ?? abs($amountSigned));
$preBalance = $fmtMoney($data['pre_balance'] ?? 0);
$postBalance = $fmtMoney($data['post_balance'] ?? 0);
$invoiceTotal = $fmtMoney($data['invoice_total'] ?? 0);
$createdAt = $fmtFromUtc($data['created_at'] ?? null);
$dueDate = $fmtDueLocal($data['due_date'] ?? null);
$typeLabel = (($data['charge_type'] ?? 'add') === 'add') ? 'Added charge' : 'Deducted amount';
$amountFmt = $fmtMoney($amountSigned);
$emailData = [
'title' => 'Account Charge Update',
'parentName' => $parentName,
'schoolYear' => $data['school_year'] ?? '',
'semester' => $data['semester'] ?? '',
'invoiceId' => $data['invoice_id'] ?? null,
'invoiceNumber' => $data['invoice_number'] ?? null,
'typeLabel' => $typeLabel,
'chargeTitle' => $data['charge_title'] ?? '',
'chargeDesc' => $data['charge_desc'] ?? '',
'chargeType' => $data['charge_type'] ?? 'add',
'amountSigned' => $amountFmt,
'amountAbs' => $fmtMoney($amountAbs),
'invoiceTotal' => $invoiceTotal,
'preBalance' => $preBalance,
'postBalance' => $postBalance,
'createdAt' => $createdAt,
'dueDate' => $dueDate !== '' ? $dueDate : null,
'portalLink' => $data['portal_link'] ?? url('/login'),
'invoiceLink' => $data['invoice_link'] ?? null,
'students' => $studentdata,
];
$inAppTitle = 'Account Updated';
$inAppMsg = sprintf(
'%s: %s (%s). New balance: %s.',
$typeLabel,
$emailData['chargeTitle'],
$emailData['amountSigned'],
$emailData['postBalance']
);
$this->notifyUser((int) ($data['user_id'] ?? 0), $inAppTitle, $inAppMsg, 'billing');
$subject = sprintf(
'Charge Update - Invoice #%s (%s)',
(string) ($emailData['invoiceId'] ?? $emailData['invoiceNumber'] ?? ''),
$emailData['amountSigned']
);
$body = $this->renderEmail('emails/extra_charge_notice', $emailData, 'Account Charge Update');
return $this->sendEmail((string) ($data['email'] ?? ''), $subject, $body, 'billing');
}
private function notifyUser(int $userId, string $title, string $message, string $topic): void
{
if ($userId <= 0) {
return;
}
$this->notifier->notifyUser($userId, $title, $message, ['in_app'], $topic, 'parent');
}
private function sendEmail(string $to, string $subject, string $html, string $fromKey): array
{
if ($to === '') {
Log::warning('Payment event missing recipient email', ['subject' => $subject]);
return ['ok' => false, 'message' => 'Missing recipient email.'];
}
$ok = $this->emailService->send($to, $subject, $html, $fromKey);
if (!$ok) {
Log::error('Payment event email failed', ['to' => $to, 'subject' => $subject]);
}
return ['ok' => $ok];
}
private function renderEmail(string $viewName, array $data, string $fallbackTitle): string
{
try {
return view($viewName, $data, ['saveData' => true]);
} catch (\Throwable $e) {
return '<p>' . e($fallbackTitle) . '</p>';
}
}
}
@@ -0,0 +1,110 @@
<?php
namespace App\Services\Staff;
class StaffTimeOffLinkService
{
private const TOKEN_CONTEXT = 'timeoff_notify';
private const DEFAULT_TTL = 1209600;
private string $secret;
private int $ttl;
public function __construct(?string $secret = null, ?int $ttlSeconds = null)
{
$this->secret = $secret ?: (string) env('JWT_SECRET', 'change-me-in-env');
$this->ttl = ($ttlSeconds !== null && $ttlSeconds > 0) ? $ttlSeconds : self::DEFAULT_TTL;
}
public function createToken(array $payload): string
{
$now = time();
$data = array_merge($payload, [
'iat' => $now,
'exp' => $now + $this->ttl,
'ctx' => self::TOKEN_CONTEXT,
]);
return $this->jwtEncode($data, $this->secret);
}
public function parseToken(?string $token): ?array
{
if (!is_string($token) || $token === '') {
return null;
}
$payload = $this->jwtDecode($token, $this->secret);
if (!is_array($payload)) {
return null;
}
if (($payload['ctx'] ?? null) !== self::TOKEN_CONTEXT) {
return null;
}
$exp = (int) ($payload['exp'] ?? 0);
if ($exp > 0 && $exp < time()) {
return null;
}
return $payload;
}
private function jwtEncode(array $payload, string $secret): string
{
$header = ['typ' => 'JWT', 'alg' => 'HS256'];
$segments = [];
$segments[] = $this->base64UrlEncode(json_encode($header, JSON_UNESCAPED_SLASHES));
$segments[] = $this->base64UrlEncode(json_encode($payload, JSON_UNESCAPED_SLASHES));
$signingInput = implode('.', $segments);
$signature = hash_hmac('sha256', $signingInput, $secret, true);
$segments[] = $this->base64UrlEncode($signature);
return implode('.', $segments);
}
private function jwtDecode(string $token, string $secret): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$header64, $payload64, $signature64] = $parts;
$header = json_decode($this->base64UrlDecode($header64), true);
$payload = json_decode($this->base64UrlDecode($payload64), true);
$signature = $this->base64UrlDecode($signature64);
if (!is_array($header) || !is_array($payload)) {
return null;
}
$signingInput = $header64 . '.' . $payload64;
if (($header['alg'] ?? '') !== 'HS256') {
return null;
}
$expected = hash_hmac('sha256', $signingInput, $secret, true);
if (!hash_equals($expected, $signature)) {
return null;
}
return $payload;
}
private function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private function base64UrlDecode(string $data): string
{
$padding = strlen($data) % 4;
if ($padding > 0) {
$data .= str_repeat('=', 4 - $padding);
}
return base64_decode(strtr($data, '-_', '+/')) ?: '';
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Services\System;
use App\Models\Configuration;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Support\Facades\Log;
class ConfigUpdateService
{
private array $tasks = [
'update_date_age_reference' => 'taskUpdateDateAgeReference',
'enable_attendance_on' => 'taskEnableAttendanceOn',
'enable_attendance_off' => 'taskEnableAttendanceOff',
'set_semester_spring' => 'taskSetSemesterSpring',
'set_semester_fall' => 'taskSetSemesterFall',
];
public function availableTasks(): array
{
return array_keys($this->tasks);
}
public function runTask(string $task, bool $dry, bool $force, DateTimeZone $tz): array
{
if (!isset($this->tasks[$task])) {
return ['ok' => false, 'message' => 'Unknown task.'];
}
$lockFile = storage_path('app/locks/config_update_' . $task . '.lock');
if (!is_dir(dirname($lockFile))) {
@mkdir(dirname($lockFile), 0775, true);
}
$fp = @fopen($lockFile, 'c+');
if (!$fp) {
return ['ok' => false, 'message' => 'Unable to open lock file.'];
}
if (!$force && !flock($fp, LOCK_EX | LOCK_NB)) {
fclose($fp);
return ['ok' => false, 'message' => 'Task is already running.'];
}
try {
$method = $this->tasks[$task];
$ok = (bool) $this->{$method}($dry, $force, $tz);
return ['ok' => $ok, 'task' => $task];
} catch (\Throwable $e) {
Log::error('ConfigUpdate task failed: ' . $e->getMessage());
return ['ok' => false, 'message' => $e->getMessage()];
} finally {
try {
flock($fp, LOCK_UN);
fclose($fp);
@unlink($lockFile);
} catch (\Throwable $e) {
}
}
}
private function taskEnableAttendanceOn(bool $dry, bool $force, DateTimeZone $tz): bool
{
return $this->setConfig('enable_attendance', '1', $dry);
}
private function taskEnableAttendanceOff(bool $dry, bool $force, DateTimeZone $tz): bool
{
return $this->setConfig('enable_attendance', '0', $dry);
}
private function taskUpdateDateAgeReference(bool $dry, bool $force, DateTimeZone $tz): bool
{
$now = new DateTimeImmutable('now', $tz);
$isJune1 = $now->format('n-j') === '6-1';
if (!$isJune1 && !$force) {
return true;
}
$value = $now->format('Y') . '-12-31';
return $dry ? true : Configuration::setConfigValueByKey('date_age_reference', $value);
}
private function taskSetSemesterSpring(bool $dry, bool $force, DateTimeZone $tz): bool
{
$now = new DateTimeImmutable('now', $tz);
$isFeb1 = $now->format('n-j') === '2-1';
if (!$isFeb1 && !$force) {
return true;
}
return $dry ? true : Configuration::setConfigValueByKey('semester', 'Spring');
}
private function taskSetSemesterFall(bool $dry, bool $force, DateTimeZone $tz): bool
{
$now = new DateTimeImmutable('now', $tz);
$isJun1 = $now->format('n-j') === '6-1';
if (!$isJun1 && !$force) {
return true;
}
return $dry ? true : Configuration::setConfigValueByKey('semester', 'Fall');
}
private function setConfig(string $key, string $value, bool $dry): bool
{
if ($dry) {
return true;
}
return Configuration::setConfigValueByKey($key, $value);
}
}
@@ -2,7 +2,7 @@
namespace App\Services\Teachers;
use App\Libraries\StaffTimeOffLinkService;
use App\Services\Staff\StaffTimeOffLinkService;
use App\Models\StaffAttendance;
use App\Models\TeacherClass;
use App\Models\User;
@@ -0,0 +1,61 @@
<?php
namespace App\Services\Users;
use App\Models\ParentModel;
use Illuminate\Support\Facades\DB;
class InactiveUserCleanupService
{
public function cleanup(int $minutes = 15): array
{
$cutoff = now()->subMinutes($minutes)->toDateTimeString();
$users = DB::table('users')
->select('id')
->where('status', 'Inactive')
->where('created_at', '<', $cutoff)
->get()
->map(fn ($row) => (array) $row)
->all();
if (empty($users)) {
$orphans = $this->purgeOrphanedUserRoles();
return [
'deleted_users' => 0,
'deleted_parents' => 0,
'deleted_roles' => $orphans,
];
}
$userIds = array_map(static fn ($u) => (int) ($u['id'] ?? 0), $users);
$userIds = array_values(array_filter($userIds));
$deletedParents = 0;
DB::transaction(function () use ($userIds, &$deletedParents): void {
$deletedParents = ParentModel::query()
->whereIn('firstparent_id', $userIds)
->delete();
DB::table('user_roles')->whereIn('user_id', $userIds)->delete();
DB::table('users')->whereIn('id', $userIds)->delete();
});
$orphans = $this->purgeOrphanedUserRoles();
return [
'deleted_users' => count($userIds),
'deleted_parents' => (int) $deletedParents,
'deleted_roles' => $orphans,
];
}
private function purgeOrphanedUserRoles(): int
{
return DB::table('user_roles')
->whereNotIn('user_id', function ($q) {
$q->select('id')->from('users');
})
->delete();
}
}
@@ -0,0 +1,260 @@
<?php
namespace App\Services\Whatsapp;
use App\Models\WhatsappInviteLog;
use App\Services\EmailService;
use Illuminate\Support\Facades\Log;
class WhatsappInviteEmailService
{
public function __construct(private EmailService $emailService)
{
}
public function send(array $payload): array
{
$to = array_values(array_unique(array_filter((array) ($payload['to_emails'] ?? []))));
$cc = array_values(array_unique(array_filter((array) ($payload['cc_emails'] ?? []))));
$cc = array_values(array_diff($cc, $to));
if (empty($to) && !empty($cc)) {
$to = $cc;
$cc = [];
}
if (empty($to)) {
Log::warning('WhatsappInvite: no recipients after normalization.');
return ['ok' => false, 'message' => 'No recipients provided.'];
}
$sections = (array) ($payload['sections'] ?? []);
$links = array_values(array_unique(array_filter((array) ($payload['links'] ?? []))));
$parent = $payload['parent'] ?? null;
$schoolYear = $payload['schoolYear'] ?? null;
$semester = $payload['semester'] ?? null;
$items = $this->buildItems($sections, $links);
if (empty($items)) {
Log::warning('WhatsappInvite: no invite items built.');
return ['ok' => false, 'message' => 'No invite links found.'];
}
$subject = count($items) > 1
? 'Your WhatsApp Group Links (Multiple Classes)'
: 'Your WhatsApp Group Link';
$viewData = [
'parent' => $parent,
'items' => $items,
'sections' => $sections,
'links' => $links,
'schoolYear' => $schoolYear,
'semester' => $semester,
'title' => $subject,
'teacherNames' => $payload['teachers'] ?? [],
];
$bodyHtml = '';
try {
$bodyHtml = view('emails/whatsapp_invite', $viewData, ['saveData' => true]);
} catch (\Throwable $e) {
$lines = ['Assalamu Alaikum,', '', 'Here are your WhatsApp link(s):'];
foreach ($items as $it) {
$lines[] = ' - ' . $it['class_section_name'] . ': ' . $it['invite_link'];
}
$lines[] = '';
$lines[] = 'Jazakumu-llahu khayran.';
$bodyHtml = nl2br(implode("\n", $lines));
}
$ok = $this->emailService->send($to, $subject, $bodyHtml, 'notifications', $cc);
$singleSectionId = count($items) === 1
? ((int) ($items[0]['class_section_id'] ?? 0) ?: null)
: null;
$parentId = (int) ($parent['id'] ?? 0);
if ($ok) {
WhatsappInviteLog::logSuccess($parentId, implode(', ', $to), $singleSectionId, null);
Log::info('Whatsapp invite sent.', ['to' => $to, 'cc' => $cc]);
} else {
WhatsappInviteLog::logFailure($parentId, implode(', ', $to), 'Email send failed', $singleSectionId, null);
Log::error('Whatsapp invite failed.', ['to' => $to, 'cc' => $cc]);
}
return [
'ok' => $ok,
'recipients' => count($to),
];
}
private function buildItems(array $sections, array $links): array
{
$seen = [];
$items = [];
foreach ($sections as $section) {
$sid = isset($section['class_section_id']) ? (int) $section['class_section_id'] : 0;
$name = trim((string) ($section['class_section_name'] ?? ($sid ? ('Section ' . $sid) : 'Class')));
$url = trim((string) ($section['invite_link'] ?? ''));
if ($url === '') {
continue;
}
$key = $sid . '|' . $url;
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$items[] = [
'class_section_id' => $sid,
'class_section_name' => $name,
'invite_link' => $url,
'qr_src' => $this->generateQrImage($url),
];
}
if (empty($items) && !empty($links)) {
foreach ($links as $i => $url) {
$url = trim((string) $url);
if ($url === '') {
continue;
}
$key = '0|' . $url;
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$items[] = [
'class_section_id' => 0,
'class_section_name' => 'Class Link ' . ($i + 1),
'invite_link' => $url,
'qr_src' => $this->generateQrImage($url),
];
}
}
return $items;
}
private function generateQrImage(string $inviteLink): string
{
if ($inviteLink === '') {
return $this->qrFallback($inviteLink);
}
$dir = storage_path('app/qrcodes');
if (!is_dir($dir)) {
if (!@mkdir($dir, 0775, true) && !is_dir($dir)) {
Log::error('QR: cannot create dir', ['dir' => $dir]);
return $this->qrFallback($inviteLink);
}
}
if (!is_writable($dir)) {
Log::error('QR: dir not writable', ['dir' => $dir]);
return $this->qrFallback($inviteLink);
}
$filename = 'wa_' . md5($inviteLink) . '.png';
$path = $dir . DIRECTORY_SEPARATOR . $filename;
if (!is_file($path)) {
$v5BuilderClass = '\\Endroid\\QrCode\\Builder\\Builder';
$v5WriterClass = '\\Endroid\\QrCode\\Writer\\PngWriter';
$v5Encoding = '\\Endroid\\QrCode\\Encoding\\Encoding';
$writerClass = '\\Endroid\\QrCode\\Writer\\PngWriter';
$qrClass = '\\Endroid\\QrCode\\QrCode';
$chlQRCode = '\\chillerlan\\QRCode\\QRCode';
$chlQROptions = '\\chillerlan\\QRCode\\QROptions';
$chlQRMatrix = '\\chillerlan\\QRCode\\Data\\QRMatrix';
$lastErr = null;
if (class_exists($v5BuilderClass) && class_exists($v5WriterClass)) {
try {
$builder = $v5BuilderClass::create()
->writer(new $v5WriterClass())
->data($inviteLink);
if (class_exists($v5Encoding)) {
$builder->encoding(new $v5Encoding('UTF-8'));
}
$result = $builder->size(500)->margin(2)->build();
$result->saveToFile($path);
} catch (\Throwable $e) {
$lastErr = 'Endroid v5: ' . $e->getMessage();
}
}
if (!is_file($path) && class_exists($writerClass)) {
try {
if (class_exists($qrClass) && method_exists($qrClass, 'create')) {
$qr = $qrClass::create($inviteLink);
} else {
$qr = class_exists($qrClass) ? new $qrClass($inviteLink) : null;
}
if ($qr) {
if (method_exists($qr, 'setSize')) {
$qr->setSize(500);
}
if (method_exists($qr, 'setMargin')) {
$qr->setMargin(2);
}
$writer = new $writerClass();
$result = $writer->write($qr);
if (method_exists($result, 'saveToFile')) {
$result->saveToFile($path);
} else {
$raw = method_exists($result, 'getString') ? $result->getString() : (string) $result;
file_put_contents($path, $raw);
}
}
} catch (\Throwable $e) {
$lastErr = 'Endroid v3/v4: ' . $e->getMessage();
}
}
if (!is_file($path) && class_exists($chlQRCode) && class_exists($chlQROptions)) {
try {
$optsArr = [
'version' => 5,
'outputType' => constant($chlQRCode . '::OUTPUT_IMAGE_PNG'),
'scale' => 6,
'imageBase64' => false,
'quietzoneSize' => 2,
];
if (class_exists($chlQRMatrix) && defined($chlQRMatrix . '::ECC_H')) {
$optsArr['eccLevel'] = constant($chlQRMatrix . '::ECC_H');
}
$opts = new $chlQROptions($optsArr);
$qrcode = new $chlQRCode($opts);
$png = $qrcode->render($inviteLink);
file_put_contents($path, $png);
} catch (\Throwable $e) {
$lastErr = 'chillerlan: ' . $e->getMessage();
}
}
if (!is_file($path) && $lastErr) {
Log::error('QR generation failed', ['error' => $lastErr]);
}
}
if (is_file($path)) {
$data = @file_get_contents($path);
if ($data !== false) {
return 'data:image/png;base64,' . base64_encode($data);
}
}
return $this->qrFallback($inviteLink);
}
private function qrFallback(string $inviteLink): string
{
$link = rawurlencode($inviteLink);
return 'https://api.qrserver.com/v1/create-qr-code/?size=260x260&qzone=2&data=' . $link;
}
}