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,154 @@
<?php
namespace App\Http\Controllers\Api\Settings;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Settings\SchoolCalendarIndexRequest;
use App\Http\Requests\Settings\SchoolCalendarOptionsRequest;
use App\Http\Requests\Settings\SchoolCalendarStoreRequest;
use App\Http\Requests\Settings\SchoolCalendarUpdateRequest;
use App\Http\Resources\Settings\SchoolCalendarEventDetailResource;
use App\Http\Resources\Settings\SchoolCalendarEventResource;
use App\Services\Settings\SchoolCalendar\SchoolCalendarContextService;
use App\Services\Settings\SchoolCalendar\SchoolCalendarFormatterService;
use App\Services\Settings\SchoolCalendar\SchoolCalendarMeetingService;
use App\Services\Settings\SchoolCalendar\SchoolCalendarMutationService;
use App\Services\Settings\SchoolCalendar\SchoolCalendarQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class SchoolCalendarController extends BaseApiController
{
public function __construct(
private SchoolCalendarContextService $contextService,
private SchoolCalendarQueryService $queryService,
private SchoolCalendarMeetingService $meetingService,
private SchoolCalendarFormatterService $formatterService,
private SchoolCalendarMutationService $mutationService
) {
parent::__construct();
}
public function options(SchoolCalendarOptionsRequest $request): JsonResponse
{
return $this->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']),
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Settings;
use App\Http\Requests\ApiFormRequest;
use Illuminate\Validation\Rule;
class SchoolCalendarIndexRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->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'])],
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Settings;
use App\Http\Requests\ApiFormRequest;
class SchoolCalendarOptionsRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Settings;
use App\Http\Requests\ApiFormRequest;
use App\Services\Settings\SchoolCalendar\SchoolCalendarContextService;
use Illuminate\Validation\Rule;
class SchoolCalendarStoreRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->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'],
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Settings;
use App\Http\Requests\ApiFormRequest;
use App\Services\Settings\SchoolCalendar\SchoolCalendarContextService;
use Illuminate\Validation\Rule;
class SchoolCalendarUpdateRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->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'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources\Settings;
use Illuminate\Http\Resources\Json\JsonResource;
class SchoolCalendarEventDetailResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (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,
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources\Settings;
use Illuminate\Http\Resources\Json\JsonResource;
class SchoolCalendarEventResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this['id'] ?? null,
'title' => (string) ($this['title'] ?? ''),
'start' => (string) ($this['start'] ?? ''),
'description' => (string) ($this['description'] ?? ''),
'extendedProps' => (array) ($this['extendedProps'] ?? []),
];
}
}
+2
View File
@@ -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;
@@ -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();
}
}
-658
View File
@@ -1,658 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\CalendarModel;
use App\Models\ConfigurationModel;
use App\Models\TeacherModel;
use App\Models\UserModel;
use App\Models\ParentMeetingScheduleModel;
use App\Services\EmailService;
class SchoolCalendarController extends BaseController
{
protected $calendarModel;
protected $configModel;
protected $semester;
protected $schoolYear;
protected $db;
protected $eventTypes = [
'1st Semester Scores',
'2nd Semester Scores',
'Break',
'Event',
'Final',
'Final Exam Draft',
'Midterm',
'Midterm Exam Draft',
];
protected EmailService $emailService;
protected UserModel $userModel;
protected TeacherModel $teacherModel;
protected ParentMeetingScheduleModel $meetingModel;
public function __construct()
{
// Load the models
$this->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 [];
}
}
}
-92
View File
@@ -1,92 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use Config\SessionTimeout;
use CodeIgniter\Controller;
use CodeIgniter\API\ResponseTrait;
class SessionTimeoutController extends BaseController
{
use ResponseTrait;
public function getTimeoutConfig()
{
return $this->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);
}
}
+8
View File
@@ -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']);
@@ -0,0 +1,230 @@
<?php
namespace Tests\Feature\Api\V1\Settings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class SchoolCalendarControllerTest extends TestCase
{
use RefreshDatabase;
public function test_options_returns_event_types_and_defaults(): void
{
$this->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(),
]);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Tests\Unit\Resources\Settings;
use App\Http\Resources\Settings\SchoolCalendarEventResource;
use Tests\TestCase;
class SchoolCalendarEventResourceTest extends TestCase
{
public function test_resource_returns_expected_shape(): void
{
$resource = new SchoolCalendarEventResource([
'id' => 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);
}
}
@@ -0,0 +1,18 @@
<?php
namespace Tests\Unit\Services\Settings\SchoolCalendar;
use App\Services\Settings\SchoolCalendar\SchoolCalendarContextService;
use Tests\TestCase;
class SchoolCalendarContextServiceTest extends TestCase
{
public function test_event_types_returns_list(): void
{
$service = new SchoolCalendarContextService();
$types = $service->eventTypes();
$this->assertNotEmpty($types);
$this->assertContains('Event', $types);
}
}
@@ -0,0 +1,26 @@
<?php
namespace Tests\Unit\Services\Settings\SchoolCalendar;
use App\Services\Settings\SchoolCalendar\SchoolCalendarFormatterService;
use Tests\TestCase;
class SchoolCalendarFormatterServiceTest extends TestCase
{
public function test_format_event_sets_background_for_no_school(): void
{
$service = new SchoolCalendarFormatterService();
$formatted = $service->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']);
}
}
@@ -0,0 +1,57 @@
<?php
namespace Tests\Unit\Services\Settings\SchoolCalendar;
use App\Services\Settings\SchoolCalendar\SchoolCalendarMeetingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class SchoolCalendarMeetingServiceTest extends TestCase
{
use RefreshDatabase;
public function test_list_meetings_filters_for_parent(): void
{
DB::table('parent_meeting_schedules')->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']);
}
}
@@ -0,0 +1,62 @@
<?php
namespace Tests\Unit\Services\Settings\SchoolCalendar;
use App\Models\CalendarEvent;
use App\Services\Settings\SchoolCalendar\SchoolCalendarContextService;
use App\Services\Settings\SchoolCalendar\SchoolCalendarMutationService;
use App\Services\Settings\SchoolCalendar\SchoolCalendarNotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class SchoolCalendarMutationServiceTest extends TestCase
{
use RefreshDatabase;
public function test_create_persists_event(): void
{
$context = new SchoolCalendarContextService();
$notifier = Mockery::mock(SchoolCalendarNotificationService::class);
$notifier->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',
]);
}
}
@@ -0,0 +1,21 @@
<?php
namespace Tests\Unit\Services\Settings\SchoolCalendar;
use App\Services\EmailService;
use App\Services\Settings\SchoolCalendar\SchoolCalendarNotificationService;
use Mockery;
use Tests\TestCase;
class SchoolCalendarNotificationServiceTest extends TestCase
{
public function test_notify_returns_empty_when_no_targets(): void
{
$emailService = Mockery::mock(EmailService::class);
$service = new SchoolCalendarNotificationService($emailService);
$result = $service->notify(['title' => 'Event'], []);
$this->assertSame([], $result);
}
}
@@ -0,0 +1,94 @@
<?php
namespace Tests\Unit\Services\Settings\SchoolCalendar;
use App\Services\Settings\SchoolCalendar\SchoolCalendarQueryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class SchoolCalendarQueryServiceTest extends TestCase
{
use RefreshDatabase;
public function test_list_events_filters_by_school_year(): void
{
DB::table('calendar_events')->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);
}
}