add all controllers logic
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Attendance;
|
||||
|
||||
use App\Models\LateSlipLog;
|
||||
|
||||
class LateSlipLogCommandService
|
||||
{
|
||||
public function delete(LateSlipLog $log): bool
|
||||
{
|
||||
return (bool) $log->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Attendance;
|
||||
|
||||
use App\Models\LateSlipLog;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class LateSlipLogQueryService
|
||||
{
|
||||
public function paginate(array $filters, int $page = 1, int $perPage = 50): LengthAwarePaginator
|
||||
{
|
||||
$query = LateSlipLog::query();
|
||||
|
||||
if (!empty($filters['school_year'])) {
|
||||
$query->where('school_year', (string) $filters['school_year']);
|
||||
}
|
||||
|
||||
if (!empty($filters['semester'])) {
|
||||
$query->where('semester', (string) $filters['semester']);
|
||||
}
|
||||
|
||||
if (!empty($filters['q'])) {
|
||||
$query->where('student_name', 'like', '%' . $filters['q'] . '%');
|
||||
}
|
||||
|
||||
$dateFrom = $this->toDbDate($filters['date_from'] ?? null);
|
||||
if ($dateFrom) {
|
||||
$query->where('slip_date', '>=', $dateFrom);
|
||||
}
|
||||
|
||||
$dateTo = $this->toDbDate($filters['date_to'] ?? null);
|
||||
if ($dateTo) {
|
||||
$query->where('slip_date', '<=', $dateTo);
|
||||
}
|
||||
|
||||
$sortBy = (string) ($filters['sort_by'] ?? 'id');
|
||||
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
$allowedSorts = ['id', 'slip_date', 'student_name', 'printed_at', 'updated_at'];
|
||||
|
||||
if (in_array($sortBy, $allowedSorts, true)) {
|
||||
$query->orderBy($sortBy, $sortDir);
|
||||
} else {
|
||||
$query->orderBy('id', 'desc');
|
||||
}
|
||||
|
||||
return $query->paginate($perPage, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
public function find(int $id): ?LateSlipLog
|
||||
{
|
||||
return LateSlipLog::query()->find($id);
|
||||
}
|
||||
|
||||
private function toDbDate(?string $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$ts = strtotime($value);
|
||||
return $ts ? date('Y-m-d', $ts) : null;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,14 @@ namespace App\Services\Auth;
|
||||
|
||||
class RegistrationCaptchaService
|
||||
{
|
||||
private function cacheKey(): string
|
||||
{
|
||||
$ip = (string) (request()?->ip() ?? 'unknown');
|
||||
$ua = (string) (request()?->header('User-Agent') ?? '');
|
||||
$hash = substr(sha1($ua), 0, 12);
|
||||
return 'captcha:' . $ip . ':' . $hash;
|
||||
}
|
||||
|
||||
public function generate(?int $length = null): string
|
||||
{
|
||||
$length = $length ?? random_int(4, 8);
|
||||
@@ -14,14 +22,14 @@ class RegistrationCaptchaService
|
||||
$captchaText .= $characters[random_int(0, strlen($characters) - 1)];
|
||||
}
|
||||
|
||||
session()->put('captcha_answer', $captchaText);
|
||||
cache()->put($this->cacheKey(), $captchaText, now()->addMinutes(10));
|
||||
|
||||
return $captchaText;
|
||||
}
|
||||
|
||||
public function verify(string $input): bool
|
||||
{
|
||||
$expected = (string) session()->get('captcha_answer', '');
|
||||
$expected = (string) cache()->get($this->cacheKey(), '');
|
||||
if ($expected === '') {
|
||||
return false;
|
||||
}
|
||||
@@ -31,6 +39,6 @@ class RegistrationCaptchaService
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
session()->forget('captcha_answer');
|
||||
cache()->forget($this->cacheKey());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use Illuminate\Session\Store;
|
||||
|
||||
class SessionActivityService
|
||||
{
|
||||
public function __construct(private Store $session)
|
||||
{
|
||||
}
|
||||
|
||||
public function hasLastActivity(): bool
|
||||
{
|
||||
return $this->session->has('last_activity');
|
||||
}
|
||||
|
||||
public function getLastActivity(): ?int
|
||||
{
|
||||
$value = $this->session->get('last_activity');
|
||||
return $value !== null ? (int) $value : null;
|
||||
}
|
||||
|
||||
public function setLastActivity(int $timestamp): void
|
||||
{
|
||||
$this->session->put('last_activity', $timestamp);
|
||||
}
|
||||
|
||||
public function clearLastActivity(): void
|
||||
{
|
||||
$this->session->forget('last_activity');
|
||||
}
|
||||
|
||||
public function invalidate(): void
|
||||
{
|
||||
$this->session->invalidate();
|
||||
$this->session->regenerateToken();
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Config\SessionTimeout;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class SessionTimeoutConfigService
|
||||
{
|
||||
public function config(): array
|
||||
{
|
||||
return [
|
||||
'timeout' => SessionTimeout::TIMEOUT_DURATION,
|
||||
'warning_time' => SessionTimeout::WARNING_THRESHOLD,
|
||||
'check_interval' => SessionTimeout::CHECK_INTERVAL,
|
||||
'logout_url' => URL::to('/auth/logout'),
|
||||
'keep_alive_url' => route('session.timeout.ping'),
|
||||
'check_url' => route('session.timeout.check'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Config\SessionTimeout;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class SessionTimeoutService
|
||||
{
|
||||
public function __construct(
|
||||
private SessionActivityService $activityService
|
||||
) {
|
||||
}
|
||||
|
||||
public function checkTimeout(): array
|
||||
{
|
||||
if (!$this->activityService->hasLastActivity()) {
|
||||
return $this->expireSession();
|
||||
}
|
||||
|
||||
$lastActivity = (int) $this->activityService->getLastActivity();
|
||||
$elapsed = time() - $lastActivity;
|
||||
|
||||
if ($elapsed > SessionTimeout::TIMEOUT_DURATION) {
|
||||
return $this->expireSession();
|
||||
}
|
||||
|
||||
if ($elapsed > SessionTimeout::WARNING_THRESHOLD) {
|
||||
return [
|
||||
'status' => 'warning',
|
||||
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'active',
|
||||
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed,
|
||||
];
|
||||
}
|
||||
|
||||
public function pingActivity(): array
|
||||
{
|
||||
if (
|
||||
!$this->activityService->hasLastActivity()
|
||||
|| (time() - (int) $this->activityService->getLastActivity() > SessionTimeout::TIMEOUT_DURATION)
|
||||
) {
|
||||
return $this->expireSession();
|
||||
}
|
||||
|
||||
$this->activityService->setLastActivity(time());
|
||||
|
||||
return [
|
||||
'status' => 'active',
|
||||
'time_remaining' => SessionTimeout::TIMEOUT_DURATION,
|
||||
];
|
||||
}
|
||||
|
||||
private function expireSession(): array
|
||||
{
|
||||
Log::info('Session expired due to inactivity.');
|
||||
$this->activityService->clearLastActivity();
|
||||
$this->activityService->invalidate();
|
||||
|
||||
return [
|
||||
'status' => 'expired',
|
||||
'redirect' => URL::to('/login'),
|
||||
'message' => 'Your session has expired due to inactivity.',
|
||||
'time_remaining' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UserRoleService
|
||||
{
|
||||
public function getRoleIds(int $userId): array
|
||||
{
|
||||
return DB::table('user_roles')
|
||||
->where('user_id', $userId)
|
||||
->whereNull('deleted_at')
|
||||
->pluck('role_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
public function hasPermissionForCrud(array $roleIds, string $permissionName, string $crudAction = 'read'): bool
|
||||
{
|
||||
if (empty($roleIds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rows = DB::table('role_permissions as rp')
|
||||
->join('permissions as p', 'p.id', '=', 'rp.permission_id')
|
||||
->whereIn('rp.role_id', $roleIds)
|
||||
->where('p.name', $permissionName)
|
||||
->select('rp.can_create', 'rp.can_read', 'rp.can_update', 'rp.can_delete', 'rp.can_manage')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$manage = (bool) ($row->can_manage ?? false);
|
||||
if ($manage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($crudAction === 'create' && !empty($row->can_create)) {
|
||||
return true;
|
||||
}
|
||||
if ($crudAction === 'read' && !empty($row->can_read)) {
|
||||
return true;
|
||||
}
|
||||
if ($crudAction === 'update' && !empty($row->can_update)) {
|
||||
return true;
|
||||
}
|
||||
if ($crudAction === 'delete' && !empty($row->can_delete)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassPreparation;
|
||||
|
||||
use App\Models\ClassPrepAdjustment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ClassPreparationAdjustmentWriterService
|
||||
{
|
||||
public function saveAdjustments(string $classSectionId, string $schoolYear, array $adjustments, array $allowedItems): int
|
||||
{
|
||||
$allowed = array_fill_keys($allowedItems, true);
|
||||
|
||||
return DB::transaction(function () use ($classSectionId, $schoolYear, $adjustments, $allowed) {
|
||||
$count = 0;
|
||||
|
||||
foreach ($adjustments as $itemName => $adjustment) {
|
||||
$item = (string) $itemName;
|
||||
if ($item === '' || !isset($allowed[$item])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = ClassPrepAdjustment::query()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('item_name', $item)
|
||||
->first();
|
||||
|
||||
if ($row) {
|
||||
$row->update([
|
||||
'adjustment' => (int) $adjustment,
|
||||
'adjustable' => 1,
|
||||
]);
|
||||
$count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
ClassPrepAdjustment::query()->create([
|
||||
'class_section_id' => $classSectionId,
|
||||
'item_name' => $item,
|
||||
'adjustment' => (int) $adjustment,
|
||||
'school_year' => $schoolYear,
|
||||
'adjustable' => 1,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassPreparation;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ClassPreparationPrintService
|
||||
{
|
||||
public function __construct(
|
||||
private ClassPreparationContextService $context,
|
||||
private ClassPreparationRosterService $roster,
|
||||
private ClassPreparationCalculatorService $calculator,
|
||||
private ClassPreparationAdjustmentService $adjustments,
|
||||
private ClassPreparationLogService $logs
|
||||
) {
|
||||
}
|
||||
|
||||
public function printPrep(string $classSectionId, string $schoolYear, ?string $semester = null): array
|
||||
{
|
||||
$semester = $semester ?: $this->context->getSemester();
|
||||
$limitToSemester = $this->context->hasRosterForSemester($schoolYear, $semester);
|
||||
|
||||
$studentCount = $this->roster->getStudentCountForSection($schoolYear, $semester, $limitToSemester, $classSectionId);
|
||||
$classLevel = $this->calculator->getClassLevelBySection($classSectionId);
|
||||
$className = ClassSection::getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
||||
|
||||
$items = $this->calculator->calculatePrepItems($studentCount, $classLevel, $classSectionId);
|
||||
[$items, $adjMap] = $this->adjustments->applyAdjustments($items, $classSectionId, $schoolYear);
|
||||
|
||||
$printedAt = $this->utcNow();
|
||||
$ok = $this->logs->createLog($classSectionId, $className, $schoolYear, $items, $printedAt);
|
||||
|
||||
if (!$ok) {
|
||||
Log::warning('Failed to store class preparation log.', [
|
||||
'class_section_id' => $classSectionId,
|
||||
'school_year' => $schoolYear,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $ok,
|
||||
'class_section_id' => $classSectionId,
|
||||
'class_section' => $className,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'prep_items' => $items,
|
||||
'adjustments' => $adjMap,
|
||||
'printed_at' => $ok ? $printedAt : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function utcNow(): string
|
||||
{
|
||||
if (function_exists('utc_now')) {
|
||||
return (string) utc_now();
|
||||
}
|
||||
return now('UTC')->toDateTimeString();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ class ClassPreparationService
|
||||
private ClassPreparationCalculatorService $calculator;
|
||||
private ClassPreparationAdjustmentService $adjustments;
|
||||
private ClassPreparationLogService $logs;
|
||||
private ClassPreparationAdjustmentWriterService $adjustmentWriter;
|
||||
private ClassPreparationPrintService $printService;
|
||||
|
||||
public function __construct(
|
||||
ClassPreparationContextService $context,
|
||||
@@ -19,7 +21,9 @@ class ClassPreparationService
|
||||
ClassPreparationInventoryService $inventory,
|
||||
ClassPreparationCalculatorService $calculator,
|
||||
ClassPreparationAdjustmentService $adjustments,
|
||||
ClassPreparationLogService $logs
|
||||
ClassPreparationLogService $logs,
|
||||
ClassPreparationAdjustmentWriterService $adjustmentWriter,
|
||||
ClassPreparationPrintService $printService
|
||||
) {
|
||||
$this->context = $context;
|
||||
$this->roster = $roster;
|
||||
@@ -27,6 +31,8 @@ class ClassPreparationService
|
||||
$this->calculator = $calculator;
|
||||
$this->adjustments = $adjustments;
|
||||
$this->logs = $logs;
|
||||
$this->adjustmentWriter = $adjustmentWriter;
|
||||
$this->printService = $printService;
|
||||
}
|
||||
|
||||
public function listPrep(?string $schoolYear = null, ?string $semester = null): array
|
||||
@@ -111,6 +117,23 @@ class ClassPreparationService
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function saveAdjustments(string $classSectionId, string $schoolYear, array $adjustments): int
|
||||
{
|
||||
$schoolYear = $schoolYear !== '' ? $schoolYear : $this->context->getSchoolYear();
|
||||
|
||||
return $this->adjustmentWriter->saveAdjustments(
|
||||
$classSectionId,
|
||||
$schoolYear,
|
||||
$adjustments,
|
||||
$this->calculator->getAllowedCategories()
|
||||
);
|
||||
}
|
||||
|
||||
public function printPrep(string $classSectionId, string $schoolYear, ?string $semester = null): array
|
||||
{
|
||||
return $this->printService->printPrep($classSectionId, $schoolYear, $semester);
|
||||
}
|
||||
|
||||
private function utcNow(): string
|
||||
{
|
||||
if (function_exists('utc_now')) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassSections;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Student;
|
||||
use App\Models\User;
|
||||
|
||||
class ClassAttendanceService
|
||||
{
|
||||
public function getAttendancePayload(int $classSectionId, int $teacherId, ?string $schoolYear = null, ?string $semester = null): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?: (string) (Configuration::getConfig('school_year') ?? '');
|
||||
$semester = $semester ?: (string) (Configuration::getConfig('semester') ?? '');
|
||||
|
||||
$teacher = User::query()->find($teacherId);
|
||||
$teacherName = trim((string) ($teacher?->firstname ?? '') . ' ' . (string) ($teacher?->lastname ?? ''));
|
||||
|
||||
$className = ClassSection::getClassSectionNameBySectionId($classSectionId) ?? (string) $classSectionId;
|
||||
$students = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, $teacherId);
|
||||
|
||||
return [
|
||||
'teacher_name' => $teacherName,
|
||||
'class_name' => $className,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'students' => $students,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassSections;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
|
||||
class ClassSectionCommandService
|
||||
{
|
||||
public function create(array $data): ClassSection
|
||||
{
|
||||
return ClassSection::query()->create($data);
|
||||
}
|
||||
|
||||
public function update(ClassSection $section, array $data): ClassSection
|
||||
{
|
||||
$section->fill($data);
|
||||
$section->save();
|
||||
|
||||
return $section->refresh();
|
||||
}
|
||||
|
||||
public function delete(ClassSection $section): bool
|
||||
{
|
||||
return (bool) $section->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassSections;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class ClassSectionQueryService
|
||||
{
|
||||
public function list(array $filters, int $page = 1, int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
$query = ClassSection::query()
|
||||
->leftJoin('classes', 'classSection.class_id', '=', 'classes.id')
|
||||
->select('classSection.*', 'classes.class_name');
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
$term = '%' . strtolower((string) $filters['search']) . '%';
|
||||
$query->where(function ($q) use ($term) {
|
||||
$q->whereRaw('LOWER(classSection.class_section_name) LIKE ?', [$term])
|
||||
->orWhereRaw('LOWER(classes.class_name) LIKE ?', [$term]);
|
||||
});
|
||||
}
|
||||
|
||||
if (!empty($filters['class_id'])) {
|
||||
$query->where('classSection.class_id', (int) $filters['class_id']);
|
||||
}
|
||||
|
||||
if (!empty($filters['school_year'])) {
|
||||
$query->where('classSection.school_year', (string) $filters['school_year']);
|
||||
}
|
||||
|
||||
if (!empty($filters['semester'])) {
|
||||
$query->where('classSection.semester', (string) $filters['semester']);
|
||||
}
|
||||
|
||||
$sortBy = (string) ($filters['sort_by'] ?? 'class_section_name');
|
||||
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$allowedSorts = [
|
||||
'class_section_name' => 'classSection.class_section_name',
|
||||
'class_section_id' => 'classSection.class_section_id',
|
||||
'class_id' => 'classSection.class_id',
|
||||
'school_year' => 'classSection.school_year',
|
||||
'semester' => 'classSection.semester',
|
||||
'class_name' => 'classes.class_name',
|
||||
];
|
||||
|
||||
$sortColumn = $allowedSorts[$sortBy] ?? $allowedSorts['class_section_name'];
|
||||
$query->orderBy($sortColumn, $sortDir);
|
||||
|
||||
return $query->paginate($perPage, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
public function find(int $id): ?ClassSection
|
||||
{
|
||||
return ClassSection::query()
|
||||
->leftJoin('classes', 'classSection.class_id', '=', 'classes.id')
|
||||
->select('classSection.*', 'classes.class_name')
|
||||
->where('classSection.id', $id)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ClassSections;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\SchoolClass;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ClassSectionSeedService
|
||||
{
|
||||
public function seedDefaults(): int
|
||||
{
|
||||
$semester = (string) (Configuration::getConfig('semester') ?? '');
|
||||
$schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
|
||||
|
||||
$classes = [
|
||||
['class_name' => 'Class 1', 'schedule' => 'Monday 9:00 AM - 10:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 2', 'schedule' => 'Tuesday 9:00 AM - 10:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 3', 'schedule' => 'Wednesday 9:00 AM - 10:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 4', 'schedule' => 'Thursday 9:00 AM - 10:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 5', 'schedule' => 'Friday 9:00 AM - 10:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 6', 'schedule' => 'Monday 10:00 AM - 11:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 7', 'schedule' => 'Tuesday 10:00 AM - 11:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 8', 'schedule' => 'Wednesday 10:00 AM - 11:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 9', 'schedule' => 'Thursday 10:00 AM - 11:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Youth', 'schedule' => 'Friday 10:00 AM - 11:00 AM', 'capacity' => 30],
|
||||
];
|
||||
|
||||
return DB::transaction(function () use ($classes, $semester, $schoolYear) {
|
||||
$created = 0;
|
||||
foreach ($classes as $class) {
|
||||
$row = SchoolClass::query()->firstOrCreate(
|
||||
[
|
||||
'class_name' => $class['class_name'],
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
],
|
||||
[
|
||||
'schedule' => $class['schedule'],
|
||||
'capacity' => $class['capacity'],
|
||||
]
|
||||
);
|
||||
|
||||
if ($row->wasRecentlyCreated) {
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
return $created;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Dashboard;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DashboardRouteService
|
||||
{
|
||||
private const FALLBACK_ROUTE = '/landing_page/guest_dashboard';
|
||||
|
||||
public function resolveForUser(?User $user): array
|
||||
{
|
||||
if (!$user) {
|
||||
Log::warning('Dashboard route requested without an authenticated user.');
|
||||
return [
|
||||
'route' => self::FALLBACK_ROUTE,
|
||||
'role' => null,
|
||||
'role_slug' => null,
|
||||
'roles' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$roles = $user->roles()
|
||||
->pluck('roles.name')
|
||||
->map(fn ($name) => strtolower((string) $name))
|
||||
->toArray();
|
||||
|
||||
if (empty($roles)) {
|
||||
Log::warning('Dashboard route requested with no roles assigned.', [
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'route' => self::FALLBACK_ROUTE,
|
||||
'role' => null,
|
||||
'role_slug' => null,
|
||||
'roles' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$rows = Role::findByNamesOrSlugs($roles);
|
||||
|
||||
if (!empty($rows)) {
|
||||
$role = $rows[0];
|
||||
$route = $role->dashboard_route ?: self::FALLBACK_ROUTE;
|
||||
|
||||
return [
|
||||
'route' => $route,
|
||||
'role' => $role->name ?? null,
|
||||
'role_slug' => $role->slug ?? null,
|
||||
'roles' => $roles,
|
||||
];
|
||||
}
|
||||
|
||||
Log::warning('No matching role dashboard route found.', [
|
||||
'user_id' => $user->id,
|
||||
'roles' => $roles,
|
||||
]);
|
||||
|
||||
return [
|
||||
'route' => self::FALLBACK_ROUTE,
|
||||
'role' => null,
|
||||
'role_slug' => null,
|
||||
'roles' => $roles,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ class OpenApiRouteExporter
|
||||
|
||||
private function mergeRoute(array &$paths, Route $route): void
|
||||
{
|
||||
if (!$this->isApiControllerRoute($route)) {
|
||||
if (!$this->isDocumentableRoute($route)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,14 +70,27 @@ class OpenApiRouteExporter
|
||||
}
|
||||
}
|
||||
|
||||
private function isApiControllerRoute(Route $route): bool
|
||||
private function isDocumentableRoute(Route $route): bool
|
||||
{
|
||||
$uri = ltrim($route->uri(), '/');
|
||||
if (!str_starts_with($uri, 'api/v1') && !str_starts_with($uri, 'v1/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$action = $route->getActionName();
|
||||
if (!is_string($action)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_starts_with($action, 'App\\Http\\Controllers\\Api\\');
|
||||
if (str_contains($action, 'DocsController')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($action === 'Closure') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function resolveTag(string $uri): string
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontend;
|
||||
|
||||
use App\Models\ContactUs;
|
||||
use App\Models\Configuration;
|
||||
use App\Services\Email\EmailDispatchService;
|
||||
use App\Services\System\GlobalConfigService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ContactSubmissionService
|
||||
{
|
||||
public function __construct(
|
||||
private EmailDispatchService $emailService,
|
||||
private GlobalConfigService $configService
|
||||
) {
|
||||
}
|
||||
|
||||
public function submit(array $payload): array
|
||||
{
|
||||
$email = strtolower((string) $payload['email']);
|
||||
$message = (string) $payload['message'];
|
||||
$subject = (string) ($payload['subject'] ?? 'Contact Us Form Submission');
|
||||
|
||||
$schoolYear = $payload['school_year'] ?? $this->configService->getSchoolYear() ?? '';
|
||||
$semester = $payload['semester'] ?? $this->configService->getSemester() ?? '';
|
||||
|
||||
$record = null;
|
||||
try {
|
||||
$record = ContactUs::query()->create([
|
||||
'sender_id' => (int) ($payload['sender_id'] ?? 0),
|
||||
'reciever_id' => (int) ($payload['receiver_id'] ?? 0),
|
||||
'subject' => $subject,
|
||||
'message' => $message,
|
||||
'semester' => $semester ?: 'Fall',
|
||||
'school_year' => $schoolYear ?: '2025-2026',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Contact submission save failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$recipient = Configuration::getConfig('administrator_email')
|
||||
?: (string) config('mail.from.address', '')
|
||||
?: 'alrahma.isgl@gmail.com';
|
||||
|
||||
$htmlMessage = nl2br('You have received a new message from ' . $email . ":\n\n" . $message);
|
||||
|
||||
$emailSent = false;
|
||||
if (!app()->runningUnitTests()) {
|
||||
$emailSent = $this->emailService->send($recipient, $subject, $htmlMessage, null, $email, $email);
|
||||
if (!$emailSent) {
|
||||
Log::error('Contact submission email failed for ' . $email);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'record' => $record,
|
||||
'email_sent' => $emailSent,
|
||||
'recipient' => $recipient,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontend;
|
||||
|
||||
class FrontendPageService
|
||||
{
|
||||
public function page(string $name): array
|
||||
{
|
||||
return [
|
||||
'page' => $name,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontend;
|
||||
|
||||
use App\Models\Configuration;
|
||||
|
||||
class LandingPageContextService
|
||||
{
|
||||
public function context(): array
|
||||
{
|
||||
return [
|
||||
'school_year' => (string) (Configuration::getConfig('school_year') ?? ''),
|
||||
'semester' => (string) (Configuration::getConfig('semester') ?? ''),
|
||||
'last_day_of_registration' => (string) (Configuration::getConfig('enrollment_deadline') ?? 'Not set'),
|
||||
'refund_deadline' => (string) (Configuration::getConfig('refund_deadline') ?? 'Not set'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontend;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Models\StudentClass;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LandingPageParentDashboardService
|
||||
{
|
||||
public function __construct(private LandingPageContextService $context)
|
||||
{
|
||||
}
|
||||
|
||||
public function summary(int $parentUserId, string $userType): array
|
||||
{
|
||||
$context = $this->context->context();
|
||||
$schoolYear = (string) ($context['school_year'] ?? '');
|
||||
$semester = (string) ($context['semester'] ?? '');
|
||||
|
||||
$parentId = $this->resolveParentId($parentUserId, $userType);
|
||||
if (!$parentId) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Unable to retrieve student data. Please contact support.',
|
||||
];
|
||||
}
|
||||
|
||||
$notifications = $this->notificationsForParent($parentId);
|
||||
|
||||
$students = DB::table('students')
|
||||
->where('parent_id', $parentId)
|
||||
->get()
|
||||
->map(function ($row) use ($schoolYear) {
|
||||
$student = (array) $row;
|
||||
$classSection = StudentClass::getClassSectionsByStudentId($student['id'], $schoolYear);
|
||||
$student['class_section'] = $classSection;
|
||||
$student['grade'] = $classSection;
|
||||
return $student;
|
||||
})
|
||||
->all();
|
||||
|
||||
$attendance = DB::table('attendance_data')
|
||||
->select('attendance_data.*', 'students.firstname', 'students.lastname')
|
||||
->join('students', 'students.id', '=', 'attendance_data.student_id')
|
||||
->where('students.parent_id', $parentId)
|
||||
->where('attendance_data.school_year', $schoolYear)
|
||||
->where('attendance_data.semester', $semester)
|
||||
->orderBy('attendance_data.date', 'DESC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$grades = DB::table('final_score')
|
||||
->select('final_score.*', 'students.firstname', 'students.lastname')
|
||||
->join('students', 'students.id', '=', 'final_score.student_id')
|
||||
->where('students.parent_id', $parentId)
|
||||
->where('final_score.school_year', $schoolYear)
|
||||
->where('final_score.semester', $semester)
|
||||
->orderBy('final_score.created_at', 'DESC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$enrollments = DB::table('enrollments')
|
||||
->select('enrollments.*', 'classes.class_name')
|
||||
->join('students', 'students.id', '=', 'enrollments.student_id')
|
||||
->join('classes', 'classes.id', '=', 'enrollments.class_section_id')
|
||||
->where('students.parent_id', $parentId)
|
||||
->where('enrollments.school_year', $schoolYear)
|
||||
->where('enrollments.semester', $semester)
|
||||
->orderBy('enrollments.enrollment_date', 'DESC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$paymentBalance = Invoice::getLatestInvoiceTotalAmount($parentId);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'notifications' => $notifications,
|
||||
'students' => $students,
|
||||
'attendance' => $attendance,
|
||||
'grades' => $grades,
|
||||
'enrollments' => $enrollments,
|
||||
'last_day_of_registration' => $context['last_day_of_registration'] ?? 'Not set',
|
||||
'withdrawal_deadline' => $context['refund_deadline'] ?? 'Not set',
|
||||
'payment_balance' => $paymentBalance,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveParentId(int $userId, string $userType): ?int
|
||||
{
|
||||
if ($userType === 'primary') {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
if ($userType === 'secondary') {
|
||||
$row = DB::table('parents')
|
||||
->select('parent_id')
|
||||
->where('secondparent_user_id', $userId)
|
||||
->first();
|
||||
return $row ? (int) $row->parent_id : null;
|
||||
}
|
||||
|
||||
if ($userType === 'tertiary') {
|
||||
$row = DB::table('authorized_users')
|
||||
->select('user_id as parent_id')
|
||||
->where('authorized_user_id', $userId)
|
||||
->first();
|
||||
return $row ? (int) $row->parent_id : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function notificationsForParent(int $parentId): array
|
||||
{
|
||||
return DB::table('notifications')
|
||||
->select([
|
||||
'notifications.id',
|
||||
'notifications.title',
|
||||
'notifications.message',
|
||||
'notifications.target_group',
|
||||
'notifications.created_at',
|
||||
'notifications.expires_at',
|
||||
'user_notifications.user_id',
|
||||
DB::raw("CASE WHEN user_notifications.user_id IS NOT NULL THEN 'personal' ELSE 'broadcast' END as notification_type"),
|
||||
])
|
||||
->leftJoin('user_notifications', function ($join) use ($parentId) {
|
||||
$join->on('user_notifications.notification_id', '=', 'notifications.id')
|
||||
->where('user_notifications.user_id', '=', $parentId);
|
||||
})
|
||||
->where(function ($q) use ($parentId) {
|
||||
$q->where('notifications.target_group', 'parent')
|
||||
->orWhere('user_notifications.user_id', $parentId);
|
||||
})
|
||||
->whereNull('notifications.deleted_at')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('notifications.expires_at')
|
||||
->orWhere('notifications.expires_at', '>', now());
|
||||
})
|
||||
->orderBy('notifications.created_at', 'DESC')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontend;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LandingPageRoleService
|
||||
{
|
||||
public function resolveRole(?User $user): string
|
||||
{
|
||||
if (!$user) {
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
$roles = DB::table('user_roles as ur')
|
||||
->join('roles as r', 'r.id', '=', 'ur.role_id')
|
||||
->where('ur.user_id', $user->id)
|
||||
->whereNull('ur.deleted_at')
|
||||
->orderBy('r.priority', 'asc')
|
||||
->pluck('r.name')
|
||||
->map(fn ($r) => strtolower((string) $r))
|
||||
->all();
|
||||
|
||||
if (!empty($roles)) {
|
||||
return $roles[0];
|
||||
}
|
||||
|
||||
return 'guest';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontend;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class LandingPageService
|
||||
{
|
||||
public function __construct(
|
||||
private LandingPageRoleService $roleService,
|
||||
private LandingPageContextService $contextService,
|
||||
private LandingPageTeacherSummaryService $teacherSummaryService,
|
||||
private LandingPageParentDashboardService $parentDashboardService
|
||||
) {
|
||||
}
|
||||
|
||||
public function dashboardForUser(?User $user, ?int $classSectionId = null): array
|
||||
{
|
||||
$role = $this->roleService->resolveRole($user);
|
||||
|
||||
return match ($role) {
|
||||
'administrator' => $this->administrator(),
|
||||
'admin' => $this->admin(),
|
||||
'teacher', 'teacher_assistant' => $this->teacher($user, $classSectionId),
|
||||
'student' => $this->student(),
|
||||
'parent', 'authorized_user' => $this->parent($user),
|
||||
default => $this->guest(),
|
||||
};
|
||||
}
|
||||
|
||||
public function administrator(): array
|
||||
{
|
||||
return [
|
||||
'role' => 'administrator',
|
||||
'dashboard' => 'administrator',
|
||||
];
|
||||
}
|
||||
|
||||
public function admin(): array
|
||||
{
|
||||
return [
|
||||
'role' => 'admin',
|
||||
'dashboard' => 'admin',
|
||||
];
|
||||
}
|
||||
|
||||
public function teacher(?User $user, ?int $classSectionId = null): array
|
||||
{
|
||||
if (!$user) {
|
||||
return ['role' => 'teacher', 'dashboard' => 'teacher', 'summary' => []];
|
||||
}
|
||||
|
||||
return [
|
||||
'role' => 'teacher',
|
||||
'dashboard' => 'teacher',
|
||||
'summary' => $this->teacherSummaryService->summary($user->id, $classSectionId),
|
||||
];
|
||||
}
|
||||
|
||||
public function student(): array
|
||||
{
|
||||
return [
|
||||
'role' => 'student',
|
||||
'dashboard' => 'student',
|
||||
];
|
||||
}
|
||||
|
||||
public function parent(?User $user): array
|
||||
{
|
||||
if (!$user) {
|
||||
return ['role' => 'parent', 'dashboard' => 'parent', 'summary' => []];
|
||||
}
|
||||
|
||||
$userType = (string) ($user->user_type ?? 'primary');
|
||||
|
||||
return [
|
||||
'role' => 'parent',
|
||||
'dashboard' => 'parent',
|
||||
'summary' => $this->parentDashboardService->summary($user->id, $userType),
|
||||
];
|
||||
}
|
||||
|
||||
public function guest(): array
|
||||
{
|
||||
return [
|
||||
'role' => 'guest',
|
||||
'dashboard' => 'guest',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontend;
|
||||
|
||||
use App\Models\AttendanceDay;
|
||||
use App\Models\AttendanceRecord;
|
||||
use App\Models\Calendar;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\ScoreComment;
|
||||
use App\Models\SemesterScore;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\TeacherClass;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LandingPageTeacherSummaryService
|
||||
{
|
||||
public function __construct(private LandingPageContextService $context)
|
||||
{
|
||||
}
|
||||
|
||||
public function summary(int $teacherId, ?int $requestedClassSectionId = null): array
|
||||
{
|
||||
$context = $this->context->context();
|
||||
$schoolYear = (string) ($context['school_year'] ?? '');
|
||||
$semester = (string) ($context['semester'] ?? '');
|
||||
|
||||
$assignments = TeacherClass::getClassAssignmentsByUserId($teacherId, $schoolYear, $semester);
|
||||
$availableIds = array_values(array_filter(array_unique(array_map(
|
||||
static fn (array $row) => (int) ($row['class_section_id'] ?? 0),
|
||||
$assignments
|
||||
))));
|
||||
|
||||
$activeId = null;
|
||||
if ($requestedClassSectionId && in_array($requestedClassSectionId, $availableIds, true)) {
|
||||
$activeId = $requestedClassSectionId;
|
||||
} elseif (!empty($availableIds)) {
|
||||
$activeId = $availableIds[0];
|
||||
}
|
||||
|
||||
$activeName = null;
|
||||
foreach ($assignments as $row) {
|
||||
if ((int) ($row['class_section_id'] ?? 0) === (int) $activeId) {
|
||||
$activeName = $row['class_section_name'] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$normalizedSemester = ucfirst(strtolower(trim((string) $semester)));
|
||||
if (!in_array($normalizedSemester, ['Fall', 'Spring'], true)) {
|
||||
$normalizedSemester = 'Fall';
|
||||
}
|
||||
|
||||
$classSectionIds = $availableIds;
|
||||
$dashboardSummary = $this->buildTeacherDashboardSummary($classSectionIds, $normalizedSemester, $schoolYear);
|
||||
|
||||
return [
|
||||
'class_section_id' => $activeId,
|
||||
'active_class_name' => $activeName,
|
||||
'classes' => $assignments,
|
||||
'jobCount' => count($assignments),
|
||||
'scoreSummary' => $dashboardSummary['scoreSummary'],
|
||||
'commentSummary' => $dashboardSummary['commentSummary'],
|
||||
'attendanceSummary' => $dashboardSummary['attendanceSummary'],
|
||||
'deadlineEvents' => $dashboardSummary['deadlineEvents'],
|
||||
'participationSummary' => $dashboardSummary['participationSummary'],
|
||||
'attendanceStatus' => $dashboardSummary['attendanceStatus'],
|
||||
'dashboardNotifications' => $dashboardSummary['notifications'],
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildTeacherDashboardSummary(array $classSectionIds, string $semester, string $schoolYear): array
|
||||
{
|
||||
$scoreField = $semester === 'Spring' ? 'final_exam_score' : 'midterm_exam_score';
|
||||
$scoreLabel = $semester === 'Spring' ? 'Final Exam' : 'Midterm';
|
||||
|
||||
$summary = [
|
||||
'scoreSummary' => [
|
||||
'completionPct' => 0,
|
||||
'missing' => 0,
|
||||
'expected' => 0,
|
||||
'filled' => 0,
|
||||
'fieldLabel' => $scoreLabel,
|
||||
],
|
||||
'commentSummary' => [
|
||||
'total' => 0,
|
||||
'pendingReview' => 0,
|
||||
'reviewed' => 0,
|
||||
'byType' => [],
|
||||
],
|
||||
'attendanceSummary' => [
|
||||
'recorded' => 0,
|
||||
'students' => 0,
|
||||
'completionPct' => 0,
|
||||
'avgAbsences' => 0,
|
||||
],
|
||||
'participationSummary' => [
|
||||
'filled' => 0,
|
||||
'missing' => 0,
|
||||
'completionPct' => 0,
|
||||
'expected' => 0,
|
||||
],
|
||||
'deadlineEvents' => [],
|
||||
'notifications' => [],
|
||||
'attendanceStatus' => [
|
||||
'date' => '',
|
||||
'missingSections' => [],
|
||||
'missingNames' => [],
|
||||
'submitted' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$uniqueStudentIds = [];
|
||||
if (!empty($classSectionIds)) {
|
||||
$studentRows = DB::table('student_class as sc')
|
||||
->select('sc.student_id', 'sc.class_section_id')
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->whereIn('sc.class_section_id', $classSectionIds)
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->get();
|
||||
|
||||
foreach ($studentRows as $row) {
|
||||
$studentId = (int) ($row->student_id ?? 0);
|
||||
if ($studentId > 0) {
|
||||
$uniqueStudentIds[] = $studentId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$uniqueStudentIds = array_values(array_unique($uniqueStudentIds));
|
||||
$totalStudents = count($uniqueStudentIds);
|
||||
$summary['attendanceSummary']['students'] = $totalStudents;
|
||||
|
||||
$scoreRows = [];
|
||||
if (!empty($classSectionIds)) {
|
||||
$scoreRows = SemesterScore::query()
|
||||
->select(['student_id', 'class_section_id', 'midterm_exam_score', 'final_exam_score', 'participation_score'])
|
||||
->whereIn('class_section_id', $classSectionIds)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$scoreTypesConfig = [
|
||||
'participation' => ['label' => 'Participation', 'field' => 'participation_score'],
|
||||
];
|
||||
if ($semester === 'Fall') {
|
||||
$scoreTypesConfig = array_merge([
|
||||
'midterm' => ['label' => 'Midterm', 'field' => 'midterm_exam_score'],
|
||||
], $scoreTypesConfig);
|
||||
}
|
||||
if ($semester === 'Spring') {
|
||||
$scoreTypesConfig = array_merge([
|
||||
'final' => ['label' => 'Final exam', 'field' => 'final_exam_score'],
|
||||
], $scoreTypesConfig);
|
||||
}
|
||||
|
||||
$filledCounts = array_fill_keys(array_keys($scoreTypesConfig), 0);
|
||||
foreach ($scoreRows as $row) {
|
||||
foreach ($scoreTypesConfig as $key => $cfg) {
|
||||
$value = trim((string) ($row[$cfg['field']] ?? ''));
|
||||
if ($value !== '') {
|
||||
$filledCounts[$key]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$expectedScoreCount = $totalStudents > 0 ? $totalStudents : count($scoreRows);
|
||||
$scoreDetails = [];
|
||||
foreach ($scoreTypesConfig as $key => $cfg) {
|
||||
$filled = $filledCounts[$key] ?? 0;
|
||||
$missing = max(0, $expectedScoreCount - $filled);
|
||||
$completionPct = $expectedScoreCount > 0 ? (int) round(min(100, ($filled / $expectedScoreCount) * 100)) : 0;
|
||||
$scoreDetails[$key] = [
|
||||
'label' => $cfg['label'],
|
||||
'field' => $cfg['field'],
|
||||
'filled' => $filled,
|
||||
'missing' => $missing,
|
||||
'expected' => $expectedScoreCount,
|
||||
'completionPct' => $completionPct,
|
||||
];
|
||||
}
|
||||
|
||||
$scoreTypeByField = [];
|
||||
if ($semester === 'Fall') {
|
||||
$scoreTypeByField['midterm_exam_score'] = 'midterm';
|
||||
}
|
||||
if ($semester === 'Spring') {
|
||||
$scoreTypeByField['final_exam_score'] = 'final';
|
||||
}
|
||||
$activeScoreType = $scoreTypeByField[$scoreField] ?? array_key_first($scoreDetails);
|
||||
$activeScoreDetails = $scoreDetails[$activeScoreType] ?? array_values($scoreDetails)[0];
|
||||
|
||||
$summary['scoreSummary'] = [
|
||||
'completionPct' => $activeScoreDetails['completionPct'],
|
||||
'missing' => $activeScoreDetails['missing'],
|
||||
'expected' => $activeScoreDetails['expected'],
|
||||
'filled' => $activeScoreDetails['filled'],
|
||||
'fieldLabel' => $scoreLabel,
|
||||
'types' => $scoreDetails,
|
||||
];
|
||||
|
||||
$participationMetrics = $scoreDetails['participation'];
|
||||
$summary['participationSummary'] = [
|
||||
'filled' => $participationMetrics['filled'],
|
||||
'missing' => $participationMetrics['missing'],
|
||||
'completionPct' => $participationMetrics['completionPct'],
|
||||
'expected' => $participationMetrics['expected'],
|
||||
];
|
||||
|
||||
$commentRows = [];
|
||||
if (!empty($uniqueStudentIds)) {
|
||||
$commentRows = ScoreComment::query()
|
||||
->select(['student_id', 'score_type', 'comment', 'comment_review'])
|
||||
->whereIn('student_id', $uniqueStudentIds)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$expectedCommentTypes = array_values(array_filter([
|
||||
'ptap',
|
||||
$semester === 'Fall' ? 'midterm' : null,
|
||||
$semester === 'Spring' ? 'final' : null,
|
||||
]));
|
||||
|
||||
$commentStats = [];
|
||||
$commentsByStudent = [];
|
||||
foreach ($commentRows as $row) {
|
||||
$sid = (int) ($row['student_id'] ?? 0);
|
||||
$type = strtolower(trim((string) ($row['score_type'] ?? '')));
|
||||
if ($type === '') {
|
||||
$type = 'general';
|
||||
}
|
||||
if (!isset($commentStats[$type])) {
|
||||
$commentStats[$type] = ['pending' => 0, 'reviewed' => 0];
|
||||
}
|
||||
|
||||
$commentText = trim((string) ($row['comment'] ?? ''));
|
||||
$reviewValue = trim((string) ($row['comment_review'] ?? ''));
|
||||
|
||||
if ($sid > 0 && $commentText !== '') {
|
||||
$commentsByStudent[$sid][$type] = $commentText;
|
||||
}
|
||||
|
||||
if ($commentText === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($reviewValue === '') {
|
||||
$commentStats[$type]['pending']++;
|
||||
} else {
|
||||
$commentStats[$type]['reviewed']++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($expectedCommentTypes as $type) {
|
||||
if (!isset($commentStats[$type])) {
|
||||
$commentStats[$type] = ['pending' => 0, 'reviewed' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
$missingComments = 0;
|
||||
$missingDetail = [];
|
||||
foreach ($uniqueStudentIds as $sid) {
|
||||
foreach ($expectedCommentTypes as $type) {
|
||||
$text = $commentsByStudent[$sid][$type] ?? '';
|
||||
if ($text === '') {
|
||||
$missingDetail[$type] = ($missingDetail[$type] ?? 0) + 1;
|
||||
$missingComments++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$commentTypes = [];
|
||||
foreach ($expectedCommentTypes as $type) {
|
||||
$stats = $commentStats[$type];
|
||||
$filled = $stats['pending'] + $stats['reviewed'];
|
||||
$completionPct = $totalStudents > 0 ? (int) round(min(100, ($filled / $totalStudents) * 100)) : 0;
|
||||
|
||||
$commentTypes[$type] = [
|
||||
'label' => ucfirst($type) . ' comments',
|
||||
'pending' => $stats['pending'],
|
||||
'reviewed' => $stats['reviewed'],
|
||||
'filled' => $filled,
|
||||
'missing' => $missingDetail[$type] ?? 0,
|
||||
'expected' => $totalStudents,
|
||||
'completionPct' => $completionPct,
|
||||
];
|
||||
}
|
||||
|
||||
$totalPending = array_sum(array_column($commentTypes, 'pending'));
|
||||
$totalReviewed = array_sum(array_column($commentTypes, 'reviewed'));
|
||||
$totalFilled = array_sum(array_column($commentTypes, 'filled'));
|
||||
$totalExpected = array_sum(array_column($commentTypes, 'expected'));
|
||||
$commentCompletionPct = $totalExpected > 0 ? (int) round(min(100, ($totalFilled / $totalExpected) * 100)) : 0;
|
||||
|
||||
$summary['commentSummary'] = [
|
||||
'total' => $totalFilled,
|
||||
'pendingReview' => $totalPending,
|
||||
'reviewed' => $totalReviewed,
|
||||
'missing' => $missingComments,
|
||||
'missingDetail' => $missingDetail,
|
||||
'completionPct' => $commentCompletionPct,
|
||||
'types' => $commentTypes,
|
||||
];
|
||||
|
||||
$attendanceRows = [];
|
||||
if (!empty($classSectionIds)) {
|
||||
$attendanceRows = AttendanceRecord::query()
|
||||
->select(['student_id', 'class_section_id', 'total_absence'])
|
||||
->whereIn('class_section_id', $classSectionIds)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$attendanceSet = [];
|
||||
$totalAbsences = 0;
|
||||
foreach ($attendanceRows as $record) {
|
||||
$studentId = (int) ($record['student_id'] ?? 0);
|
||||
$sectionId = (int) ($record['class_section_id'] ?? 0);
|
||||
if ($studentId <= 0 || $sectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$key = "{$sectionId}:{$studentId}";
|
||||
$attendanceSet[$key] = true;
|
||||
$totalAbsences += (int) ($record['total_absence'] ?? 0);
|
||||
}
|
||||
|
||||
$attendanceRecorded = count($attendanceSet);
|
||||
$attendancePct = $totalStudents > 0 ? (int) round(min(100, ($attendanceRecorded / $totalStudents) * 100)) : 0;
|
||||
$avgAbsence = $attendanceRecorded > 0 ? round($totalAbsences / $attendanceRecorded, 1) : 0;
|
||||
|
||||
$summary['attendanceSummary'] = [
|
||||
'recorded' => $attendanceRecorded,
|
||||
'students' => $totalStudents,
|
||||
'completionPct' => $attendancePct,
|
||||
'avgAbsences' => $avgAbsence,
|
||||
];
|
||||
|
||||
$timezone = new DateTimeZone(date_default_timezone_get() ?: 'UTC');
|
||||
$today = (new DateTimeImmutable('now', $timezone))->format('Y-m-d');
|
||||
|
||||
$attendanceStatus = $this->buildAttendanceSubmissionStatus($classSectionIds, $semester, $schoolYear, $today);
|
||||
$summary['attendanceStatus'] = $attendanceStatus;
|
||||
|
||||
$summary['deadlineEvents'] = Calendar::query()
|
||||
->where('notify_teacher', 1)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->where('date', '>=', $today)
|
||||
->orderBy('date', 'ASC')
|
||||
->limit(5)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
if ($attendanceStatus['submitted'] === false && !empty($classSectionIds)) {
|
||||
$summary['attendanceSummary']['completionPct'] = 0;
|
||||
$summary['attendanceSummary']['recorded'] = 0;
|
||||
}
|
||||
|
||||
$summary['notifications'] = $this->buildDashboardNotifications($summary);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function buildDashboardNotifications(array $summary): array
|
||||
{
|
||||
$notifications = [];
|
||||
$buildScoreLink = static function (?string $focus): string {
|
||||
$base = '/teacher/scores';
|
||||
return $focus ? $base . '?focus=' . urlencode($focus) : $base;
|
||||
};
|
||||
$determineFocus = static function (array $event): string {
|
||||
$eventTypeText = strtolower(trim((string) ($event['event_type'] ?? '')));
|
||||
if ($eventTypeText !== '' && str_contains($eventTypeText, 'draft')) {
|
||||
return 'comments';
|
||||
}
|
||||
$text = strtolower(trim(($event['title'] ?? '') . ' ' . ($event['description'] ?? '')));
|
||||
if ($text === '') {
|
||||
return 'scores';
|
||||
}
|
||||
if (str_contains($text, 'comment') || str_contains($text, 'ptap')) {
|
||||
return 'comments';
|
||||
}
|
||||
return 'scores';
|
||||
};
|
||||
$calendarLink = '/teacher/calendar';
|
||||
$attendanceLink = '/teacher/showupdate_attendance';
|
||||
$timezone = new DateTimeZone(date_default_timezone_get() ?: 'UTC');
|
||||
$todayDate = (new DateTimeImmutable('now', $timezone))->setTime(0, 0);
|
||||
|
||||
$calendarNotificationConfig = [
|
||||
'1st Semester Scores' => 14,
|
||||
'2nd Semester Scores' => 14,
|
||||
'Final' => 14,
|
||||
'Final Exam Draft' => 42,
|
||||
'Midterm' => 14,
|
||||
'Midterm Exam Draft' => 42,
|
||||
];
|
||||
|
||||
$activeDeadline = false;
|
||||
foreach ($summary['deadlineEvents'] ?? [] as $event) {
|
||||
$eventTypeRaw = trim((string) ($event['event_type'] ?? ''));
|
||||
if ($eventTypeRaw === '') {
|
||||
continue;
|
||||
}
|
||||
$matchedType = null;
|
||||
$thresholdDays = null;
|
||||
foreach ($calendarNotificationConfig as $type => $days) {
|
||||
if (strcasecmp($eventTypeRaw, $type) === 0) {
|
||||
$matchedType = $type;
|
||||
$thresholdDays = $days;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($matchedType === null || $thresholdDays === null) {
|
||||
continue;
|
||||
}
|
||||
$rawDate = $event['date'] ?? '';
|
||||
if ($rawDate === '') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$eventDateTime = new DateTimeImmutable($rawDate, $timezone);
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
}
|
||||
$eventDateTime = $eventDateTime->setTime(0, 0);
|
||||
$daysUntil = (int) $todayDate->diff($eventDateTime)->format('%r%a');
|
||||
|
||||
if ($daysUntil <= 0) {
|
||||
$activeDeadline = true;
|
||||
$eventFocus = $determineFocus($event);
|
||||
$notifications[] = [
|
||||
'type' => 'danger',
|
||||
'message' => sprintf(
|
||||
"Deadline today: %s is due. Submit the remaining entries before the day ends.",
|
||||
$matchedType
|
||||
),
|
||||
'link' => $buildScoreLink($eventFocus),
|
||||
'linkText' => 'Submit now',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($daysUntil > $thresholdDays) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativeText = $daysUntil === 1 ? ' (tomorrow)' : " (in {$daysUntil} days)";
|
||||
$notifications[] = [
|
||||
'type' => 'warning',
|
||||
'message' => sprintf(
|
||||
"%s is due on %s%s. Review it on the calendar so you don’t miss it.",
|
||||
$matchedType,
|
||||
$eventDateTime->format('M j, Y'),
|
||||
$relativeText
|
||||
),
|
||||
'link' => $calendarLink,
|
||||
'linkText' => 'View deadline',
|
||||
];
|
||||
}
|
||||
|
||||
if (!$activeDeadline) {
|
||||
$scoreLabel = $summary['scoreSummary']['fieldLabel'] ?? 'Score';
|
||||
if (!empty($summary['scoreSummary']['missing'] ?? 0)) {
|
||||
$notifications[] = [
|
||||
'type' => 'warning',
|
||||
'message' => sprintf(
|
||||
"You have %s %s entries missing on the scores page. Please finish submitting them.",
|
||||
(int) $summary['scoreSummary']['missing'],
|
||||
$scoreLabel
|
||||
),
|
||||
'link' => $buildScoreLink('scores'),
|
||||
'linkText' => 'Score sheet',
|
||||
];
|
||||
}
|
||||
|
||||
foreach (['midterm', 'final', 'ptap'] as $type) {
|
||||
$count = (int) ($summary['commentSummary']['missingDetail'][$type] ?? 0);
|
||||
if ($count <= 0) {
|
||||
continue;
|
||||
}
|
||||
$notifications[] = [
|
||||
'type' => 'warning',
|
||||
'message' => sprintf(
|
||||
"%s comment missing for %s student(s). Leave comments on the scores page.",
|
||||
ucfirst($type),
|
||||
$count
|
||||
),
|
||||
'link' => $buildScoreLink('comments'),
|
||||
'linkText' => ucfirst($type) . ' comments',
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($summary['participationSummary']['missing'] ?? 0)) {
|
||||
$notifications[] = [
|
||||
'type' => 'warning',
|
||||
'message' => sprintf(
|
||||
"Participation entries are still missing for %s student(s). Please add them from the scores page.",
|
||||
(int) $summary['participationSummary']['missing']
|
||||
),
|
||||
'link' => $buildScoreLink('scores'),
|
||||
'linkText' => 'Participation',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($summary['attendanceStatus']['missingSections'] ?? [])) {
|
||||
$missingNames = $summary['attendanceStatus']['missingNames'] ?? [];
|
||||
$label = '';
|
||||
if (!empty($missingNames)) {
|
||||
$label = ' (' . implode(', ', array_slice($missingNames, 0, 3)) . (count($missingNames) > 3 ? '…' : '') . ')';
|
||||
}
|
||||
$notifications[] = [
|
||||
'type' => 'danger',
|
||||
'message' => "Today's attendance is still unsubmitted for {$summary['attendanceStatus']['date']}{$label}. Please submit it now.",
|
||||
'link' => $attendanceLink,
|
||||
'linkText' => 'Record attendance',
|
||||
];
|
||||
}
|
||||
|
||||
return $notifications;
|
||||
}
|
||||
|
||||
private function buildAttendanceSubmissionStatus(array $classSectionIds, string $semester, string $schoolYear, string $date): array
|
||||
{
|
||||
if (empty($classSectionIds)) {
|
||||
return [
|
||||
'date' => $date,
|
||||
'missingSections' => [],
|
||||
'missingNames' => [],
|
||||
'submitted' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$rows = AttendanceDay::query()
|
||||
->select(['class_section_id', 'status'])
|
||||
->whereIn('class_section_id', $classSectionIds)
|
||||
->where('date', $date)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$statusMap = [];
|
||||
foreach ($rows as $row) {
|
||||
$csId = (int) ($row['class_section_id'] ?? 0);
|
||||
if ($csId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$statusMap[$csId] = strtolower(trim((string) ($row['status'] ?? '')));
|
||||
}
|
||||
|
||||
$missingSections = [];
|
||||
foreach ($classSectionIds as $classSectionId) {
|
||||
$status = $statusMap[$classSectionId] ?? '';
|
||||
if (!in_array($status, ['submitted', 'published', 'finalized'], true)) {
|
||||
$missingSections[] = $classSectionId;
|
||||
}
|
||||
}
|
||||
|
||||
$missingNames = [];
|
||||
if (!empty($missingSections)) {
|
||||
$sectionRows = ClassSection::query()
|
||||
->select(['class_section_id', 'class_section_name'])
|
||||
->whereIn('class_section_id', $missingSections)
|
||||
->get()
|
||||
->toArray();
|
||||
foreach ($sectionRows as $row) {
|
||||
$name = trim((string) ($row['class_section_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = (string) ($row['class_section_id'] ?? '');
|
||||
}
|
||||
$missingNames[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'missingSections' => $missingSections,
|
||||
'missingNames' => $missingNames,
|
||||
'submitted' => empty($missingSections),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontend;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class ProfileIconService
|
||||
{
|
||||
public function buildForUser(int $userId): array
|
||||
{
|
||||
$user = User::query()->find($userId);
|
||||
if (!$user) {
|
||||
return [
|
||||
'userName' => 'User not found',
|
||||
'userInitials' => '??',
|
||||
];
|
||||
}
|
||||
|
||||
$firstname = (string) ($user->firstname ?? '');
|
||||
$lastname = (string) ($user->lastname ?? '');
|
||||
|
||||
$initials = '';
|
||||
if ($firstname !== '') {
|
||||
$initials .= strtoupper($firstname[0]);
|
||||
}
|
||||
if ($lastname !== '') {
|
||||
$initials .= strtoupper($lastname[0]);
|
||||
}
|
||||
|
||||
return [
|
||||
'userName' => trim($firstname . ' ' . $lastname),
|
||||
'userInitials' => $initials !== '' ? $initials : '??',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontend;
|
||||
|
||||
class StaticPageService
|
||||
{
|
||||
public function load(string $filename, string $type): array
|
||||
{
|
||||
$safeName = basename($filename);
|
||||
$path = public_path('html/' . $safeName);
|
||||
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => 'Page content not available.',
|
||||
];
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => 'Page content not available.',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'content' => $content,
|
||||
'type' => $type,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Models\Message;
|
||||
use App\Services\System\GlobalConfigService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class MessageCommandService
|
||||
{
|
||||
public function __construct(private GlobalConfigService $configService)
|
||||
{
|
||||
}
|
||||
|
||||
public function create(int $senderId, array $payload, ?UploadedFile $attachment = null): Message
|
||||
{
|
||||
$recipientId = $payload['recipient_id'] ?? null;
|
||||
if (!$recipientId && !empty($payload['recipient_role'])) {
|
||||
$recipientId = $this->recipientIdFromRole($payload['recipient_role']);
|
||||
}
|
||||
|
||||
if (!$recipientId) {
|
||||
throw new RuntimeException('Recipient is required.');
|
||||
}
|
||||
|
||||
if ((int) $recipientId === $senderId) {
|
||||
throw new RuntimeException('Cannot send a message to yourself.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($senderId, $payload, $recipientId, $attachment) {
|
||||
$path = null;
|
||||
if ($attachment) {
|
||||
$path = $attachment->store('messages', ['disk' => 'public']);
|
||||
}
|
||||
|
||||
$message = Message::query()->create([
|
||||
'sender_id' => $senderId,
|
||||
'recipient_id' => (int) $recipientId,
|
||||
'subject' => (string) $payload['subject'],
|
||||
'message' => (string) $payload['message'],
|
||||
'sent_datetime' => now(),
|
||||
'read_status' => 0,
|
||||
'message_number' => 'MSG-' . now()->format('YmdHis'),
|
||||
'priority' => $payload['priority'] ?? 'normal',
|
||||
'attachment' => $path,
|
||||
'status' => $payload['status'] ?? 'sent',
|
||||
'semester' => $payload['semester'] ?? $this->configService->getSemester() ?? '',
|
||||
'school_year' => $payload['school_year'] ?? $this->configService->getSchoolYear() ?? '',
|
||||
]);
|
||||
|
||||
if (!$message) {
|
||||
throw new RuntimeException('Failed to send message.');
|
||||
}
|
||||
|
||||
return $message;
|
||||
});
|
||||
}
|
||||
|
||||
public function update(Message $message, array $payload): Message
|
||||
{
|
||||
return DB::transaction(function () use ($message, $payload) {
|
||||
$updates = [];
|
||||
if (array_key_exists('status', $payload)) {
|
||||
$updates['status'] = $payload['status'];
|
||||
}
|
||||
if (array_key_exists('priority', $payload)) {
|
||||
$updates['priority'] = $payload['priority'];
|
||||
}
|
||||
if (array_key_exists('read_status', $payload)) {
|
||||
$read = (int) $payload['read_status'];
|
||||
$updates['read_status'] = $read;
|
||||
if ($read === 1) {
|
||||
$updates['read_datetime'] = now();
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($updates)) {
|
||||
$message->fill($updates);
|
||||
$message->save();
|
||||
}
|
||||
|
||||
return $message->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function trash(Message $message): bool
|
||||
{
|
||||
return DB::transaction(function () use ($message) {
|
||||
$message->status = 'trashed';
|
||||
return (bool) $message->save();
|
||||
});
|
||||
}
|
||||
|
||||
public function markRead(Message $message): bool
|
||||
{
|
||||
if ($message->read_status) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$message->read_status = 1;
|
||||
$message->read_datetime = now();
|
||||
|
||||
return (bool) $message->save();
|
||||
}
|
||||
|
||||
private function recipientIdFromRole(string $roleName): int
|
||||
{
|
||||
$map = [
|
||||
'teacher' => 1,
|
||||
'parent' => 2,
|
||||
'admin' => 3,
|
||||
'student' => 4,
|
||||
'guest' => 5,
|
||||
'administrator' => 6,
|
||||
];
|
||||
|
||||
$key = strtolower(trim($roleName));
|
||||
if (!isset($map[$key])) {
|
||||
throw new RuntimeException('Invalid recipient role.');
|
||||
}
|
||||
|
||||
return $map[$key];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class MessageQueryService
|
||||
{
|
||||
public function getUserRoleName(int $userId): ?string
|
||||
{
|
||||
$user = User::query()->with('roles')->find($userId);
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$role = $user->roles->sortBy('id')->first();
|
||||
return $role ? (string) $role->name : null;
|
||||
}
|
||||
|
||||
public function inbox(int $userId, array $filters, int $page, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
return $this->baseQuery($filters)
|
||||
->where('recipient_id', $userId)
|
||||
->whereIn('status', ['sent', 'received'])
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
public function sent(int $userId, array $filters, int $page, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
return $this->baseQuery($filters)
|
||||
->where('sender_id', $userId)
|
||||
->where('status', 'sent')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
public function drafts(int $userId, array $filters, int $page, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
return $this->baseQuery($filters)
|
||||
->where('sender_id', $userId)
|
||||
->where('status', 'draft')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
public function trash(int $userId, array $filters, int $page, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
return $this->baseQuery($filters)
|
||||
->where('status', 'trashed')
|
||||
->where(function ($q) use ($userId) {
|
||||
$q->where('sender_id', $userId)
|
||||
->orWhere('recipient_id', $userId);
|
||||
})
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
public function receivedAndMarkRead(int $userId): array
|
||||
{
|
||||
$messages = Message::query()
|
||||
->with(['sender', 'recipient'])
|
||||
->where('recipient_id', $userId)
|
||||
->orderByDesc('sent_datetime')
|
||||
->get();
|
||||
|
||||
$ids = $messages
|
||||
->where('read_status', false)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
if (!empty($ids)) {
|
||||
Message::query()
|
||||
->whereIn('id', $ids)
|
||||
->update([
|
||||
'read_status' => 1,
|
||||
'read_datetime' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $messages->all();
|
||||
}
|
||||
|
||||
public function findForUser(int $messageId): ?Message
|
||||
{
|
||||
return Message::query()
|
||||
->with(['sender', 'recipient'])
|
||||
->find($messageId);
|
||||
}
|
||||
|
||||
private function baseQuery(array $filters)
|
||||
{
|
||||
$query = Message::query()->with(['sender', 'recipient']);
|
||||
|
||||
if (!empty($filters['priority'])) {
|
||||
$query->where('priority', $filters['priority']);
|
||||
}
|
||||
|
||||
if (isset($filters['read_status'])) {
|
||||
$query->where('read_status', (int) $filters['read_status']);
|
||||
}
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
$term = '%' . $filters['search'] . '%';
|
||||
$query->where(function ($q) use ($term) {
|
||||
$q->where('subject', 'like', $term)
|
||||
->orWhere('message', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
$sortBy = $filters['sort_by'] ?? 'sent_datetime';
|
||||
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
$query->orderBy($sortBy, $sortDir);
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Models\ParentModel;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
|
||||
class MessageRecipientService
|
||||
{
|
||||
public function teachers(): array
|
||||
{
|
||||
$rows = TeacherClass::query()
|
||||
->select('teacher_class.teacher_id', 'users.firstname', 'users.lastname')
|
||||
->join('users', 'users.id', '=', 'teacher_class.teacher_id')
|
||||
->orderBy('users.firstname')
|
||||
->orderBy('users.lastname')
|
||||
->get();
|
||||
|
||||
return $rows
|
||||
->map(fn ($row) => [
|
||||
'id' => (int) $row->teacher_id,
|
||||
'name' => trim($row->firstname . ' ' . $row->lastname),
|
||||
])
|
||||
->unique('id')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function parents(): array
|
||||
{
|
||||
$sectionIds = TeacherClass::query()->pluck('class_section_id')->filter()->unique()->values()->all();
|
||||
if (empty($sectionIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$studentIds = StudentClass::query()
|
||||
->active()
|
||||
->whereIn('student_class.class_section_id', $sectionIds)
|
||||
->pluck('student_class.student_id')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parentIds = Student::query()
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->pluck('parent_id')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (empty($parentIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$secondParentIds = ParentModel::query()
|
||||
->whereIn('firstparent_id', $parentIds)
|
||||
->pluck('secondparent_id')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$allIds = collect($parentIds)->merge($secondParentIds)->unique()->values()->all();
|
||||
|
||||
$users = User::query()
|
||||
->whereIn('id', $allIds)
|
||||
->orderBy('firstname')
|
||||
->orderBy('lastname')
|
||||
->get();
|
||||
|
||||
return $users
|
||||
->map(fn ($row) => [
|
||||
'id' => (int) $row->id,
|
||||
'name' => trim($row->firstname . ' ' . $row->lastname),
|
||||
])
|
||||
->unique('id')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Navigation;
|
||||
|
||||
use App\Models\NavItem;
|
||||
use App\Models\Role;
|
||||
use App\Models\RoleNavItem;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NavBuilderService
|
||||
{
|
||||
public function __construct(private NavbarService $navbarService)
|
||||
{
|
||||
}
|
||||
|
||||
public function getAdminPayload(): array
|
||||
{
|
||||
$all = NavItem::query()
|
||||
->orderBy('menu_parent_id', 'asc')
|
||||
->orderBy('label', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$byId = [];
|
||||
foreach ($all as $row) {
|
||||
$row['children'] = [];
|
||||
$byId[$row['id']] = $row;
|
||||
}
|
||||
|
||||
$tree = [];
|
||||
foreach ($byId as $id => &$node) {
|
||||
$pid = $node['menu_parent_id'] ?: null;
|
||||
if ($pid && isset($byId[$pid])) {
|
||||
$byId[$pid]['children'][] = &$node;
|
||||
} else {
|
||||
$tree[] = &$node;
|
||||
}
|
||||
}
|
||||
unset($node);
|
||||
|
||||
$this->sortTreeAlpha($tree);
|
||||
|
||||
$flatAlpha = $all;
|
||||
usort($flatAlpha, fn ($a, $b) => strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')));
|
||||
|
||||
$roleAssignments = $this->buildRoleAssignments();
|
||||
$flattened = $this->flattenTreeForResponse($tree, $roleAssignments);
|
||||
|
||||
$parentOptions = array_map(static function ($row) {
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'label' => (string) ($row['label'] ?? ''),
|
||||
];
|
||||
}, $flatAlpha);
|
||||
|
||||
$roles = Role::query()->orderBy('name')->get(['id', 'name'])->toArray();
|
||||
|
||||
return [
|
||||
'items' => $flattened,
|
||||
'roles' => $roles,
|
||||
'parentOptions' => $parentOptions,
|
||||
];
|
||||
}
|
||||
|
||||
public function save(array $payload): array
|
||||
{
|
||||
$id = (int) ($payload['id'] ?? 0);
|
||||
$menuParentId = $payload['menu_parent_id'] ?? $payload['parent_id'] ?? null;
|
||||
$menuParentId = ($menuParentId === '' || $menuParentId === null) ? null : (int) $menuParentId;
|
||||
if ($id && $menuParentId === $id) {
|
||||
$menuParentId = null;
|
||||
}
|
||||
|
||||
if ($menuParentId !== null && !NavItem::query()->whereKey($menuParentId)->exists()) {
|
||||
return ['ok' => false, 'message' => 'Selected parent does not exist.'];
|
||||
}
|
||||
|
||||
$data = [
|
||||
'menu_parent_id' => $menuParentId,
|
||||
'label' => trim((string) ($payload['label'] ?? '')),
|
||||
'url' => trim((string) ($payload['url'] ?? '')) ?: null,
|
||||
'icon_class' => trim((string) ($payload['icon_class'] ?? '')) ?: null,
|
||||
'target' => trim((string) ($payload['target'] ?? '')) ?: null,
|
||||
'sort_order' => (int) ($payload['sort_order'] ?? 0),
|
||||
'is_enabled' => !empty($payload['is_enabled']) ? 1 : 0,
|
||||
];
|
||||
|
||||
$roleIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', (array) ($payload['roles'] ?? [])),
|
||||
fn ($v) => $v > 0
|
||||
)));
|
||||
|
||||
$navItemId = null;
|
||||
|
||||
DB::transaction(function () use ($id, $data, $roleIds, &$navItemId): void {
|
||||
if ($id > 0) {
|
||||
NavItem::query()->whereKey($id)->update($data);
|
||||
$navItemId = $id;
|
||||
} else {
|
||||
$navItemId = (int) NavItem::query()->create($data)->id;
|
||||
}
|
||||
|
||||
RoleNavItem::query()->where('nav_item_id', $navItemId)->delete();
|
||||
foreach ($roleIds as $rid) {
|
||||
RoleNavItem::query()->create([
|
||||
'role_id' => $rid,
|
||||
'nav_item_id' => $navItemId,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$this->navbarService->clearCache();
|
||||
|
||||
return ['ok' => true, 'id' => $navItemId];
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$deleted = (bool) NavItem::query()->whereKey($id)->delete();
|
||||
$this->navbarService->clearCache();
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
public function reorder(array $orders): void
|
||||
{
|
||||
DB::transaction(function () use ($orders): void {
|
||||
foreach ($orders as $id => $order) {
|
||||
NavItem::query()->whereKey((int) $id)->update(['sort_order' => (int) $order]);
|
||||
}
|
||||
});
|
||||
|
||||
$this->navbarService->clearCache();
|
||||
}
|
||||
|
||||
private function buildRoleAssignments(): array
|
||||
{
|
||||
$rows = RoleNavItem::query()
|
||||
->select('role_nav_items.nav_item_id', 'roles.id as role_id', 'roles.name as role_name')
|
||||
->join('roles', 'roles.id', '=', 'role_nav_items.role_id')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$roleAssignments = [];
|
||||
foreach ($rows as $row) {
|
||||
$navId = (int) ($row['nav_item_id'] ?? 0);
|
||||
if ($navId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$roleId = (int) ($row['role_id'] ?? 0);
|
||||
$roleName = $row['role_name'] ?? ($roleId ? ('#' . $roleId) : null);
|
||||
|
||||
if ($roleId > 0) {
|
||||
$roleAssignments[$navId]['ids'][] = $roleId;
|
||||
}
|
||||
if ($roleName !== null) {
|
||||
$roleAssignments[$navId]['names'][] = $roleName;
|
||||
}
|
||||
}
|
||||
|
||||
return $roleAssignments;
|
||||
}
|
||||
|
||||
private function flattenTreeForResponse(array $tree, array $roleAssignments, int $depth = 0): array
|
||||
{
|
||||
$output = [];
|
||||
foreach ($tree as $node) {
|
||||
$id = (int) ($node['id'] ?? 0);
|
||||
$roles = $roleAssignments[$id] ?? ['ids' => [], 'names' => []];
|
||||
|
||||
$output[] = [
|
||||
'id' => $id,
|
||||
'label' => (string) ($node['label'] ?? ''),
|
||||
'url' => $node['url'] ?? null,
|
||||
'icon_class' => $node['icon_class'] ?? null,
|
||||
'target' => $node['target'] ?? null,
|
||||
'menu_parent_id' => $node['menu_parent_id'] ?? null,
|
||||
'sort_order' => (int) ($node['sort_order'] ?? 0),
|
||||
'is_enabled' => (int) ($node['is_enabled'] ?? 0),
|
||||
'depth' => $depth,
|
||||
'roles' => [
|
||||
'ids' => array_values(array_unique($roles['ids'] ?? [])),
|
||||
'names' => array_values(array_unique($roles['names'] ?? [])),
|
||||
],
|
||||
];
|
||||
|
||||
if (!empty($node['children'])) {
|
||||
$output = array_merge($output, $this->flattenTreeForResponse($node['children'], $roleAssignments, $depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
private function labelKey(string $s): string
|
||||
{
|
||||
$s = trim(preg_replace('/\s+/', ' ', $s));
|
||||
$s = preg_replace('/^[^[:alnum:]]+/u', '', $s);
|
||||
$s = preg_replace('/^(?i)(the|an|a)\s+/', '', $s);
|
||||
return mb_strtolower($s, 'UTF-8');
|
||||
}
|
||||
|
||||
private function sortTreeAlpha(array &$nodes): void
|
||||
{
|
||||
usort($nodes, fn ($a, $b) => strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')));
|
||||
foreach ($nodes as &$node) {
|
||||
if (!empty($node['children'])) {
|
||||
$this->sortTreeAlpha($node['children']);
|
||||
}
|
||||
}
|
||||
unset($node);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Navigation;
|
||||
|
||||
use App\Models\NavItem;
|
||||
use App\Models\RoleNavItem;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class NavbarService
|
||||
{
|
||||
public function getMenuForRoles(array $roles): array
|
||||
{
|
||||
$roles = array_values(array_filter(array_map(fn ($r) => strtolower(trim((string) $r)), $roles)));
|
||||
$cacheKey = 'navbar_' . md5(json_encode($roles));
|
||||
|
||||
return Cache::remember($cacheKey, 300, function () use ($roles) {
|
||||
$allowedIds = RoleNavItem::getNavItemIdsForRoles($roles);
|
||||
if (empty($allowedIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = NavItem::query()
|
||||
->where('is_enabled', 1)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$byId = [];
|
||||
foreach ($rows as $row) {
|
||||
if (in_array((int) $row['id'], $allowedIds, true)) {
|
||||
$row['children'] = [];
|
||||
$byId[$row['id']] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$tree = [];
|
||||
foreach ($byId as $id => &$node) {
|
||||
$pid = $node['menu_parent_id'] ?? null;
|
||||
if ($pid && isset($byId[$pid])) {
|
||||
$byId[$pid]['children'][] = &$node;
|
||||
} else {
|
||||
$tree[] = &$node;
|
||||
}
|
||||
}
|
||||
unset($node);
|
||||
|
||||
return $tree;
|
||||
});
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
Cache::flush();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Payment;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PaypalPaymentService
|
||||
@@ -55,12 +56,13 @@ class PaypalPaymentService
|
||||
$paypalPayment->setRedirectUrls($redirectUrls);
|
||||
$paypalPayment->create($apiContext);
|
||||
|
||||
session()->put('paypalPaymentId', $paypalPayment->getId());
|
||||
session()->put('paymentId', $paymentId);
|
||||
Cache::put($this->cacheKey((string) $paypalPayment->getId()), $paymentId, now()->addHours(2));
|
||||
|
||||
return [
|
||||
'redirect_url' => $paypalPayment->getApprovalLink(),
|
||||
'mode' => 'sdk',
|
||||
'paypal_payment_id' => $paypalPayment->getId(),
|
||||
'payment_id' => $paymentId,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('PayPal create payment failed: ' . $e->getMessage());
|
||||
@@ -68,27 +70,33 @@ class PaypalPaymentService
|
||||
}
|
||||
}
|
||||
|
||||
public function executePayment(string $payerId, ?string $paypalPaymentId = null): void
|
||||
public function executePayment(string $payerId, ?string $paypalPaymentId = null, ?int $paymentId = null): void
|
||||
{
|
||||
if (!$this->sdkAvailable()) {
|
||||
throw new \RuntimeException('PayPal SDK not installed.');
|
||||
}
|
||||
|
||||
$paymentId = $paypalPaymentId ?: (string) session()->get('paypalPaymentId');
|
||||
if (!$paymentId) {
|
||||
$paypalId = $paypalPaymentId ?: '';
|
||||
if ($paypalId === '') {
|
||||
throw new \RuntimeException('Missing PayPal payment ID.');
|
||||
}
|
||||
|
||||
$apiContext = $this->buildApiContext();
|
||||
$payment = \PayPal\Api\Payment::get($paymentId, $apiContext);
|
||||
$payment = \PayPal\Api\Payment::get($paypalId, $apiContext);
|
||||
$execution = new \PayPal\Api\PaymentExecution();
|
||||
$execution->setPayerId($payerId);
|
||||
|
||||
$payment->execute($execution, $apiContext);
|
||||
|
||||
$internalPaymentId = (int) session()->get('paymentId');
|
||||
$internalPaymentId = $paymentId;
|
||||
if (!$internalPaymentId && $paypalId !== '') {
|
||||
$internalPaymentId = (int) Cache::get($this->cacheKey($paypalId));
|
||||
}
|
||||
if ($internalPaymentId) {
|
||||
Payment::query()->whereKey($internalPaymentId)->update(['status' => 'Completed']);
|
||||
if ($paypalId !== '') {
|
||||
Cache::forget($this->cacheKey($paypalId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,4 +128,9 @@ class PaypalPaymentService
|
||||
|
||||
return $apiContext;
|
||||
}
|
||||
|
||||
private function cacheKey(string $paypalPaymentId): string
|
||||
{
|
||||
return 'paypal_payment:' . $paypalPaymentId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Phone;
|
||||
|
||||
class PhoneFormatterService
|
||||
{
|
||||
public function format(string $number): ?string
|
||||
{
|
||||
$digits = preg_replace('/\\D/', '', $number);
|
||||
|
||||
if (strlen($digits) === 10) {
|
||||
return sprintf(
|
||||
'%s-%s-%s',
|
||||
substr($digits, 0, 3),
|
||||
substr($digits, 3, 3),
|
||||
substr($digits, 6)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Policy;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PolicyContentService
|
||||
{
|
||||
private const TYPES = [
|
||||
'school' => 'school_policy.html',
|
||||
'picture' => 'picture_policy.html',
|
||||
];
|
||||
|
||||
public function getPolicy(string $type): array
|
||||
{
|
||||
$type = strtolower(trim($type));
|
||||
if (!isset(self::TYPES[$type])) {
|
||||
throw new \InvalidArgumentException('Unsupported policy type.');
|
||||
}
|
||||
|
||||
$filename = self::TYPES[$type];
|
||||
$path = resource_path('policies/' . $filename);
|
||||
|
||||
if (!is_file($path)) {
|
||||
Log::warning('Policy file missing.', ['type' => $type, 'path' => $path]);
|
||||
return [
|
||||
'type' => $type,
|
||||
'title' => $this->titleFor($type),
|
||||
'content' => '',
|
||||
'format' => 'html',
|
||||
'source' => $path,
|
||||
'updated_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'title' => $this->titleFor($type),
|
||||
'content' => (string) file_get_contents($path),
|
||||
'format' => 'html',
|
||||
'source' => $path,
|
||||
'updated_at' => date('Y-m-d H:i:s', filemtime($path)),
|
||||
];
|
||||
}
|
||||
|
||||
private function titleFor(string $type): string
|
||||
{
|
||||
return $type === 'picture' ? 'Picture Policy' : 'School Policy';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Preferences;
|
||||
|
||||
use App\Models\Preferences;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PreferencesCommandService
|
||||
{
|
||||
public function __construct(private PreferencesOptionsService $options)
|
||||
{
|
||||
}
|
||||
|
||||
public function upsert(int $userId, array $payload): Preferences
|
||||
{
|
||||
$data = $this->mapPayload($payload);
|
||||
$data['user_id'] = $userId;
|
||||
|
||||
return DB::transaction(function () use ($userId, $data) {
|
||||
$existing = Preferences::query()->where('user_id', $userId)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->fill($data);
|
||||
$existing->save();
|
||||
return $existing->refresh();
|
||||
}
|
||||
|
||||
return Preferences::query()->create($data);
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(Preferences $preferences): bool
|
||||
{
|
||||
return (bool) $preferences->delete();
|
||||
}
|
||||
|
||||
private function mapPayload(array $payload): array
|
||||
{
|
||||
$email = $payload['receive_email_notifications'] ?? $payload['notification_email'] ?? null;
|
||||
$sms = $payload['receive_sms_notifications'] ?? $payload['notification_sms'] ?? null;
|
||||
|
||||
$style = $this->options->normalizeStyle($payload['style_color'] ?? null);
|
||||
$menu = $this->options->normalizeMenu($payload['menu_color'] ?? null);
|
||||
|
||||
$data = [
|
||||
'notification_email' => $email !== null ? (int) filter_var($email, FILTER_VALIDATE_BOOLEAN) : null,
|
||||
'notification_sms' => $sms !== null ? (int) filter_var($sms, FILTER_VALIDATE_BOOLEAN) : null,
|
||||
'theme' => $payload['theme'] ?? null,
|
||||
'language' => $payload['language'] ?? null,
|
||||
'style_color' => $style,
|
||||
'menu_color' => $menu,
|
||||
];
|
||||
|
||||
return array_filter($data, static fn ($value) => $value !== null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Preferences;
|
||||
|
||||
class PreferencesOptionsService
|
||||
{
|
||||
public function defaultPreferences(): array
|
||||
{
|
||||
return [
|
||||
'receive_email_notifications' => true,
|
||||
'receive_sms_notifications' => true,
|
||||
'theme' => 'light',
|
||||
'language' => 'en',
|
||||
'style_color' => 'blue',
|
||||
'menu_color' => 'white',
|
||||
];
|
||||
}
|
||||
|
||||
public function styleOptions(): array
|
||||
{
|
||||
return ['blue', 'green', 'red', 'orange', 'gray', 'black', 'white'];
|
||||
}
|
||||
|
||||
public function menuOptions(): array
|
||||
{
|
||||
return ['white', 'gray', 'black', 'blue', 'green'];
|
||||
}
|
||||
|
||||
public function normalizeStyle(?string $style): ?string
|
||||
{
|
||||
if (!$style) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$style = trim($style);
|
||||
return in_array($style, $this->styleOptions(), true) ? $style : null;
|
||||
}
|
||||
|
||||
public function normalizeMenu(?string $menu): ?string
|
||||
{
|
||||
if (!$menu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$menu = trim($menu);
|
||||
return in_array($menu, $this->menuOptions(), true) ? $menu : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Preferences;
|
||||
|
||||
use App\Models\Preferences;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class PreferencesQueryService
|
||||
{
|
||||
public function __construct(private PreferencesOptionsService $options)
|
||||
{
|
||||
}
|
||||
|
||||
public function getForUser(int $userId): array
|
||||
{
|
||||
$row = Preferences::query()->where('user_id', $userId)->first();
|
||||
|
||||
$base = $this->options->defaultPreferences();
|
||||
if (!$row) {
|
||||
return [
|
||||
'preferences' => $base,
|
||||
'options' => $this->optionsPayload(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'preferences' => array_merge($base, $this->mapRow($row)),
|
||||
'options' => $this->optionsPayload(),
|
||||
];
|
||||
}
|
||||
|
||||
public function paginate(array $filters, int $page = 1, int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
$query = Preferences::query()
|
||||
->leftJoin('users', 'users.id', '=', 'user_preferences.user_id')
|
||||
->select('user_preferences.*', 'users.firstname', 'users.lastname', 'users.email');
|
||||
|
||||
if (!empty($filters['user_id'])) {
|
||||
$query->where('user_preferences.user_id', (int) $filters['user_id']);
|
||||
}
|
||||
|
||||
if (!empty($filters['q'])) {
|
||||
$term = '%' . strtolower(trim((string) $filters['q'])) . '%';
|
||||
$query->where(function ($q) use ($term) {
|
||||
$q->whereRaw('LOWER(users.firstname) LIKE ?', [$term])
|
||||
->orWhereRaw('LOWER(users.lastname) LIKE ?', [$term])
|
||||
->orWhereRaw('LOWER(users.email) LIKE ?', [$term]);
|
||||
});
|
||||
}
|
||||
|
||||
$sortBy = (string) ($filters['sort_by'] ?? 'updated_at');
|
||||
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
$allowedSorts = ['updated_at', 'created_at', 'user_id'];
|
||||
|
||||
if (in_array($sortBy, $allowedSorts, true)) {
|
||||
$query->orderBy('user_preferences.' . $sortBy, $sortDir);
|
||||
} else {
|
||||
$query->orderBy('user_preferences.updated_at', 'desc');
|
||||
}
|
||||
|
||||
return $query->paginate($perPage, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
private function optionsPayload(): array
|
||||
{
|
||||
return [
|
||||
'style_options' => $this->options->styleOptions(),
|
||||
'menu_options' => $this->options->menuOptions(),
|
||||
];
|
||||
}
|
||||
|
||||
private function mapRow(Preferences $row): array
|
||||
{
|
||||
return [
|
||||
'receive_email_notifications' => (bool) ($row->notification_email ?? false),
|
||||
'receive_sms_notifications' => (bool) ($row->notification_sms ?? false),
|
||||
'theme' => $row->theme ?? null,
|
||||
'language' => $row->language ?? null,
|
||||
'style_color' => $row->style_color ?? null,
|
||||
'menu_color' => $row->menu_color ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Services\Roles;
|
||||
|
||||
use App\Models\UserRole;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
class RoleSwitchService
|
||||
{
|
||||
@@ -19,7 +18,6 @@ class RoleSwitchService
|
||||
|
||||
public function switchRole(string $role): string
|
||||
{
|
||||
Session::put('active_role', $role);
|
||||
return $this->dashboardService->bestDashboardRouteFor([$role]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,6 @@ class SemesterSelectionService
|
||||
{
|
||||
public function selectedTeacherSemester(): string
|
||||
{
|
||||
$selected = trim((string) (session()->get('teacher_scores_selected_semester') ?? ''));
|
||||
if ($selected !== '') {
|
||||
return $this->normalize($selected);
|
||||
}
|
||||
|
||||
$fallback = trim((string) (session()->get('semester') ?? ''));
|
||||
if ($fallback !== '') {
|
||||
return $this->normalize($fallback);
|
||||
}
|
||||
|
||||
$configSemester = trim((string) (Configuration::getConfig('semester') ?? ''));
|
||||
if ($configSemester !== '') {
|
||||
return $this->normalize($configSemester);
|
||||
|
||||
@@ -328,13 +328,28 @@ class ScoreCommentService
|
||||
|
||||
private function isReviewer(): bool
|
||||
{
|
||||
$currentUserRole = session()->get('role');
|
||||
$user = auth()->user();
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentUserRoles = $user->roles()
|
||||
->pluck('roles.name')
|
||||
->map(fn ($name) => strtolower((string) $name))
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
$reviewerRoles = (string) (Configuration::getConfig('comment_reviewer') ?? '');
|
||||
if ($reviewerRoles === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedRoles = array_map('trim', explode(',', $reviewerRoles));
|
||||
return in_array($currentUserRole, $allowedRoles, true);
|
||||
$allowedRoles = array_map(
|
||||
static fn ($role) => strtolower(trim((string) $role)),
|
||||
explode(',', $reviewerRoles)
|
||||
);
|
||||
|
||||
return !empty(array_intersect($currentUserRoles, $allowedRoles));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Security;
|
||||
|
||||
use App\Models\IpAttempt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class IpBanCommandService
|
||||
{
|
||||
public function banNow(?int $id, ?string $ip, int $hours): ?IpAttempt
|
||||
{
|
||||
$row = $this->findRow($id, $ip);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$blockedUntil = $this->addHours($hours);
|
||||
$attempts = max((int) $row->attempts, 10);
|
||||
|
||||
return DB::transaction(function () use ($row, $blockedUntil, $attempts) {
|
||||
$row->blocked_until = $blockedUntil;
|
||||
$row->attempts = $attempts;
|
||||
$row->save();
|
||||
return $row->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function unbanOne(?int $id, ?string $ip): ?IpAttempt
|
||||
{
|
||||
$row = $this->findRow($id, $ip);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($row) {
|
||||
$row->blocked_until = null;
|
||||
$row->attempts = 0;
|
||||
$row->save();
|
||||
return $row->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function unbanAll(): int
|
||||
{
|
||||
return (int) IpAttempt::query()
|
||||
->where('blocked_until', '>', $this->utcNow())
|
||||
->update([
|
||||
'blocked_until' => null,
|
||||
'attempts' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function findRow(?int $id, ?string $ip): ?IpAttempt
|
||||
{
|
||||
if ($id && $id > 0) {
|
||||
return IpAttempt::query()->find($id);
|
||||
}
|
||||
if ($ip !== null && $ip !== '') {
|
||||
return IpAttempt::query()->where('ip_address', $ip)->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function utcNow(): string
|
||||
{
|
||||
if (function_exists('utc_now')) {
|
||||
return (string) utc_now();
|
||||
}
|
||||
return now('UTC')->toDateTimeString();
|
||||
}
|
||||
|
||||
private function addHours(int $hours): string
|
||||
{
|
||||
$hours = max(1, $hours);
|
||||
if (function_exists('utc_now')) {
|
||||
return (string) date('Y-m-d H:i:s', strtotime("+{$hours} hours", strtotime((string) utc_now())));
|
||||
}
|
||||
return now('UTC')->addHours($hours)->toDateTimeString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Security;
|
||||
|
||||
use App\Models\IpAttempt;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class IpBanQueryService
|
||||
{
|
||||
public function paginate(array $filters, int $page = 1, int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
$query = IpAttempt::query()->orderBy('updated_at', 'desc');
|
||||
|
||||
$status = strtolower(trim((string) ($filters['status'] ?? 'all')));
|
||||
if ($status === 'active') {
|
||||
$query->where('blocked_until', '>', $this->utcNow());
|
||||
}
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
$term = '%' . trim((string) $filters['search']) . '%';
|
||||
$query->where('ip_address', 'like', $term);
|
||||
}
|
||||
|
||||
$sortBy = (string) ($filters['sort_by'] ?? 'updated_at');
|
||||
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
$allowedSorts = ['updated_at', 'blocked_until', 'attempts', 'ip_address', 'last_attempt_at'];
|
||||
if (in_array($sortBy, $allowedSorts, true)) {
|
||||
$query->orderBy($sortBy, $sortDir);
|
||||
}
|
||||
|
||||
return $query->paginate($perPage, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
public function find(int $id): ?IpAttempt
|
||||
{
|
||||
return IpAttempt::query()->find($id);
|
||||
}
|
||||
|
||||
private function utcNow(): string
|
||||
{
|
||||
if (function_exists('utc_now')) {
|
||||
return (string) utc_now();
|
||||
}
|
||||
return now('UTC')->toDateTimeString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Settings;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SettingsService
|
||||
{
|
||||
public function get(): array
|
||||
{
|
||||
$settings = Setting::singleton();
|
||||
|
||||
return [
|
||||
'site_name' => $settings->name,
|
||||
'timezone' => $settings->timezone,
|
||||
'administrator_email' => Configuration::getConfig('administrator_email') ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
public function update(array $payload, int $userId): array
|
||||
{
|
||||
return DB::transaction(function () use ($payload, $userId) {
|
||||
$settings = Setting::singleton();
|
||||
|
||||
$updates = [];
|
||||
if (array_key_exists('site_name', $payload)) {
|
||||
$updates['name'] = $payload['site_name'];
|
||||
}
|
||||
if (array_key_exists('timezone', $payload)) {
|
||||
$updates['timezone'] = $payload['timezone'];
|
||||
}
|
||||
|
||||
if (!empty($updates)) {
|
||||
$updates['updated_by'] = $userId;
|
||||
$settings->fill($updates);
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
if (array_key_exists('administrator_email', $payload)) {
|
||||
Configuration::setConfigValueByKey('administrator_email', (string) $payload['administrator_email']);
|
||||
}
|
||||
|
||||
return $this->get();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Staff;
|
||||
|
||||
use App\Models\Staff;
|
||||
use App\Models\User;
|
||||
use App\Services\System\GlobalConfigService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class StaffCommandService
|
||||
{
|
||||
public function __construct(private GlobalConfigService $configService)
|
||||
{
|
||||
}
|
||||
|
||||
public function create(array $payload): Staff
|
||||
{
|
||||
return DB::transaction(function () use ($payload) {
|
||||
$userId = $this->resolveUserId($payload);
|
||||
|
||||
$roleName = (string) ($payload['role_name'] ?? '');
|
||||
$activeRole = strtolower($roleName);
|
||||
if (!empty($payload['status']) && strtolower((string) $payload['status']) === 'inactive') {
|
||||
$activeRole = 'inactive';
|
||||
}
|
||||
|
||||
$schoolYear = (string) ($payload['school_year'] ?? $this->configService->getSchoolYear() ?? '');
|
||||
|
||||
$staff = Staff::query()->create([
|
||||
'user_id' => $userId,
|
||||
'firstname' => (string) $payload['firstname'],
|
||||
'lastname' => (string) $payload['lastname'],
|
||||
'email' => (string) $payload['email'],
|
||||
'phone' => $payload['phone'] ?? null,
|
||||
'role_name' => $roleName,
|
||||
'active_role' => $activeRole,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if (!$staff) {
|
||||
throw new RuntimeException('Failed to create staff member.');
|
||||
}
|
||||
|
||||
return $staff;
|
||||
});
|
||||
}
|
||||
|
||||
public function update(Staff $staff, array $payload): Staff
|
||||
{
|
||||
return DB::transaction(function () use ($staff, $payload) {
|
||||
$updates = [];
|
||||
foreach (['firstname', 'lastname', 'email', 'phone', 'role_name', 'school_year'] as $field) {
|
||||
if (array_key_exists($field, $payload) && $payload[$field] !== '') {
|
||||
$updates[$field] = $payload[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('role_name', $updates)) {
|
||||
$updates['active_role'] = strtolower((string) $updates['role_name']);
|
||||
}
|
||||
if (!empty($payload['status']) && strtolower((string) $payload['status']) === 'inactive') {
|
||||
$updates['active_role'] = 'inactive';
|
||||
}
|
||||
|
||||
$updates['updated_at'] = now();
|
||||
|
||||
if (!empty($updates)) {
|
||||
$staff->fill($updates);
|
||||
$staff->save();
|
||||
}
|
||||
|
||||
return $staff->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(Staff $staff): bool
|
||||
{
|
||||
return (bool) $staff->delete();
|
||||
}
|
||||
|
||||
private function resolveUserId(array $payload): int
|
||||
{
|
||||
if (!empty($payload['user_id'])) {
|
||||
return (int) $payload['user_id'];
|
||||
}
|
||||
|
||||
$email = $payload['email'] ?? null;
|
||||
if ($email) {
|
||||
$userId = User::query()->where('email', $email)->value('id');
|
||||
if ($userId) {
|
||||
return (int) $userId;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('A valid user_id or existing email is required.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Staff;
|
||||
|
||||
use App\Models\Staff;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
use App\Services\System\GlobalConfigService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StaffQueryService
|
||||
{
|
||||
public function __construct(private GlobalConfigService $configService)
|
||||
{
|
||||
}
|
||||
|
||||
public function paginate(array $filters, int $page, int $perPage): array
|
||||
{
|
||||
$excluded = ['student', 'parent', 'guest', 'inactive'];
|
||||
|
||||
$query = Staff::query()
|
||||
->whereNotIn(DB::raw('LOWER(active_role)'), $excluded);
|
||||
|
||||
if (!empty($filters['role'])) {
|
||||
$role = strtolower(trim((string) $filters['role']));
|
||||
$query->whereRaw('LOWER(active_role) = ?', [$role]);
|
||||
}
|
||||
|
||||
$sortBy = $filters['sort_by'] ?? 'created_at';
|
||||
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$paginator = $query
|
||||
->orderBy($sortBy, $sortDir)
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$schoolYear = (string) ($filters['school_year'] ?? $this->configService->getSchoolYear() ?? '');
|
||||
$semester = (string) ($filters['semester'] ?? $this->configService->getSemester() ?? '');
|
||||
|
||||
$assignByTeacher = $this->buildAssignments($schoolYear);
|
||||
|
||||
$issuesCount = 0;
|
||||
$items = $paginator->getCollection()->map(function ($staff) use ($assignByTeacher, &$issuesCount) {
|
||||
$staff->school_id = User::getSchoolIdByUserId((int) ($staff->user_id ?? 0));
|
||||
|
||||
$role = strtolower((string) ($staff->active_role ?? ''));
|
||||
if (in_array($role, ['teacher', 'teacher_assistant'], true)) {
|
||||
$tid = (int) ($staff->user_id ?? 0);
|
||||
$labels = $assignByTeacher[$tid] ?? [];
|
||||
if (!empty($labels)) {
|
||||
$staff->class_section = implode(', ', array_unique($labels));
|
||||
$staff->verification_issue = false;
|
||||
} else {
|
||||
$staff->class_section = 'No class assigned';
|
||||
$staff->verification_issue = true;
|
||||
$issuesCount++;
|
||||
}
|
||||
} else {
|
||||
$staff->class_section = '—';
|
||||
$staff->verification_issue = false;
|
||||
}
|
||||
|
||||
return $staff;
|
||||
});
|
||||
|
||||
$paginator->setCollection($items);
|
||||
|
||||
return [
|
||||
'paginator' => $paginator,
|
||||
'issues_count' => $issuesCount,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
];
|
||||
}
|
||||
|
||||
public function find(int $id): ?Staff
|
||||
{
|
||||
return Staff::query()->find($id);
|
||||
}
|
||||
|
||||
private function buildAssignments(string $schoolYear): array
|
||||
{
|
||||
if ($schoolYear === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = TeacherClass::query()
|
||||
->select('teacher_class.teacher_id', 'teacher_class.position', 'classSection.class_section_name')
|
||||
->leftJoin('classSection', 'classSection.class_section_id', '=', 'teacher_class.class_section_id')
|
||||
->where('teacher_class.school_year', $schoolYear)
|
||||
->get();
|
||||
|
||||
$assignByTeacher = [];
|
||||
foreach ($rows as $row) {
|
||||
$tid = (int) ($row->teacher_id ?? 0);
|
||||
if ($tid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($row->class_section_name ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$pos = strtolower((string) ($row->position ?? ''));
|
||||
if (!in_array($pos, ['main', 'ta'], true)) {
|
||||
continue;
|
||||
}
|
||||
$assignByTeacher[$tid][] = $name . ' (' . $pos . ')';
|
||||
}
|
||||
|
||||
return $assignByTeacher;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Support;
|
||||
|
||||
use App\Services\Email\EmailDispatchService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ContactMessageService
|
||||
{
|
||||
public function __construct(private EmailDispatchService $emailService)
|
||||
{
|
||||
}
|
||||
|
||||
public function send(array $payload): array
|
||||
{
|
||||
$name = (string) $payload['name'];
|
||||
$email = strtolower((string) $payload['email']);
|
||||
$subject = (string) $payload['subject'];
|
||||
$message = (string) $payload['message'];
|
||||
|
||||
$formatted = "<p><strong>From:</strong> {$name} ({$email})</p>";
|
||||
$formatted .= "<p><strong>Subject:</strong> {$subject}</p>";
|
||||
$formatted .= '<p>' . nl2br($message) . '</p>';
|
||||
|
||||
$recipient = (string) config('support.contact_email', 'support@alrahmaisgl.org');
|
||||
|
||||
$emailSent = false;
|
||||
if (!app()->runningUnitTests()) {
|
||||
$emailSent = $this->emailService->send($recipient, $subject, $formatted, null, $email, $name);
|
||||
}
|
||||
|
||||
if (!$emailSent) {
|
||||
Log::warning('Contact email failed for ' . $email);
|
||||
}
|
||||
|
||||
return [
|
||||
'email_sent' => $emailSent,
|
||||
'recipient' => $recipient,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Support;
|
||||
|
||||
use App\Models\SupportRequest;
|
||||
use App\Services\Email\EmailDispatchService;
|
||||
use App\Services\System\GlobalConfigService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SupportRequestService
|
||||
{
|
||||
public function __construct(
|
||||
private GlobalConfigService $configService,
|
||||
private EmailDispatchService $emailService
|
||||
) {
|
||||
}
|
||||
|
||||
public function list(int $userId, array $filters, int $page, int $perPage, bool $asAdmin = false): LengthAwarePaginator
|
||||
{
|
||||
$query = SupportRequest::query()->with('user');
|
||||
|
||||
if (!$asAdmin) {
|
||||
$query->where('user_id', $userId);
|
||||
} elseif (!empty($filters['user_id'])) {
|
||||
$query->where('user_id', (int) $filters['user_id']);
|
||||
}
|
||||
|
||||
if (!empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
$sortBy = $filters['sort_by'] ?? 'created_at';
|
||||
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
return $query->orderBy($sortBy, $sortDir)
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
public function create(int $userId, array $payload): array
|
||||
{
|
||||
$semester = $this->configService->getSemester() ?? '';
|
||||
$schoolYear = $this->configService->getSchoolYear() ?? '';
|
||||
|
||||
return DB::transaction(function () use ($userId, $payload, $semester, $schoolYear) {
|
||||
$record = SupportRequest::query()->create([
|
||||
'user_id' => $userId,
|
||||
'subject' => (string) $payload['subject'],
|
||||
'message' => (string) $payload['message'],
|
||||
'status' => 'open',
|
||||
'semester' => $semester ?: 'Fall',
|
||||
'school_year' => $schoolYear ?: '2025-2026',
|
||||
]);
|
||||
|
||||
$recipient = (string) config('support.contact_email', 'support@alrahmaisgl.org');
|
||||
$emailSent = false;
|
||||
if (!app()->runningUnitTests()) {
|
||||
$emailSent = $this->emailService->send(
|
||||
$recipient,
|
||||
(string) $payload['subject'],
|
||||
nl2br((string) $payload['message']),
|
||||
null,
|
||||
(string) ($payload['user_email'] ?? null),
|
||||
(string) ($payload['user_name'] ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
if (!$emailSent) {
|
||||
Log::warning('Support email failed for user ' . $userId);
|
||||
}
|
||||
|
||||
return [
|
||||
'record' => $record,
|
||||
'email_sent' => $emailSent,
|
||||
'recipient' => $recipient,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class CleanupSchedulerService
|
||||
{
|
||||
public function shouldRun(string $cacheKey, int $minutes): bool
|
||||
{
|
||||
$lastRun = Cache::get($cacheKey);
|
||||
if (!$lastRun) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return now()->diffInMinutes($lastRun) >= $minutes;
|
||||
}
|
||||
|
||||
public function markRun(string $cacheKey): void
|
||||
{
|
||||
Cache::put($cacheKey, now(), now()->addDay());
|
||||
}
|
||||
|
||||
public function runUnverifiedCleanup(): void
|
||||
{
|
||||
Artisan::call('users:delete-unverified');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DatabaseHealthService
|
||||
{
|
||||
public function check(): array
|
||||
{
|
||||
try {
|
||||
DB::select('SELECT 1');
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class HealthCheckService
|
||||
{
|
||||
public function check(): array
|
||||
{
|
||||
$uploadsBase = storage_path('app/uploads');
|
||||
$paths = [
|
||||
'uploads' => $uploadsBase,
|
||||
'uploads/reimbursements' => $uploadsBase . DIRECTORY_SEPARATOR . 'reimbursements',
|
||||
'uploads/receipts' => $uploadsBase . DIRECTORY_SEPARATOR . 'receipts',
|
||||
'uploads/checks' => $uploadsBase . DIRECTORY_SEPARATOR . 'checks',
|
||||
'uploads/early_dismissal_signatures' => $uploadsBase . DIRECTORY_SEPARATOR . 'early_dismissal_signatures',
|
||||
];
|
||||
|
||||
$pathsStatus = [];
|
||||
foreach ($paths as $label => $path) {
|
||||
$pathsStatus[] = [
|
||||
'label' => $label,
|
||||
'path' => $path,
|
||||
'exists' => is_dir($path),
|
||||
'writable' => is_writable($path),
|
||||
];
|
||||
}
|
||||
|
||||
$dbChecks = [
|
||||
'user_preferences_exists' => Schema::hasTable('user_preferences'),
|
||||
'settings_exists' => Schema::hasTable('settings'),
|
||||
'migrations_exists' => Schema::hasTable('migrations'),
|
||||
];
|
||||
|
||||
$dbChecks['user_preferences_has_timezone'] = $dbChecks['user_preferences_exists']
|
||||
? Schema::hasColumn('user_preferences', 'timezone')
|
||||
: false;
|
||||
|
||||
$dbChecks['settings_has_timezone'] = $dbChecks['settings_exists']
|
||||
? Schema::hasColumn('settings', 'timezone')
|
||||
: false;
|
||||
|
||||
$okPaths = array_reduce($pathsStatus, function (bool $carry, array $row) {
|
||||
return $carry && $row['exists'] && $row['writable'];
|
||||
}, true);
|
||||
|
||||
$okDb = true;
|
||||
if ($dbChecks['user_preferences_exists'] && !$dbChecks['user_preferences_has_timezone']) {
|
||||
$okDb = false;
|
||||
}
|
||||
if ($dbChecks['settings_exists'] && !$dbChecks['settings_has_timezone']) {
|
||||
$okDb = false;
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $okPaths && $okDb,
|
||||
'paths' => $pathsStatus,
|
||||
'database' => $dbChecks,
|
||||
'write_path' => storage_path(),
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,18 @@ class TimeService
|
||||
return (string) (config('app.timezone') ?: 'UTC');
|
||||
}
|
||||
|
||||
public function primeFromRequest($request): void
|
||||
{
|
||||
$headerTz = (string) ($request?->header('X-Timezone') ?? '');
|
||||
if ($headerTz === '') {
|
||||
$headerTz = (string) ($request?->header('Time-Zone') ?? '');
|
||||
}
|
||||
|
||||
if ($headerTz !== '' && in_array($headerTz, \DateTimeZone::listIdentifiers(), true)) {
|
||||
// No session-backed storage; header is read per request if needed.
|
||||
}
|
||||
}
|
||||
|
||||
public function serverTimezone(): string
|
||||
{
|
||||
return (string) (config('app.timezone') ?: 'UTC');
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ui;
|
||||
|
||||
use App\Models\Preferences;
|
||||
|
||||
class UiStyleService
|
||||
{
|
||||
public function update(int $userId, array $payload): Preferences
|
||||
{
|
||||
$updates = $this->buildUpdates($payload);
|
||||
|
||||
$prefs = Preferences::query()->where('user_id', $userId)->first();
|
||||
if ($prefs) {
|
||||
$prefs->fill($updates)->save();
|
||||
return $prefs->fresh();
|
||||
}
|
||||
|
||||
$updates['user_id'] = $userId;
|
||||
return Preferences::query()->create($updates);
|
||||
}
|
||||
|
||||
public function buildUpdates(array $payload): array
|
||||
{
|
||||
$updates = [];
|
||||
$accent = $this->sanitizePaletteKey($payload['accent'] ?? null);
|
||||
$menu = $this->sanitizePaletteKey($payload['menu'] ?? null);
|
||||
$menuBg = $this->normalizeHex($payload['menu_bg'] ?? null);
|
||||
$menuTx = $this->normalizeHex($payload['menu_text'] ?? null);
|
||||
$menuMode = $this->normalizeMenuMode($payload['menu_mode'] ?? null);
|
||||
|
||||
$stylePalettes = (array) config('ui.style_palettes', []);
|
||||
$menuPalettes = (array) config('ui.menu_palettes', []);
|
||||
|
||||
if ($accent !== null && (empty($stylePalettes) || array_key_exists($accent, $stylePalettes))) {
|
||||
$updates['style_color'] = $accent;
|
||||
}
|
||||
|
||||
if ($menu !== null && (empty($menuPalettes) || array_key_exists($menu, $menuPalettes))) {
|
||||
$updates['menu_color'] = $menu;
|
||||
$updates['menu_custom_bg'] = null;
|
||||
$updates['menu_custom_text'] = null;
|
||||
$updates['menu_custom_mode'] = null;
|
||||
}
|
||||
|
||||
if ($menu === 'custom' || $menuBg !== null || $menuTx !== null) {
|
||||
if ($menuBg === null && $menuTx === null) {
|
||||
return $updates;
|
||||
}
|
||||
$updates['menu_color'] = 'custom';
|
||||
$updates['menu_custom_bg'] = $menuBg ?: '#0F172A';
|
||||
$updates['menu_custom_text'] = $menuTx ?: '#FFFFFF';
|
||||
$updates['menu_custom_mode'] = $menuMode;
|
||||
}
|
||||
|
||||
return $updates;
|
||||
}
|
||||
|
||||
private function sanitizePaletteKey(?string $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private function normalizeMenuMode(?string $mode): string
|
||||
{
|
||||
$mode = strtolower(trim((string) $mode));
|
||||
if (in_array($mode, ['light', 'dark', 'auto'], true)) {
|
||||
return $mode;
|
||||
}
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
private function normalizeHex(?string $hex): ?string
|
||||
{
|
||||
$h = trim((string) $hex);
|
||||
if ($h === '') {
|
||||
return null;
|
||||
}
|
||||
if ($h[0] !== '#') {
|
||||
$h = '#' . $h;
|
||||
}
|
||||
if (preg_match('/^#([0-9a-fA-F]{3})$/', $h, $m)) {
|
||||
$r = $m[1][0];
|
||||
$g = $m[1][1];
|
||||
$b = $m[1][2];
|
||||
return strtoupper('#' . $r . $r . $g . $g . $b . $b);
|
||||
}
|
||||
if (preg_match('/^#([0-9a-fA-F]{6})$/', $h)) {
|
||||
return strtoupper($h);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user