add school calendar logic

This commit is contained in:
root
2026-03-10 16:55:50 -04:00
parent 311bb93977
commit abebe0d9c0
25 changed files with 1358 additions and 750 deletions
@@ -0,0 +1,32 @@
<?php
namespace App\Services\Settings\SchoolCalendar;
use App\Models\Configuration;
class SchoolCalendarContextService
{
public function defaultSchoolYear(): string
{
return trim((string) (Configuration::getConfig('school_year') ?? ''));
}
public function defaultSemester(): string
{
return trim((string) (Configuration::getConfig('semester') ?? ''));
}
public function eventTypes(): array
{
return [
'1st Semester Scores',
'2nd Semester Scores',
'Break',
'Event',
'Final',
'Final Exam Draft',
'Midterm',
'Midterm Exam Draft',
];
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services\Settings\SchoolCalendar;
use App\Models\CalendarEvent;
class SchoolCalendarFormatterService
{
public function formatEvent(CalendarEvent|array $event, ?string $audience = null): array
{
$event = $event instanceof CalendarEvent ? $event->toArray() : $event;
$audience = $audience !== null ? strtolower(trim($audience)) : null;
$p = (int) ($event['notify_parent'] ?? 0);
$t = (int) ($event['notify_teacher'] ?? 0);
$a = (int) ($event['notify_admin'] ?? 0);
$ns = (int) ($event['no_school'] ?? 0);
$backgroundColor = null;
if (in_array($audience, ['teacher', 'parent'], true) && $ns === 1) {
$backgroundColor = '#ffc107';
}
if (in_array($audience, ['teacher', 'admin'], true) && $backgroundColor === null) {
$isStaffOnly = ($p === 0 && ($t === 1 || $a === 1) && $ns === 0);
if ($isStaffOnly) {
$backgroundColor = '#28a745';
}
}
$title = (string) ($event['title'] ?? 'Untitled');
if ($ns === 1) {
$title = preg_replace('/\bno\s*[- ]\s*school\b/i', 'No School', $title);
}
return [
'id' => (int) ($event['id'] ?? 0),
'title' => $title,
'start' => (string) ($event['date'] ?? ''),
'description' => (string) ($event['description'] ?? ''),
'extendedProps' => [
'semester' => (string) ($event['semester'] ?? ''),
'school_year' => (string) ($event['school_year'] ?? ''),
'event_type' => $event['event_type'] ?? null,
'notify_parent' => $p,
'notify_teacher' => $t,
'notify_admin' => $a,
'no_school' => $ns,
'backgroundColor' => $backgroundColor,
],
];
}
public function formatMeetingEvent(array $row, ?string $audience = null): array
{
$audience = $audience !== null ? strtolower(trim($audience)) : null;
$parentName = (string) ($row['parent_name'] ?? 'Parent');
$studentName = (string) ($row['student_name'] ?? 'Student');
$time = trim((string) ($row['time'] ?? ''));
$timeLabel = $time !== '' ? (' ' . $time) : '';
$title = $audience === 'admin' ? 'Admin Meeting' : 'Parent Meeting';
$descParts = [];
$descParts[] = 'Student: ' . $studentName;
$descParts[] = 'Parent: ' . $parentName;
if (!empty($row['class_section_name'])) {
$descParts[] = 'Section: ' . $row['class_section_name'];
}
$descParts[] = 'Date/Time: ' . ($row['date'] ?? '') . $timeLabel;
if (!empty($row['notes'])) {
$descParts[] = 'Notes: ' . $row['notes'];
}
$start = (string) ($row['date'] ?? '');
if ($start !== '' && $time !== '') {
$start = $start . 'T' . $time;
}
return [
'id' => 'pm-' . (int) ($row['id'] ?? 0),
'title' => $title,
'start' => $start,
'description' => implode("\n", $descParts),
'extendedProps' => [
'semester' => (string) ($row['semester'] ?? ''),
'school_year' => (string) ($row['school_year'] ?? ''),
'notify_parent' => 1,
'notify_admin' => 1,
'notify_teacher' => 0,
'no_school' => 0,
'backgroundColor' => '#6f42c1',
],
];
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Services\Settings\SchoolCalendar;
use App\Models\ParentMeetingSchedule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class SchoolCalendarMeetingService
{
public function listMeetings(string $schoolYear, ?string $audience, ?int $parentUserId = null): array
{
$audience = $audience !== null ? strtolower(trim($audience)) : null;
$builder = ParentMeetingSchedule::query()
->where('school_year', $schoolYear)
->where(function (Builder $q) {
$q->where('status', 'scheduled')
->orWhere('status', 'Scheduled')
->orWhere('status', '')
->orWhereNull('status');
});
if ($audience === 'parent' && $parentUserId) {
$studentIds = $this->getStudentIdsForParent($parentUserId);
if (!empty($studentIds)) {
$builder->where(function (Builder $q) use ($parentUserId, $studentIds) {
$q->where('parent_user_id', $parentUserId)
->orWhereIn('student_id', $studentIds);
});
} else {
$builder->where('parent_user_id', $parentUserId);
}
}
$rows = $builder->orderBy('date', 'ASC')->get()->toArray();
if (!empty($rows)) {
return $rows;
}
$fallback = ParentMeetingSchedule::query()
->where(function (Builder $q) {
$q->where('status', 'scheduled')
->orWhere('status', 'Scheduled')
->orWhere('status', '')
->orWhereNull('status');
});
if ($audience === 'parent' && $parentUserId) {
$studentIds = $this->getStudentIdsForParent($parentUserId);
if (!empty($studentIds)) {
$fallback->where(function (Builder $q) use ($parentUserId, $studentIds) {
$q->where('parent_user_id', $parentUserId)
->orWhereIn('student_id', $studentIds);
});
} else {
$fallback->where('parent_user_id', $parentUserId);
}
}
return $fallback->orderBy('date', 'ASC')->get()->toArray();
}
private function getStudentIdsForParent(int $parentUserId): array
{
try {
$rows = DB::table('family_guardians as fg')
->select('fs.student_id')
->join('family_students as fs', 'fs.family_id', '=', 'fg.family_id')
->where('fg.user_id', $parentUserId)
->distinct()
->get()
->map(fn ($r) => (int) $r->student_id)
->all();
$legacyRows = DB::table('students')
->select('id as student_id')
->where('parent_id', $parentUserId)
->get()
->map(fn ($r) => (int) $r->student_id)
->all();
return array_values(array_filter(array_unique(array_merge($rows, $legacyRows))));
} catch (\Throwable $e) {
return [];
}
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Services\Settings\SchoolCalendar;
use App\Models\CalendarEvent;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class SchoolCalendarMutationService
{
public function __construct(
private SchoolCalendarContextService $context,
private SchoolCalendarNotificationService $notificationService
) {
}
public function create(array $payload, array $targets = []): array
{
$data = $this->normalizePayload($payload);
try {
$event = DB::transaction(function () use ($data) {
return CalendarEvent::query()->create($data);
});
} catch (\Throwable $e) {
Log::error('SchoolCalendar create failed: ' . $e->getMessage());
throw $e;
}
$emailStatus = $this->notificationService->notify($event->toArray(), $targets);
return [
'event' => $event,
'email_status' => $emailStatus,
];
}
public function update(CalendarEvent $event, array $payload, array $targets = []): array
{
$data = $this->normalizePayload($payload);
try {
DB::transaction(function () use ($event, $data) {
$event->fill($data);
$event->save();
});
} catch (\Throwable $e) {
Log::error('SchoolCalendar update failed: ' . $e->getMessage());
throw $e;
}
$event->refresh();
$emailStatus = $this->notificationService->notify($event->toArray(), $targets);
return [
'event' => $event,
'email_status' => $emailStatus,
];
}
public function delete(CalendarEvent $event): void
{
try {
DB::transaction(function () use ($event) {
$event->delete();
});
} catch (\Throwable $e) {
Log::error('SchoolCalendar delete failed: ' . $e->getMessage());
throw $e;
}
}
private function normalizePayload(array $payload): array
{
$data = [
'title' => (string) ($payload['title'] ?? ''),
'description' => $payload['description'] ?? null,
'event_type' => $payload['event_type'] ?? null,
'date' => $payload['date'] ?? $payload['start'] ?? null,
'notify_parent' => !empty($payload['notify_parent']) ? 1 : 0,
'notify_teacher' => !empty($payload['notify_teacher']) ? 1 : 0,
'notify_admin' => !empty($payload['notify_admin']) ? 1 : 0,
'no_school' => !empty($payload['no_school']) ? 1 : 0,
'school_year' => $payload['school_year'] ?? $this->context->defaultSchoolYear(),
'semester' => $payload['semester'] ?? $this->context->defaultSemester(),
];
if (!CalendarEvent::supportsEventType()) {
unset($data['event_type']);
}
return $data;
}
}
@@ -0,0 +1,115 @@
<?php
namespace App\Services\Settings\SchoolCalendar;
use App\Models\User;
use App\Services\EmailService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\URL;
class SchoolCalendarNotificationService
{
public function __construct(private EmailService $emailService)
{
}
public function notify(array $event, array $targets): array
{
if (empty($event) || empty(array_filter($targets))) {
return [];
}
$subject = $this->buildSubject($event);
$body = $this->buildBody($event);
$results = [];
foreach ($targets as $group => $enabled) {
if (!$enabled) {
continue;
}
$emails = $this->collectEmailsForGroup($group);
if (empty($emails)) {
Log::warning("SchoolCalendar: no recipient emails for {$group}.");
$results[$group] = null;
continue;
}
$to = $emails[0] ?? '';
$ok = $this->emailService->send($to, $subject, $body, 'general', [], $emails);
if (!$ok) {
Log::warning("SchoolCalendar: failed to send calendar notification to {$group}.");
}
$results[$group] = $ok;
}
return $results;
}
private function buildSubject(array $event): string
{
$title = trim((string) ($event['title'] ?? 'School Calendar Update'));
$typeSegment = !empty($event['event_type']) ? ' (' . $event['event_type'] . ')' : '';
return 'School Calendar: ' . $title . $typeSegment;
}
private function buildBody(array $event): string
{
$calendarUrl = URL::to('/administrator/calendar_view');
$title = trim((string) ($event['title'] ?? 'School Calendar Update'));
$date = (string) ($event['date'] ?? '');
$description = trim((string) ($event['description'] ?? ''));
$body = '<h3>' . e($title) . '</h3>';
$body .= '<p><strong>Date:</strong> ' . e($date) . '</p>';
if ($description !== '') {
$body .= '<p>' . nl2br(e($description)) . '</p>';
}
$body .= '<p><a href="' . e($calendarUrl) . '">View calendar</a></p>';
return $body;
}
private function collectEmailsForGroup(string $group): array
{
return match ($group) {
'parents' => $this->normalizeEmails(User::getParents()),
'teachers' => $this->normalizeEmails(User::getAllTeachers()),
'admins' => $this->normalizeEmails($this->fetchAdminRows()),
default => [],
};
}
private function normalizeEmails(array $rows): array
{
$emails = [];
foreach ($rows as $row) {
$email = trim((string) ($row['email'] ?? ''));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
$emails[$email] = true;
}
return array_values(array_keys($emails));
}
private function fetchAdminRows(): array
{
$excluded = ['guest', 'teacher', 'teacher_assistant', 'parent'];
$escaped = array_map(static fn ($value) => "'" . addslashes($value) . "'", $excluded);
$excludedList = implode(', ', $escaped);
$builder = DB::table('users as u')
->selectRaw("u.id, u.firstname, u.lastname, u.email, MAX(CASE WHEN LOWER(COALESCE(r.slug, r.name)) NOT IN({$excludedList}) THEN 1 ELSE 0 END) AS is_admin")
->join('user_roles as ur', 'ur.user_id', '=', 'u.id')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->where('r.is_active', 1)
->whereNotNull('u.email')
->where('u.email', '!=', '')
->groupBy('u.id', 'u.firstname', 'u.lastname', 'u.email')
->having('is_admin', 1);
return $builder->get()->map(fn ($r) => (array) $r)->all();
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Services\Settings\SchoolCalendar;
use App\Models\CalendarEvent;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class SchoolCalendarQueryService
{
public function listEvents(array $filters): Collection
{
$query = CalendarEvent::query();
$schoolYear = trim((string) ($filters['school_year'] ?? ''));
if ($schoolYear !== '') {
$query->where('school_year', $schoolYear);
}
$semester = trim((string) ($filters['semester'] ?? ''));
if ($semester !== '') {
$query->where('semester', $semester);
}
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
$query->orderBy('date', $sortDir)->orderBy('id', 'asc');
return $query->get();
}
public function findEvent(int $id): ?CalendarEvent
{
return CalendarEvent::query()->find($id);
}
public function filterEventsForAudience(Collection $events, ?string $audience): Collection
{
$audience = $audience !== null ? strtolower(trim($audience)) : null;
if (!in_array($audience, ['parent', 'teacher', 'admin'], true)) {
return $events;
}
return $events->filter(function (CalendarEvent $event) use ($audience) {
$p = (int) $event->notify_parent;
$t = (int) $event->notify_teacher;
$a = (int) $event->notify_admin;
$ns = (int) $event->no_school;
$hasAny = ($p + $t + $a) > 0;
if ($ns === 1) {
return true;
}
if (!$hasAny) {
return true;
}
if ($audience === 'parent') {
return $p === 1;
}
if ($audience === 'teacher') {
return $t === 1;
}
if ($audience === 'admin') {
return $a === 1;
}
return true;
})->values();
}
}