diff --git a/app/Http/Controllers/Api/Settings/SchoolCalendarController.php b/app/Http/Controllers/Api/Settings/SchoolCalendarController.php new file mode 100644 index 00000000..a7060c60 --- /dev/null +++ b/app/Http/Controllers/Api/Settings/SchoolCalendarController.php @@ -0,0 +1,154 @@ +success([ + 'event_types' => $this->contextService->eventTypes(), + 'defaults' => [ + 'school_year' => $this->contextService->defaultSchoolYear(), + 'semester' => $this->contextService->defaultSemester(), + ], + ]); + } + + public function index(SchoolCalendarIndexRequest $request): JsonResponse + { + $filters = $request->validated(); + $audience = $filters['audience'] ?? null; + $includeMeetings = array_key_exists('include_meetings', $filters) + ? (bool) $filters['include_meetings'] + : true; + + if (empty($filters['school_year'])) { + $filters['school_year'] = $this->contextService->defaultSchoolYear(); + } + + $events = $this->queryService->listEvents($filters); + $events = $this->queryService->filterEventsForAudience($events, $audience); + + $formatted = $events->map(function ($event) use ($audience) { + return $this->formatterService->formatEvent($event, $audience); + })->all(); + + if ($includeMeetings) { + $parentUserId = $audience === 'parent' ? (int) (auth()->id() ?? 0) : null; + $meetingRows = $this->meetingService->listMeetings($filters['school_year'] ?? '', $audience, $parentUserId); + foreach ($meetingRows as $row) { + $formatted[] = $this->formatterService->formatMeetingEvent($row, $audience); + } + } + + return $this->success([ + 'events' => SchoolCalendarEventResource::collection($formatted), + ]); + } + + public function show(int $eventId): JsonResponse + { + $event = $this->queryService->findEvent($eventId); + if (!$event) { + return $this->error('Event not found.', Response::HTTP_NOT_FOUND); + } + + return $this->success([ + 'event' => new SchoolCalendarEventDetailResource($event), + ]); + } + + public function store(SchoolCalendarStoreRequest $request): JsonResponse + { + $payload = $request->validated(); + $targets = $this->extractEmailTargets($payload); + + try { + $result = $this->mutationService->create($payload, $targets); + } catch (\Throwable $e) { + Log::error('SchoolCalendar store failed: ' . $e->getMessage()); + return $this->error('Failed to save event.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->success([ + 'event' => new SchoolCalendarEventDetailResource($result['event']), + 'email_status' => $result['email_status'] ?? [], + ], 'Event added successfully.', Response::HTTP_CREATED); + } + + public function update(SchoolCalendarUpdateRequest $request, int $eventId): JsonResponse + { + $event = $this->queryService->findEvent($eventId); + if (!$event) { + return $this->error('Event not found.', Response::HTTP_NOT_FOUND); + } + + $payload = $request->validated(); + $targets = $this->extractEmailTargets($payload); + + try { + $result = $this->mutationService->update($event, $payload, $targets); + } catch (\Throwable $e) { + Log::error('SchoolCalendar update failed: ' . $e->getMessage()); + return $this->error('Failed to update event.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->success([ + 'event' => new SchoolCalendarEventDetailResource($result['event']), + 'email_status' => $result['email_status'] ?? [], + ], 'Event updated successfully.'); + } + + public function destroy(int $eventId): JsonResponse + { + $event = $this->queryService->findEvent($eventId); + if (!$event) { + return $this->error('Event not found.', Response::HTTP_NOT_FOUND); + } + + try { + $this->mutationService->delete($event); + } catch (\Throwable $e) { + Log::error('SchoolCalendar delete failed: ' . $e->getMessage()); + return $this->error('Failed to delete event.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->success(null, 'Event deleted successfully.'); + } + + private function extractEmailTargets(array $payload): array + { + return [ + 'parents' => !empty($payload['send_email_parent']), + 'teachers' => !empty($payload['send_email_teacher']), + 'admins' => !empty($payload['send_email_admin']), + ]; + } +} diff --git a/app/Http/Requests/Settings/SchoolCalendarIndexRequest.php b/app/Http/Requests/Settings/SchoolCalendarIndexRequest.php new file mode 100644 index 00000000..208c8c67 --- /dev/null +++ b/app/Http/Requests/Settings/SchoolCalendarIndexRequest.php @@ -0,0 +1,25 @@ +check(); + } + + public function rules(): array + { + return [ + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + 'audience' => ['nullable', 'string', Rule::in(['parent', 'teacher', 'admin'])], + 'include_meetings' => ['nullable', 'boolean'], + 'sort_dir' => ['nullable', 'string', Rule::in(['asc', 'desc'])], + ]; + } +} diff --git a/app/Http/Requests/Settings/SchoolCalendarOptionsRequest.php b/app/Http/Requests/Settings/SchoolCalendarOptionsRequest.php new file mode 100644 index 00000000..89be7677 --- /dev/null +++ b/app/Http/Requests/Settings/SchoolCalendarOptionsRequest.php @@ -0,0 +1,18 @@ +check(); + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Settings/SchoolCalendarStoreRequest.php b/app/Http/Requests/Settings/SchoolCalendarStoreRequest.php new file mode 100644 index 00000000..4b0e759a --- /dev/null +++ b/app/Http/Requests/Settings/SchoolCalendarStoreRequest.php @@ -0,0 +1,36 @@ +check(); + } + + public function rules(): array + { + $eventTypes = app(SchoolCalendarContextService::class)->eventTypes(); + + return [ + 'title' => ['required', 'string', 'min:3', 'max:255'], + 'description' => ['nullable', 'string'], + 'event_type' => ['nullable', 'string', Rule::in($eventTypes)], + 'date' => ['required', 'date_format:Y-m-d'], + 'notify_parent' => ['nullable', 'boolean'], + 'notify_teacher' => ['nullable', 'boolean'], + 'notify_admin' => ['nullable', 'boolean'], + 'no_school' => ['nullable', 'boolean'], + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + 'send_email_parent' => ['nullable', 'boolean'], + 'send_email_teacher' => ['nullable', 'boolean'], + 'send_email_admin' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Settings/SchoolCalendarUpdateRequest.php b/app/Http/Requests/Settings/SchoolCalendarUpdateRequest.php new file mode 100644 index 00000000..817740ca --- /dev/null +++ b/app/Http/Requests/Settings/SchoolCalendarUpdateRequest.php @@ -0,0 +1,36 @@ +check(); + } + + public function rules(): array + { + $eventTypes = app(SchoolCalendarContextService::class)->eventTypes(); + + return [ + 'title' => ['sometimes', 'string', 'min:3', 'max:255'], + 'description' => ['nullable', 'string'], + 'event_type' => ['nullable', 'string', Rule::in($eventTypes)], + 'date' => ['sometimes', 'date_format:Y-m-d'], + 'notify_parent' => ['nullable', 'boolean'], + 'notify_teacher' => ['nullable', 'boolean'], + 'notify_admin' => ['nullable', 'boolean'], + 'no_school' => ['nullable', 'boolean'], + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + 'send_email_parent' => ['nullable', 'boolean'], + 'send_email_teacher' => ['nullable', 'boolean'], + 'send_email_admin' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Resources/Settings/SchoolCalendarEventDetailResource.php b/app/Http/Resources/Settings/SchoolCalendarEventDetailResource.php new file mode 100644 index 00000000..2d142e6c --- /dev/null +++ b/app/Http/Resources/Settings/SchoolCalendarEventDetailResource.php @@ -0,0 +1,27 @@ + (int) ($this->id ?? 0), + 'title' => (string) ($this->title ?? ''), + 'description' => (string) ($this->description ?? ''), + 'event_type' => $this->event_type ?? null, + 'date' => (string) ($this->date ?? ''), + 'notify_parent' => (int) ($this->notify_parent ?? 0), + 'notify_teacher' => (int) ($this->notify_teacher ?? 0), + 'notify_admin' => (int) ($this->notify_admin ?? 0), + 'no_school' => (int) ($this->no_school ?? 0), + 'semester' => (string) ($this->semester ?? ''), + 'school_year' => (string) ($this->school_year ?? ''), + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } +} diff --git a/app/Http/Resources/Settings/SchoolCalendarEventResource.php b/app/Http/Resources/Settings/SchoolCalendarEventResource.php new file mode 100644 index 00000000..26f98fdc --- /dev/null +++ b/app/Http/Resources/Settings/SchoolCalendarEventResource.php @@ -0,0 +1,19 @@ + $this['id'] ?? null, + 'title' => (string) ($this['title'] ?? ''), + 'start' => (string) ($this['start'] ?? ''), + 'description' => (string) ($this['description'] ?? ''), + 'extendedProps' => (array) ($this['extendedProps'] ?? []), + ]; + } +} diff --git a/app/Models/CalendarEvent.php b/app/Models/CalendarEvent.php index 46e14617..401c874d 100755 --- a/app/Models/CalendarEvent.php +++ b/app/Models/CalendarEvent.php @@ -17,6 +17,7 @@ class CalendarEvent extends BaseModel 'title', 'date', 'description', + 'event_type', 'notify_parent', 'notify_admin', 'notify_teacher', @@ -35,6 +36,7 @@ class CalendarEvent extends BaseModel 'notify_admin' => 'boolean', 'notify_teacher' => 'boolean', 'no_school' => 'integer', + 'event_type' => 'string', ]; private static ?bool $hasEventTypeColumn = null; diff --git a/app/Services/Settings/SchoolCalendar/SchoolCalendarContextService.php b/app/Services/Settings/SchoolCalendar/SchoolCalendarContextService.php new file mode 100644 index 00000000..531e77f6 --- /dev/null +++ b/app/Services/Settings/SchoolCalendar/SchoolCalendarContextService.php @@ -0,0 +1,32 @@ +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', + ], + ]; + } +} diff --git a/app/Services/Settings/SchoolCalendar/SchoolCalendarMeetingService.php b/app/Services/Settings/SchoolCalendar/SchoolCalendarMeetingService.php new file mode 100644 index 00000000..f05032d6 --- /dev/null +++ b/app/Services/Settings/SchoolCalendar/SchoolCalendarMeetingService.php @@ -0,0 +1,88 @@ +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 []; + } + } +} diff --git a/app/Services/Settings/SchoolCalendar/SchoolCalendarMutationService.php b/app/Services/Settings/SchoolCalendar/SchoolCalendarMutationService.php new file mode 100644 index 00000000..a14861e1 --- /dev/null +++ b/app/Services/Settings/SchoolCalendar/SchoolCalendarMutationService.php @@ -0,0 +1,94 @@ +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; + } +} diff --git a/app/Services/Settings/SchoolCalendar/SchoolCalendarNotificationService.php b/app/Services/Settings/SchoolCalendar/SchoolCalendarNotificationService.php new file mode 100644 index 00000000..b0b49d17 --- /dev/null +++ b/app/Services/Settings/SchoolCalendar/SchoolCalendarNotificationService.php @@ -0,0 +1,115 @@ +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 = '

' . e($title) . '

'; + $body .= '

Date: ' . e($date) . '

'; + if ($description !== '') { + $body .= '

' . nl2br(e($description)) . '

'; + } + $body .= '

View calendar

'; + + 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(); + } +} diff --git a/app/Services/Settings/SchoolCalendar/SchoolCalendarQueryService.php b/app/Services/Settings/SchoolCalendar/SchoolCalendarQueryService.php new file mode 100644 index 00000000..9688cc42 --- /dev/null +++ b/app/Services/Settings/SchoolCalendar/SchoolCalendarQueryService.php @@ -0,0 +1,71 @@ +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(); + } +} diff --git a/app/old/SchoolCalendarController.php b/app/old/SchoolCalendarController.php deleted file mode 100644 index 84f6d9d8..00000000 --- a/app/old/SchoolCalendarController.php +++ /dev/null @@ -1,658 +0,0 @@ -calendarModel = new CalendarModel(); - $this->configModel = new ConfigurationModel(); - $this->userModel = new UserModel(); - $this->teacherModel = new TeacherModel(); - $this->emailService = new EmailService(); - $this->meetingModel = new ParentMeetingScheduleModel(); - $this->db = \Config\Database::connect(); - $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); - - // Load helpers - helper(['form', 'url']); - } - - public function index() - { - // Get school_year / semester from query string or defaults - $syParam = $this->request->getGet('school_year'); - $semParam = $this->request->getGet('semester'); - $schoolYear = $syParam ?: $this->schoolYear; - $semester = $semParam ?: ''; - - if ($semester !== '') { - $events = $this->calendarModel->getEventsBySchoolYearAndSemester($schoolYear, $semester); - } else { - $events = $this->calendarModel->getEventsBySchoolYear($schoolYear); - } - - return view('administrator/calendar_view', [ - 'events' => $events, - 'schoolYear' => $schoolYear, - 'semester' => $semester, - ]); - } - - - public function edit($id) - { - $event = $this->calendarModel->find($id); - - if (!$event) { - return redirect()->to('/administrator/calendar_view')->with('error', 'Event not found'); - } - - return view('administrator/calendar_edit', [ - 'event' => $event, - 'eventTypes' => $this->eventTypes, - ]); - } - - - public function update($id) - { - // Fetch the event by ID - $event = $this->calendarModel->find($id); - - if (!$event) { - return redirect()->to('/administrator/calendar_view')->with('error', 'Event not found.'); - } - - if (strtolower($this->request->getMethod()) === 'post') { - $validation = $this->validate([ - 'title' => 'required|min_length[3]', - 'start' => 'required|valid_date[Y-m-d]', - ]); - - if (!$validation) { - return view('administrator/calendar_edit', [ - 'validation' => $this->validator, - 'event' => $event, - 'eventTypes' => $this->eventTypes, - ]); - } - - $emailTargets = $this->emailNotificationTargetsFromRequest(); - - $data = [ - 'title' => $this->request->getPost('title'), - 'description' => $this->request->getPost('description'), - 'event_type' => $this->request->getPost('event_type') ?: null, - 'date' => $this->request->getPost('start'), - 'notify_parent' => $this->request->getPost('notify_parent') ? 1 : 0, - 'notify_teacher' => $this->request->getPost('notify_teacher') ? 1 : 0, - 'notify_admin' => $this->request->getPost('notify_admin') ? 1 : 0, - 'no_school' => $this->request->getPost('no_school') ? 1 : 0, - 'school_year' => $this->request->getPost('school_year') ?: $this->schoolYear, - 'semester' => $this->request->getPost('semester') ?: $this->semester, - ]; - if (!$this->calendarModel->supportsEventType()) { - unset($data['event_type']); - } - - if ($this->calendarModel->update($id, $data)) { - $updatedEvent = $this->calendarModel->find($id); - $emailStatus = $this->dispatchCalendarNotificationEmails($updatedEvent, $emailTargets); - $filters = [ - 'school_year' => $data['school_year'], - 'semester' => $data['semester'], - ]; - $redirect = redirect()->to($this->buildCalendarViewUrl($filters))->with('success', 'Event updated successfully'); - if ($emailFlash = $this->buildEmailFlash($emailStatus)) { - $redirect = $redirect - ->with('email_status', $emailFlash['message']) - ->with('email_status_class', $emailFlash['class']); - } - return $redirect; - } else { - return redirect()->back()->with('error', 'Failed to update the event.'); - } - } - - return view('administrator/calendar_edit', [ - 'event' => $event, - 'eventTypes' => $this->eventTypes, - ]); - } - - public function addEvent() - { - if (strtolower($this->request->getMethod()) === 'post') { - $validation = $this->validate([ - 'title' => 'required|min_length[3]', - 'start' => 'required|valid_date[Y-m-d]', - ]); - - if (!$validation) { - return view('administrator/calendar_add_event', [ - 'validation' => $this->validator, - 'eventTypes' => $this->eventTypes, - ]); - } - - $emailTargets = $this->emailNotificationTargetsFromRequest(); - - // Prepare data - $data = [ - 'title' => $this->request->getPost('title'), - 'description' => $this->request->getPost('description'), - 'event_type' => $this->request->getPost('event_type') ?: null, - 'date' => $this->request->getPost('start'), - 'notify_parent' => $this->request->getPost('notify_parent') ? 1 : 0, - 'notify_teacher' => $this->request->getPost('notify_teacher') ? 1 : 0, - 'notify_admin' => $this->request->getPost('notify_admin') ? 1 : 0, - 'no_school' => $this->request->getPost('no_school') ? 1 : 0, - 'school_year' => $this->request->getPost('school_year') ?: $this->schoolYear, - 'semester' => $this->request->getPost('semester') ?: $this->semester, - ]; - if (!$this->calendarModel->supportsEventType()) { - unset($data['event_type']); - } - - if ($this->calendarModel->save($data)) { - $insertedId = $this->calendarModel->getInsertID(); - $savedEvent = $this->calendarModel->find($insertedId); - $emailStatus = $this->dispatchCalendarNotificationEmails($savedEvent, $emailTargets); - log_message('info', 'Event saved successfully: ' . print_r($data, true)); - $filters = [ - 'school_year' => $data['school_year'], - 'semester' => $data['semester'], - ]; - $redirect = redirect()->to($this->buildCalendarViewUrl($filters))->with('success', 'Event added successfully'); - if ($emailFlash = $this->buildEmailFlash($emailStatus)) { - $redirect = $redirect - ->with('email_status', $emailFlash['message']) - ->with('email_status_class', $emailFlash['class']); - } - return $redirect; - } else { - log_message('error', 'Failed to save event: ' . print_r($data, true)); - return redirect()->back()->with('error', 'Failed to save the event.'); - } - } - - return view('administrator/calendar_add_event', [ - 'eventTypes' => $this->eventTypes, - ]); - } - - protected function emailNotificationTargetsFromRequest(): array - { - return [ - 'parents' => (bool) $this->request->getPost('send_email_parent'), - 'teachers' => (bool) $this->request->getPost('send_email_teacher'), - 'admins' => (bool) $this->request->getPost('send_email_admin'), - ]; - } - - protected function dispatchCalendarNotificationEmails(array $event = null, array $targets = []): array - { - if (empty($event) || empty(array_filter($targets))) { - return []; - } - - $subject = $this->buildCalendarEmailSubject($event); - $innerBody = view('emails/calendar_notification', [ - 'event' => $event, - 'calendarUrl' => base_url('administrator/calendar_view'), - ]); - $body = view('emails/_wrap_layout', [ - 'title' => $subject, - 'subject' => $subject, - 'body_html' => $innerBody, - ]); - - $results = []; - foreach ($targets as $group => $enabled) { - if (!$enabled) { - continue; - } - - $emails = $this->collectEmailsForGroup($group); - if (empty($emails)) { - log_message('warning', "SchoolCalendarController: no recipient emails for {$group}."); - $results[$group] = null; - continue; - } - - $ok = $this->emailService->sendBcc($emails, $subject, $body, 'general'); - if (!$ok) { - log_message('warning', "SchoolCalendarController: failed to send calendar notification to {$group}."); - } - $results[$group] = $ok; - } - - return $results; - } - - protected function collectEmailsForGroup(string $group): array - { - switch ($group) { - case 'parents': - return $this->normalizeEmailAddresses($this->userModel->getParents()); - case 'teachers': - return $this->normalizeEmailAddresses($this->teacherModel->getAllTeachers()); - case 'admins': - return $this->normalizeEmailAddresses($this->fetchAdminRows()); - default: - return []; - } - } - - protected function normalizeEmailAddresses(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)); - } - - protected function fetchAdminRows(): array - { - $excluded = ['guest', 'teacher', 'teacher_assistant', 'parent']; - $escaped = array_map(static fn($value) => "'" . addslashes($value) . "'", $excluded); - $excludedList = implode(', ', $escaped); - - $builder = $this->db->table('users u') - ->select("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", false) - ->join('user_roles ur', 'ur.user_id = u.id') - ->join('roles r', 'r.id = ur.role_id') - ->where('r.is_active', 1) - ->where('u.email IS NOT NULL') - ->where('u.email !=', '') - ->groupBy('u.id, u.firstname, u.lastname, u.email') - ->having('is_admin', 1); - - return $builder->get()->getResultArray(); - } - - protected function buildCalendarEmailSubject(array $event): string - { - $title = trim((string) ($event['title'] ?? 'School Calendar Update')); - $typeSegment = !empty($event['event_type']) ? ' (' . $event['event_type'] . ')' : ''; - return 'School Calendar: ' . $title . $typeSegment; - } - - protected function buildEmailFlash(array $status): ?array - { - if (empty($status)) { - return null; - } - - $sent = []; - $failed = []; - $missing = []; - - foreach ($status as $group => $result) { - if ($result === true) { - $sent[] = $group; - } elseif ($result === false) { - $failed[] = $group; - } elseif ($result === null) { - $missing[] = $group; - } - } - - $parts = []; - if (!empty($sent)) { - $parts[] = 'Email sent to ' . $this->humanizeGroupList($sent) . '.'; - } - if (!empty($failed)) { - $parts[] = 'Sending failed for ' . $this->humanizeGroupList($failed) . '.'; - } - if (!empty($missing)) { - $parts[] = 'No recipient emails for ' . $this->humanizeGroupList($missing) . '.'; - } - - if (empty($parts)) { - return null; - } - - return [ - 'message' => implode(' ', $parts), - 'class' => empty($failed) && empty($missing) ? 'info' : 'warning', - ]; - } - - protected function humanizeGroupList(array $groups): string - { - $groups = array_map('strval', $groups); - if (empty($groups)) { - return ''; - } - if (count($groups) === 1) { - return $groups[0]; - } - $last = array_pop($groups); - return implode(', ', $groups) . ' and ' . $last; - } - - protected function buildCalendarViewUrl(array $filters = []): string - { - $allowed = ['school_year', 'semester']; - $params = []; - foreach ($allowed as $key) { - $value = $filters[$key] ?? ''; - if ($value !== '') { - $params[$key] = $value; - } - } - $query = http_build_query($params); - return '/administrator/calendar_view' . ($query !== '' ? ('?' . $query) : ''); - } - - public function delete($id) - { - - // Check if the event exists - $event = $this->calendarModel->find($id); - if (!$event) { - return redirect()->to('/administrator/calendar_view')->with('error', 'Event not found.'); - } - - // Delete the event - if ($this->calendarModel->delete($id)) { - return redirect()->to('/administrator/calendar_view')->with('success', 'Event deleted successfully'); - } else { - return redirect()->to('/administrator/calendar_view')->with('error', 'Failed to delete the event.'); - } - } - - public function calendarParentView() - { - // Transform the events into the format FullCalendar expects - $formattedEvents = $this->calendarView('parent'); - - // Pass the formatted events to the view - return view('/parent/calendar', ['events' => $formattedEvents]); - } - - public function calendarTeacherView() - { - // Transform the events into the format FullCalendar expects - $formattedEvents = $this->calendarView('teacher'); - - // Pass the formatted events to the view - return view('/teacher/calendar', ['events' => $formattedEvents]); - } - - public function calendarAdminView() - { - // Transform the events into the format FullCalendar expects - $formattedEvents = $this->calendarView('admin'); - - // Pass the formatted events to the view - return view('administrator/calendar', ['events' => $formattedEvents]); - } - - private function calendarView(?string $audience = null) - { - // Filter events by school year - $events = $this->calendarModel->getEventsBySchoolYear($this->schoolYear); - - // Apply audience visibility rules: - // - If no checkboxes selected (all notify_* = 0) => visible to everyone - // - Otherwise visible only to the selected audience - if (in_array($audience, ['parent', 'teacher', 'admin'], true)) { - $events = array_values(array_filter($events, function ($event) use ($audience) { - $p = (int) ($event['notify_parent'] ?? 0); - $t = (int) ($event['notify_teacher'] ?? 0); - $a = (int) ($event['notify_admin'] ?? 0); - $ns = (int) ($event['no_school'] ?? 0); - $hasAny = ($p + $t + $a) > 0; - - // If marked as no_school, it should be visible to all audiences - if ($ns === 1) { - return true; - } - - if (!$hasAny) { - // No audience explicitly selected: show to everyone - return true; - } - - // Audience explicitly selected: restrict to matching audience - if ($audience === 'parent') { return $p === 1; } - if ($audience === 'teacher') { return $t === 1; } - if ($audience === 'admin') { return $a === 1; } - return true; - })); - } - - // Format for FullCalendar - $aud = $audience; // capture for closure - $formattedEvents = array_map(function ($event) use ($aud) { - $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; - // Color no-school events yellow on teacher and parent calendars - if (in_array($aud, ['teacher', 'parent'], true) && $ns === 1) { - $backgroundColor = '#ffc107'; // yellow - } - - // Highlight events dedicated to staff (teachers/admins) only in green - // Definition: visible to staff but not to parents, and not a no-school marker - if (in_array($aud, ['teacher', 'admin'], true) && $backgroundColor === null) { - $isStaffOnly = ($p === 0 && ($t === 1 || $a === 1) && $ns === 0); - if ($isStaffOnly) { - // Use a distinct green to differentiate from the default blue - $backgroundColor = '#28a745'; - } - } - - // Title-casing for "no school" phrase when marked as no_school - $title = $event['title'] ?? 'Untitled'; - if ($ns === 1 && is_string($title)) { - // Replace common variants with Title Case - $title = preg_replace('/\bno\s*[- ]\s*school\b/i', 'No School', $title); - } - - return [ - 'id' => $event['id'], - 'title' => $title, - 'start' => $event['date'] ?? '', // FullCalendar expects 'start' - 'description' => $event['description'] ?? '', - 'extendedProps' => [ - 'semester' => $event['semester'] ?? '', - 'school_year' => $event['school_year'] ?? '', - 'notify_parent' => $p, - 'notify_teacher' => $t, - 'notify_admin' => $a, - 'no_school' => $ns, - 'backgroundColor' => $backgroundColor, - ] - ]; - }, $events); - - // Append parent meeting schedules - $meetingEvents = $this->formatMeetingEvents($audience); - if (!empty($meetingEvents)) { - $formattedEvents = array_values(array_merge($formattedEvents, $meetingEvents)); - } - - return $formattedEvents; - } - - private function formatMeetingEvents(?string $audience = null): array - { - $aud = $audience ?? ''; - $builder = $this->meetingModel - ->where('school_year', $this->schoolYear) - ->groupStart() - ->where('status', 'scheduled') - ->orWhere('status', 'Scheduled') - ->orWhere('status', '') - ->orWhere('status', null) - ->groupEnd(); - - if ($aud === 'parent') { - $parentUserId = (int) (session()->get('user_id') ?? 0); - if ($parentUserId <= 0) { - return []; - } - $studentIds = $this->getStudentIdsForParent($parentUserId); - if (!empty($studentIds)) { - $builder = $builder->groupStart() - ->where('parent_user_id', $parentUserId) - ->orWhereIn('student_id', $studentIds) - ->groupEnd(); - } else { - $builder = $builder->where('parent_user_id', $parentUserId); - } - } - - $rows = $builder->orderBy('date', 'ASC')->findAll(); - if (empty($rows)) { - // Fallback: show any meetings if school_year was missing/mismatched - $fallback = $this->meetingModel - ->groupStart() - ->where('status', 'scheduled') - ->orWhere('status', 'Scheduled') - ->orWhere('status', '') - ->orWhere('status', null) - ->groupEnd(); - - if ($aud === 'parent') { - $parentUserId = (int) (session()->get('user_id') ?? 0); - if ($parentUserId > 0) { - $studentIds = $this->getStudentIdsForParent($parentUserId); - if (!empty($studentIds)) { - $fallback = $fallback->groupStart() - ->where('parent_user_id', $parentUserId) - ->orWhereIn('student_id', $studentIds) - ->groupEnd(); - } else { - $fallback = $fallback->where('parent_user_id', $parentUserId); - } - } - } - - $rows = $fallback->orderBy('date', 'ASC')->findAll(); - } - if (empty($rows)) { - return []; - } - - return array_map(function ($row) use ($aud) { - $parentName = (string)($row['parent_name'] ?? 'Parent'); - $studentName = (string)($row['student_name'] ?? 'Student'); - $time = trim((string)($row['time'] ?? '')); - $timeLabel = $time !== '' ? (' ' . $time) : ''; - - if ($aud === 'admin') { - $title = 'Admin Meeting'; - } else { - $title = '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' => $row['semester'] ?? '', - 'school_year' => $row['school_year'] ?? '', - 'notify_parent' => 1, - 'notify_admin' => 1, - 'notify_teacher' => 0, - 'no_school' => 0, - 'backgroundColor' => '#6f42c1', - ], - ]; - }, $rows); - } - - private function getStudentIdsForParent(int $parentUserId): array - { - try { - $rows = $this->db->query( - "SELECT DISTINCT fs.student_id - FROM family_guardians fg - JOIN family_students fs ON fs.family_id = fg.family_id - WHERE fg.user_id = ?", - [$parentUserId] - )->getResultArray(); - $ids = array_map(static fn($r) => (int)($r['student_id'] ?? 0), $rows); - - // Legacy fallback: students.parent_id - $rows2 = $this->db->query( - "SELECT id AS student_id - FROM students - WHERE parent_id = ?", - [$parentUserId] - )->getResultArray(); - foreach ($rows2 as $r) { - $ids[] = (int)($r['student_id'] ?? 0); - } - - return array_values(array_filter($ids, static fn($id) => $id > 0)); - } catch (\Throwable $e) { - return []; - } - } - -} diff --git a/app/old/SessionTimeoutController.php b/app/old/SessionTimeoutController.php deleted file mode 100644 index 2c0970ae..00000000 --- a/app/old/SessionTimeoutController.php +++ /dev/null @@ -1,92 +0,0 @@ -response->setJSON([ - 'success' => true, - 'timeout' => SessionTimeout::TIMEOUT_DURATION, - 'warning_time' => SessionTimeout::WARNING_THRESHOLD, - 'check_interval' => SessionTimeout::CHECK_INTERVAL, - 'logout_url' => site_url('auth/logout'), - 'keep_alive_url' => site_url('session/pingActivity'), - 'check_url' => site_url('session/checkTimeout') - ]); - } - - public function checkTimeout() - { - $session = session(); - - // Verify session exists and has last_activity - if (!$session->has('last_activity')) { - return $this->expireSession(); - } - - $lastActivity = $session->get('last_activity'); - $elapsed = time() - $lastActivity; - - if ($elapsed > SessionTimeout::TIMEOUT_DURATION) { - return $this->expireSession(); - } elseif ($elapsed > SessionTimeout::WARNING_THRESHOLD) { - return $this->response->setJSON([ - 'status' => 'warning', - 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed - ]); - } - - return $this->response->setJSON([ - 'status' => 'active', - 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed - ]); - } - - public function pingActivity() - { - $session = session(); - - // Only update if session is still valid - if (!$session->has('last_activity') || - (time() - $session->get('last_activity') > SessionTimeout::TIMEOUT_DURATION)) { - return $this->expireSession(); - } - - $session->set('last_activity', time()); - return $this->response->setJSON([ - 'status' => 'active', - 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - ]); - } - - private function expireSession() - { - $this->destroySession(); - return $this->response->setJSON([ - 'status' => 'expired', - 'redirect' => site_url('login'), - 'message' => 'Your session has expired due to inactivity.' - ]); - } - - private function destroySession() - { - $session = session(); - $session->setFlashdata('error', 'Your session has expired due to inactivity.'); - - // Clear session data - $session->remove('last_activity'); - $session->destroy(); - - // Regenerate session ID for security - session_regenerate_id(true); - } -} \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index f04c2fbf..5d80a4ca 100755 --- a/routes/api.php +++ b/routes/api.php @@ -187,6 +187,14 @@ Route::prefix('v1')->group(function () { Route::get('check-timeout', [SessionTimeoutController::class, 'check'])->name('session.timeout.check'); Route::post('ping', [SessionTimeoutController::class, 'ping'])->name('session.timeout.ping'); }); + Route::prefix('settings/school-calendar')->group(function () { + Route::get('options', [SchoolCalendarController::class, 'options']); + Route::get('events', [SchoolCalendarController::class, 'index']); + Route::get('events/{eventId}', [SchoolCalendarController::class, 'show']); + Route::post('events', [SchoolCalendarController::class, 'store']); + Route::patch('events/{eventId}', [SchoolCalendarController::class, 'update']); + Route::delete('events/{eventId}', [SchoolCalendarController::class, 'destroy']); + }); Route::prefix('whatsapp')->group(function () { Route::get('links', [WhatsappController::class, 'index']); diff --git a/tests/Feature/Api/V1/Settings/SchoolCalendarControllerTest.php b/tests/Feature/Api/V1/Settings/SchoolCalendarControllerTest.php new file mode 100644 index 00000000..dcefcf66 --- /dev/null +++ b/tests/Feature/Api/V1/Settings/SchoolCalendarControllerTest.php @@ -0,0 +1,230 @@ +seedConfig(); + $user = $this->seedUser(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/settings/school-calendar/options'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.event_types')); + $this->assertSame('2025-2026', $response->json('data.defaults.school_year')); + } + + public function test_index_returns_events_and_meetings(): void + { + $this->seedConfig(); + $user = $this->seedUser(); + $this->seedEvent(); + $this->seedMeeting(); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/settings/school-calendar/events?school_year=2025-2026&include_meetings=1'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.events')); + $this->assertArrayHasKey('extendedProps', $response->json('data.events.0')); + } + + public function test_show_returns_event(): void + { + $this->seedConfig(); + $user = $this->seedUser(); + $eventId = $this->seedEvent(); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/settings/school-calendar/events/' . $eventId); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $response->assertJsonPath('data.event.id', $eventId); + } + + public function test_store_creates_event(): void + { + $this->seedConfig(); + $user = $this->seedUser(); + Sanctum::actingAs($user); + + $payload = [ + 'title' => 'Calendar Event', + 'date' => '2026-01-10', + 'description' => 'Description', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]; + + $response = $this->postJson('/api/v1/settings/school-calendar/events', $payload); + + $response->assertStatus(201); + $response->assertJsonPath('status', true); + $this->assertDatabaseHas('calendar_events', [ + 'title' => 'Calendar Event', + 'school_year' => '2025-2026', + ]); + } + + public function test_update_updates_event(): void + { + $this->seedConfig(); + $user = $this->seedUser(); + $eventId = $this->seedEvent(); + + Sanctum::actingAs($user); + $response = $this->patchJson('/api/v1/settings/school-calendar/events/' . $eventId, [ + 'title' => 'Updated Event', + ]); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertDatabaseHas('calendar_events', [ + 'id' => $eventId, + 'title' => 'Updated Event', + ]); + } + + public function test_destroy_deletes_event(): void + { + $this->seedConfig(); + $user = $this->seedUser(); + $eventId = $this->seedEvent(); + + Sanctum::actingAs($user); + $response = $this->deleteJson('/api/v1/settings/school-calendar/events/' . $eventId); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertDatabaseMissing('calendar_events', ['id' => $eventId]); + } + + public function test_store_validation_rejects_invalid_payload(): void + { + $this->seedConfig(); + $user = $this->seedUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/settings/school-calendar/events', [ + 'title' => 'No date', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('message', 'Validation failed.'); + } + + public function test_store_returns_error_on_service_exception(): void + { + $this->seedConfig(); + $user = $this->seedUser(); + Sanctum::actingAs($user); + + $this->app->bind(\App\Services\Settings\SchoolCalendar\SchoolCalendarMutationService::class, function () { + return new class { + public function create(array $payload, array $targets = []): array + { + throw new \RuntimeException('fail'); + } + }; + }); + + $response = $this->postJson('/api/v1/settings/school-calendar/events', [ + 'title' => 'Event', + 'date' => '2026-01-10', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $response->assertStatus(500); + $response->assertJsonPath('status', false); + } + + public function test_requires_authentication(): void + { + $response = $this->getJson('/api/v1/settings/school-calendar/events'); + + $response->assertStatus(401); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedUser(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '9999999999', + 'email' => 'calendar@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } + + private function seedEvent(): int + { + return DB::table('calendar_events')->insertGetId([ + 'title' => 'Event', + 'date' => '2026-01-01', + 'description' => 'Desc', + 'notify_parent' => 1, + 'notify_admin' => 0, + 'notify_teacher' => 0, + 'no_school' => 0, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'notification_sent' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedMeeting(): void + { + DB::table('parent_meeting_schedules')->insert([ + 'student_id' => 1, + 'parent_user_id' => 1, + 'parent_name' => 'Parent', + 'student_name' => 'Student', + 'class_section_name' => 'Grade 1', + 'date' => '2026-01-02', + 'time' => '10:00:00', + 'notes' => 'Notes', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'scheduled', + 'created_by' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Unit/Resources/Settings/SchoolCalendarEventResourceTest.php b/tests/Unit/Resources/Settings/SchoolCalendarEventResourceTest.php new file mode 100644 index 00000000..5ff27e23 --- /dev/null +++ b/tests/Unit/Resources/Settings/SchoolCalendarEventResourceTest.php @@ -0,0 +1,28 @@ + 1, + 'title' => 'Event', + 'start' => '2026-01-01', + 'description' => 'Desc', + 'extendedProps' => ['no_school' => 0], + ]); + + $payload = $resource->toArray(request()); + + $this->assertArrayHasKey('id', $payload); + $this->assertArrayHasKey('title', $payload); + $this->assertArrayHasKey('start', $payload); + $this->assertArrayHasKey('description', $payload); + $this->assertArrayHasKey('extendedProps', $payload); + } +} diff --git a/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarContextServiceTest.php b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarContextServiceTest.php new file mode 100644 index 00000000..b6d21722 --- /dev/null +++ b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarContextServiceTest.php @@ -0,0 +1,18 @@ +eventTypes(); + + $this->assertNotEmpty($types); + $this->assertContains('Event', $types); + } +} diff --git a/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarFormatterServiceTest.php b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarFormatterServiceTest.php new file mode 100644 index 00000000..ac5ecfa4 --- /dev/null +++ b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarFormatterServiceTest.php @@ -0,0 +1,26 @@ +formatEvent([ + 'id' => 1, + 'title' => 'No School Day', + 'date' => '2026-01-01', + 'no_school' => 1, + 'notify_parent' => 0, + 'notify_teacher' => 0, + 'notify_admin' => 0, + ], 'parent'); + + $this->assertSame('#ffc107', $formatted['extendedProps']['backgroundColor']); + $this->assertSame('No School Day', $formatted['title']); + } +} diff --git a/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarMeetingServiceTest.php b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarMeetingServiceTest.php new file mode 100644 index 00000000..ddcde851 --- /dev/null +++ b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarMeetingServiceTest.php @@ -0,0 +1,57 @@ +insert([ + [ + 'student_id' => 1, + 'parent_user_id' => 10, + 'parent_name' => 'Parent', + 'student_name' => 'Student', + 'class_section_name' => 'Grade 1', + 'date' => '2026-01-02', + 'time' => '10:00:00', + 'notes' => 'Notes', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'scheduled', + 'created_by' => null, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'student_id' => 2, + 'parent_user_id' => 11, + 'parent_name' => 'Other Parent', + 'student_name' => 'Other Student', + 'class_section_name' => 'Grade 2', + 'date' => '2026-01-03', + 'time' => '11:00:00', + 'notes' => 'Notes', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'scheduled', + 'created_by' => null, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $service = new SchoolCalendarMeetingService(); + $rows = $service->listMeetings('2025-2026', 'parent', 10); + + $this->assertCount(1, $rows); + $this->assertSame(10, $rows[0]['parent_user_id']); + } +} diff --git a/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarMutationServiceTest.php b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarMutationServiceTest.php new file mode 100644 index 00000000..de7d2afa --- /dev/null +++ b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarMutationServiceTest.php @@ -0,0 +1,62 @@ +shouldReceive('notify')->andReturn([]); + + $service = new SchoolCalendarMutationService($context, $notifier); + $result = $service->create([ + 'title' => 'New Event', + 'date' => '2026-01-05', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], []); + + $this->assertInstanceOf(CalendarEvent::class, $result['event']); + $this->assertDatabaseHas('calendar_events', ['title' => 'New Event']); + } + + public function test_update_persists_changes(): void + { + $event = CalendarEvent::query()->create([ + 'title' => 'Old Event', + 'date' => '2026-01-01', + 'description' => '', + 'notify_parent' => 0, + 'notify_admin' => 0, + 'notify_teacher' => 0, + 'no_school' => 0, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'notification_sent' => 0, + ]); + + $context = new SchoolCalendarContextService(); + $notifier = Mockery::mock(SchoolCalendarNotificationService::class); + $notifier->shouldReceive('notify')->andReturn([]); + + $service = new SchoolCalendarMutationService($context, $notifier); + $service->update($event, ['title' => 'Updated Event'], []); + + $this->assertDatabaseHas('calendar_events', [ + 'id' => $event->id, + 'title' => 'Updated Event', + ]); + } +} diff --git a/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarNotificationServiceTest.php b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarNotificationServiceTest.php new file mode 100644 index 00000000..7208e0f3 --- /dev/null +++ b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarNotificationServiceTest.php @@ -0,0 +1,21 @@ +notify(['title' => 'Event'], []); + + $this->assertSame([], $result); + } +} diff --git a/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarQueryServiceTest.php b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarQueryServiceTest.php new file mode 100644 index 00000000..5510dd39 --- /dev/null +++ b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarQueryServiceTest.php @@ -0,0 +1,94 @@ +insert([ + [ + 'title' => 'Event A', + 'date' => '2026-01-01', + 'description' => '', + 'notify_parent' => 1, + 'notify_admin' => 0, + 'notify_teacher' => 0, + 'no_school' => 0, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'notification_sent' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'title' => 'Event B', + 'date' => '2026-01-02', + 'description' => '', + 'notify_parent' => 1, + 'notify_admin' => 0, + 'notify_teacher' => 0, + 'no_school' => 0, + 'semester' => 'Fall', + 'school_year' => '2026-2027', + 'notification_sent' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $service = new SchoolCalendarQueryService(); + $events = $service->listEvents(['school_year' => '2025-2026']); + + $this->assertCount(1, $events); + $this->assertSame('Event A', $events->first()->title); + } + + public function test_filter_events_for_audience_limits_visibility(): void + { + DB::table('calendar_events')->insert([ + [ + 'title' => 'Parent Event', + 'date' => '2026-01-01', + 'description' => '', + 'notify_parent' => 1, + 'notify_admin' => 0, + 'notify_teacher' => 0, + 'no_school' => 0, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'notification_sent' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'title' => 'Teacher Event', + 'date' => '2026-01-02', + 'description' => '', + 'notify_parent' => 0, + 'notify_admin' => 0, + 'notify_teacher' => 1, + 'no_school' => 0, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'notification_sent' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $service = new SchoolCalendarQueryService(); + $events = $service->listEvents(['school_year' => '2025-2026']); + $filtered = $service->filterEventsForAudience($events, 'parent'); + + $this->assertCount(1, $filtered); + $this->assertSame('Parent Event', $filtered->first()->title); + } +}