add notifications logic and add support of both JWT and Sanctum
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, '-_', '+/')) ?: '';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user