add school calendar logic
This commit is contained in:
@@ -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'] ?? []),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user