add notifications logic and add support of both JWT and Sanctum

This commit is contained in:
root
2026-03-11 01:20:31 -04:00
parent f6be51576c
commit 182036cc41
141 changed files with 8685 additions and 648 deletions
@@ -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.',
];
}
}
}