reconstruction of the project

This commit is contained in:
root
2026-03-08 16:33:24 -04:00
parent 23b7db1107
commit c8de5f7edc
9157 changed files with 77877 additions and 1073823 deletions
@@ -1,41 +0,0 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\SaveAdminNotificationSubjectsRequest;
use App\Http\Requests\Admin\SavePrintNotificationRecipientsRequest;
use App\Http\Resources\Admin\AdminNotificationAlertsResource;
use App\Http\Resources\Admin\PrintRecipientsResource;
use App\Services\Admin\AdminNotificationService;
use Illuminate\Http\JsonResponse;
class AdminNotificationController extends Controller
{
public function __construct(
private AdminNotificationService $service
) {}
public function alerts(): AdminNotificationAlertsResource
{
return new AdminNotificationAlertsResource($this->service->alertsPayload());
}
public function saveSubjects(SaveAdminNotificationSubjectsRequest $request): JsonResponse
{
$result = $this->service->saveSubjects($request->validated('subjects') ?? []);
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
}
public function printRecipients(): PrintRecipientsResource
{
return new PrintRecipientsResource($this->service->printRecipientsPayload());
}
public function savePrintRecipients(SavePrintNotificationRecipientsRequest $request): JsonResponse
{
$result = $this->service->savePrintRecipients($request->validated('notify') ?? []);
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
}
}
@@ -1,53 +0,0 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\ClassProgressAttachment;
use App\Models\ClassProgressReport;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class AdminProgressAttachmentController extends Controller
{
use HandlesProgressAttachments;
/**
* Download legacy attachment from class_progress_reports.attachment_path
*/
public function downloadLegacyByReport(int $reportId): BinaryFileResponse
{
$row = ClassProgressReport::query()->find($reportId);
if (! $row || empty($row->attachment_path)) {
abort(404, 'Attachment not found.');
}
$file = $this->resolveAttachmentFile($row->toArray());
if (! $file) {
abort(404, 'Attachment missing.');
}
return response()->download($file, basename($file));
}
/**
* Download file from normalized attachments table
*/
public function downloadAttachment(int $attachmentId): BinaryFileResponse
{
$attachment = ClassProgressAttachment::query()->find($attachmentId);
if (! $attachment || empty($attachment->file_path)) {
abort(404, 'Attachment not found.');
}
$file = $this->resolveAttachmentPath((string) $attachment->file_path);
if (! $file) {
abort(404, 'Attachment missing.');
}
$downloadName = $attachment->original_name ?: basename($file);
return response()->download($file, $downloadName);
}
}
@@ -1,209 +0,0 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Admin\IndexAdminProgressRequest;
use App\Http\Resources\Admin\Progress\ProgressGroupResource;
use App\Http\Resources\Admin\Progress\ProgressReportDetailResource;
use App\Models\ClassProgressReport;
use App\Models\ClassSection;
use App\Models\StudentClass;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AdminProgressReportController extends Controller
{
use HandlesProgressAttachments;
public function index(IndexAdminProgressRequest $request): JsonResponse
{
$filters = $request->validated();
// Ensure all keys exist (same shape as CI)
$filters = array_merge([
'from' => '',
'to' => '',
'class_section_id' => '',
'status' => '',
], $filters);
$query = ClassProgressReport::query()
->from('class_progress_reports')
->select([
'class_progress_reports.*',
'cs.class_section_name',
])
->selectRaw("CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS teacher_name")
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'class_progress_reports.class_section_id')
->leftJoin('users as u', 'u.id', '=', 'class_progress_reports.teacher_id');
$this->applyFilters($query, $filters);
$rows = $query
->orderByDesc('week_start')
->get()
->map(function ($row) {
$arr = $row->toArray();
$arr['status_label'] = config('progress.status_options.' . ($arr['status'] ?? ''), 'Unknown');
return $arr;
})
->all();
$reportGroups = $this->groupReportsByWeekAndSection($rows);
$classSections = $this->getClassSections();
$studentCounts = $this->getStudentCountsBySection();
$filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts) {
$sectionId = (int) ($section['class_section_id'] ?? 0);
return isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0;
}));
return response()->json([
'data' => ProgressGroupResource::collection(collect(array_values($reportGroups))),
'meta' => [
'filters' => $filters,
'class_sections' => $filteredSections,
'status_options' => config('progress.status_options', []),
'subject_sections' => config('progress.subject_sections', []),
],
]);
}
public function show(int $id): JsonResponse
{
$row = ClassProgressReport::query()
->from('class_progress_reports')
->select([
'class_progress_reports.*',
'cs.class_section_name',
])
->selectRaw("CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS teacher_name")
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'class_progress_reports.class_section_id')
->leftJoin('users as u', 'u.id', '=', 'class_progress_reports.teacher_id')
->where('class_progress_reports.id', $id)
->first();
if (! $row) {
return response()->json(['message' => 'Progress report not found.'], 404);
}
$rowData = $row->toArray();
$rowData['status_label'] = config('progress.status_options.' . ($rowData['status'] ?? ''), 'Unknown');
$rowData['flags'] = $this->decodeFlags($rowData['flags_json'] ?? null);
$weeklyReports = ClassProgressReport::query()
->where('class_section_id', $rowData['class_section_id'])
->where('week_start', $rowData['week_start'])
->orderBy('subject', 'asc')
->get()
->map(fn ($r) => $r->toArray())
->all();
$attachmentMap = $this->loadAttachmentsForReports(array_column($weeklyReports, 'id'));
foreach ($weeklyReports as &$report) {
$reportId = (int) ($report['id'] ?? 0);
$report['attachments'] = $attachmentMap[$reportId] ?? [];
// Legacy fallback attachment (same behavior as CI)
if (empty($report['attachments']) && ! empty($report['attachment_path'])) {
$report['attachments'][] = [
'id' => $reportId, // pseudo-id for legacy
'name' => basename((string) $report['attachment_path']),
'legacy' => true,
];
}
}
unset($report);
return response()->json([
'data' => new ProgressReportDetailResource([
'report' => $rowData,
'weekly_reports' => $weeklyReports,
'subject_sections' => config('progress.subject_sections', []),
]),
]);
}
private function applyFilters(Builder $query, array $filters): void
{
if (! empty($filters['from'])) {
$query->where('week_start', '>=', $filters['from']);
}
if (! empty($filters['to'])) {
$query->where('week_end', '<=', $filters['to']);
}
if (! empty($filters['class_section_id'])) {
$query->where('class_progress_reports.class_section_id', (int) $filters['class_section_id']);
}
if (! empty($filters['status'])) {
$query->where('class_progress_reports.status', $filters['status']);
}
}
private function groupReportsByWeekAndSection(array $rows): array
{
$groups = [];
foreach ($rows as $row) {
$key = ($row['week_start'] ?? '') . '_' . ($row['class_section_id'] ?? '');
if ($key === '_') {
continue;
}
if (! isset($groups[$key])) {
$groups[$key] = [
'week_start' => $row['week_start'] ?? null,
'week_end' => $row['week_end'] ?? null,
'class_section_id' => $row['class_section_id'] ?? null,
'class_section_name' => $row['class_section_name'] ?? '',
'reports' => [],
];
}
$subject = (string) ($row['subject'] ?? 'unknown');
$groups[$key]['reports'][$subject] = $row;
}
return $groups;
}
/**
* Replace with relation/scopes/service if you already have them.
*/
private function getClassSections(): array
{
if (! class_exists(ClassSection::class)) {
return [];
}
return ClassSection::query()
->orderBy('class_section_name')
->get()
->map(fn ($r) => $r->toArray())
->all();
}
/**
* Expected shape: [class_section_id => count]
*/
private function getStudentCountsBySection(): array
{
if (! class_exists(StudentClass::class)) {
return [];
}
return StudentClass::query()
->selectRaw('class_section_id, COUNT(*) as total')
->groupBy('class_section_id')
->pluck('total', 'class_section_id')
->map(fn ($v) => (int) $v)
->all();
}
}
@@ -1,31 +0,0 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\SendTeacherSubmissionNotificationsRequest;
use App\Http\Resources\Admin\TeacherSubmissionReportResource;
use App\Services\Admin\TeacherSubmissionService;
use Illuminate\Http\JsonResponse;
class AdminTeacherSubmissionController extends Controller
{
public function __construct(
private TeacherSubmissionService $service
) {}
public function report(): TeacherSubmissionReportResource
{
return new TeacherSubmissionReportResource($this->service->report());
}
public function sendNotifications(SendTeacherSubmissionNotificationsRequest $request): JsonResponse
{
$result = $this->service->sendNotifications(
(int) $request->user()->id,
$request->validated()
);
return response()->json($result);
}
}
@@ -1,71 +0,0 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Models\ClassProgressAttachment;
trait HandlesProgressAttachments
{
protected function resolveAttachmentFile(array $row): ?string
{
$path = trim((string) ($row['attachment_path'] ?? ''));
if ($path === '') {
return null;
}
return $this->resolveAttachmentPath($path);
}
protected function resolveAttachmentPath(string $path): ?string
{
// CI stored paths may look like: writable/uploads/...
$relative = preg_replace('#^writable/uploads/#', '', $path);
// Laravel equivalent target
$absolute = storage_path('app/uploads/' . ltrim((string) $relative, '/'));
return is_file($absolute) ? $absolute : null;
}
/**
* Returns map: [report_id => [ ['id'=>.., 'name'=>..], ... ]]
*/
protected function loadAttachmentsForReports(array $reportIds): array
{
$reportIds = array_values(array_filter(array_map('intval', $reportIds)));
if (empty($reportIds)) {
return [];
}
$rows = ClassProgressAttachment::query()
->whereIn('report_id', $reportIds)
->orderBy('id', 'asc')
->get();
$map = [];
foreach ($rows as $row) {
$reportId = (int) ($row->report_id ?? 0);
if ($reportId === 0) {
continue;
}
$map[$reportId][] = [
'id' => (int) $row->id,
'name' => $row->original_name ?: basename((string) $row->file_path),
];
}
return $map;
}
protected function decodeFlags(?string $json): array
{
if (! $json) {
return [];
}
$flags = json_decode($json, true);
return is_array($flags) ? $flags : [];
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Controller;
use App\Http\Requests\Administrator\SubmitAdministratorAbsenceRequest;
use App\Services\Administrator\AdministratorAbsenceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
class AdministratorAbsenceController extends Controller
{
public function __construct(
protected AdministratorAbsenceService $service
) {
}
public function index(): JsonResponse
{
$userId = (int) Auth::id();
if ($userId <= 0) {
return response()->json(['message' => 'Please log in first.'], 401);
}
return response()->json($this->service->getAbsenceFormData($userId));
}
public function store(SubmitAdministratorAbsenceRequest $request): JsonResponse
{
$userId = (int) Auth::id();
if ($userId <= 0) {
return response()->json(['message' => 'Please log in first.'], 401);
}
$result = $this->service->submit($request, $userId);
return response()->json([
'message' => $result['message'],
'saved' => $result['saved'] ?? 0,
'dates' => $result['dates'] ?? [],
], $result['status']);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Controller;
use App\Services\Administrator\AdministratorDashboardService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AdministratorDashboardController extends Controller
{
public function __construct(
protected AdministratorDashboardService $service
) {
}
public function metrics(): JsonResponse
{
return response()->json($this->service->metrics());
}
public function userSearch(Request $request): JsonResponse
{
return response()->json(
$this->service->userSearch((string) $request->query('query', ''))
);
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Controller;
use App\Http\Requests\Administrator\UpdateEnrollmentStatusesRequest;
use App\Services\Administrator\AdministratorEnrollmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AdministratorEnrollmentController extends Controller
{
public function __construct(
protected AdministratorEnrollmentService $service
) {
}
public function index(Request $request): JsonResponse
{
return response()->json(
$this->service->enrollmentWithdrawalData(
(string) $request->query('schoolYear', '')
)
);
}
public function newStudents(): JsonResponse
{
return response()->json($this->service->showNewStudentsData());
}
public function updateStatuses(UpdateEnrollmentStatusesRequest $request): JsonResponse
{
$editorUserId = (int) Auth::id();
if ($editorUserId <= 0) {
return response()->json(['message' => 'Please log in first.'], 401);
}
$data = $request->validated();
$result = $this->service->updateStatuses(
(array) ($data['enrollment_status'] ?? []),
$editorUserId
);
return response()->json(['message' => $result['message']], $result['status']);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Controller;
use App\Http\Requests\Administrator\SaveAdminNotificationSubjectsRequest;
use App\Http\Requests\Administrator\SavePrintRecipientsRequest;
use App\Services\Administrator\AdministratorNotificationService;
use Illuminate\Http\JsonResponse;
class AdministratorNotificationController extends Controller
{
public function __construct(
protected AdministratorNotificationService $service
) {
}
public function alerts(): JsonResponse
{
return response()->json($this->service->notificationsAlertsData());
}
public function saveAlerts(SaveAdminNotificationSubjectsRequest $request): JsonResponse
{
$data = $request->validated();
$result = $this->service->saveNotificationSubjects(
(array) ($data['subjects'] ?? [])
);
return response()->json(['message' => $result['message']], $result['status']);
}
public function printRecipients(): JsonResponse
{
return response()->json($this->service->printNotificationRecipientsData());
}
public function savePrintRecipients(SavePrintRecipientsRequest $request): JsonResponse
{
$data = $request->validated();
$result = $this->service->savePrintNotificationRecipients(
(array) ($data['notify'] ?? [])
);
return response()->json(['message' => $result['message']], $result['status']);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Controller;
use App\Http\Requests\Administrator\SendTeacherSubmissionNotificationsRequest;
use App\Services\Administrator\AdministratorTeacherSubmissionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
class AdministratorTeacherSubmissionController extends Controller
{
public function __construct(
protected AdministratorTeacherSubmissionService $service
) {
}
public function index(): JsonResponse
{
return response()->json($this->service->report());
}
public function notify(SendTeacherSubmissionNotificationsRequest $request): JsonResponse
{
$adminId = (int) Auth::id();
if ($adminId <= 0) {
return response()->json(['message' => 'Please log in first.'], 401);
}
$result = $this->service->sendNotifications($request, $adminId);
return response()->json([
'message' => $result['message'],
'sent' => $result['sent'] ?? 0,
'failed' => $result['failed'] ?? 0,
], $result['status']);
}
}
@@ -1,844 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\BaseApiController;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\Invoice;
use App\Models\LoginActivity;
use App\Models\Refund;
use App\Models\StaffAttendance;
use App\Models\StudentClass;
use App\Models\Student;
use App\Models\User;
use App\Models\UserRole;
use App\Services\FeeCalculationService;
class AdministratorController extends BaseApiController
{
protected $user;
protected $student;
protected $config;
protected $loginActivity;
protected $enrollment;
protected $refund;
protected $invoice;
protected $classSection;
protected $userRole;
protected $studentClass;
protected $staffAttendance;
protected $db;
protected $schoolYear;
protected $semester;
public function __construct()
{
parent::__construct();
// Use CI4's model() to allow tests to inject mocks
$this->user = model(User::class);
$this->student = model(Student::class);
$this->config = model(Configuration::class);
$this->loginActivity = model(LoginActivity::class);
$this->enrollment = model(Enrollment::class);
$this->refund = model(Refund::class);
$this->invoice = model(Invoice::class);
$this->classSection = model(ClassSection::class);
$this->userRole = model(UserRole::class);
$this->studentClass = model(StudentClass::class);
$this->staffAttendance= model(StaffAttendance::class);
$this->db = \Config\Database::connect();
$this->schoolYear = $this->config->getConfig('school_year');
$this->semester = $this->config->getConfig('semester');
}
/**
* Get dashboard metrics
* GET /api/v1/administrator/dashboard
*/
public function dashboard()
{
try {
$recentActivities = $this->loginActivity->getLastActivities(4);
if (!is_array($recentActivities)) {
$recentActivities = [];
}
$totalAdmins = (int) ($this->user->countAdminsBySchoolYear($this->schoolYear) ?? 0);
$teachers = $this->user->getUsersByRoleAndSchoolYear('teacher', $this->schoolYear);
$totalTeachers = count(array_unique(array_column($teachers, 'id')));
$teacherAssistants = $this->user->getUsersByRoleAndSchoolYear('teacher_assistant', $this->schoolYear);
$totalTeacherAssistants = count(array_unique(array_column($teacherAssistants, 'id')));
$parents = $this->user->getUsersByRoleAndSchoolYear('parent', $this->schoolYear);
$totalParents = count(array_unique(array_column($parents, 'id')));
$totalStudents = (int) (
$this->db->table('student_class')
->select('COUNT(DISTINCT student_class.student_id) AS cnt')
->where('student_class.school_year', $this->schoolYear)
->where('student_class.class_section_id IS NOT NULL', null, false)
->get()
->getRow('cnt')
?? 0
);
// Align shape with View\AdministratorController::buildDashboardMetrics()
$data = [
'counts' => [
'students' => $totalStudents,
'teachers' => $totalTeachers,
'teacherAssistants' => $totalTeacherAssistants,
'admins' => $totalAdmins,
'parents' => $totalParents,
],
'recentActivities' => array_map(static function ($activity) {
if (!is_array($activity)) { return []; }
return [
'login_time' => $activity['login_time'] ?? null,
'email' => $activity['email'] ?? null,
];
}, $recentActivities),
'meta' => [
'schoolYear' => $this->schoolYear,
'semester' => $this->semester,
],
];
return $this->success($data, 'Dashboard metrics retrieved successfully');
} catch (\Exception $e) {
log_message('error', 'Dashboard metrics error: ' . $e->getMessage());
return $this->error('Failed to retrieve dashboard metrics', 500);
}
}
/**
* Compute allowed absence dates (future Sundays within Sep..May for configured school year)
*/
private function allowedAbsenceDates(): array
{
$todayStr = local_date(utc_now(), 'Y-m-d');
try { $today = new \DateTimeImmutable($todayStr); } catch (\Throwable $e) { $today = new \DateTimeImmutable(); }
$sy = (string)($this->schoolYear ?? '');
$startYear = null; $endYear = null;
if (preg_match('/^(\d{4})\D+(\d{4})$/', $sy, $m)) {
$startYear = (int)$m[1];
$endYear = (int)$m[2];
} else {
$cy = (int)date('Y');
$cm = (int)date('n');
if ($cm >= 9) { // Sep-Dec
$startYear = $cy;
$endYear = $cy + 1;
} else {
$startYear = $cy - 1;
$endYear = $cy;
}
}
try {
$start = new \DateTimeImmutable(sprintf('%04d-09-01', $startYear));
$end = new \DateTimeImmutable(sprintf('%04d-05-31', $endYear));
} catch (\Throwable $e) {
return [];
}
if ($start < $today) {
$start = $today;
}
$dates = [];
for ($cursor = $start; $cursor <= $end; $cursor = $cursor->modify('+1 day')) {
if ((int)$cursor->format('w') === 0) { // Sunday
$dates[] = $cursor->format('Y-m-d');
}
}
return $dates;
}
/**
* GET /api/v1/administrator/absence
* Return current admin absence context and allowed dates
*/
public function absenceInfo()
{
$userId = $this->getCurrentUserId();
if (!$userId) {
return $this->error('Unauthorized', 401);
}
$admin = $this->user->find($userId);
$displayName = $admin ? trim(($admin['firstname'] ?? '') . ' ' . ($admin['lastname'] ?? '')) : 'Administrator';
$semester = (string)($this->semester ?? '');
$schoolYear = (string)($this->schoolYear ?? '');
$existing = $this->staffAttendance
->where('user_id', $userId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->orderBy('date', 'DESC')
->findAll();
return $this->success([
'admin_name' => $displayName,
'semester' => $semester,
'school_year' => $schoolYear,
'existing' => $existing,
'available_dates'=> $this->allowedAbsenceDates(),
], 'Absence info retrieved');
}
/**
* POST /api/v1/administrator/absence
* Submit absence entries for current admin
* Payload: { dates: ["YYYY-MM-DD",...], reason_type?: string, reason: string }
*/
public function submitAbsence()
{
$userId = $this->getCurrentUserId();
if (!$userId) {
return $this->error('Unauthorized', 401);
}
$payload = $this->request->getJSON(true) ?? [];
$dates = (array)($payload['dates'] ?? []);
$reasonType = trim((string)($payload['reason_type'] ?? ''));
$reasonText = trim((string)($payload['reason'] ?? ''));
$semester = (string)($this->semester ?? '');
$schoolYear = (string)($this->schoolYear ?? '');
if ($semester === '' || $schoolYear === '') {
return $this->error('Semester or school year not configured', 500);
}
if ($reasonText === '') {
return $this->error('Reason is required', 422);
}
$reasonBase = ($reasonType !== '') ? strtolower($reasonType) : '';
$reason = $reasonBase !== '' ? ($reasonBase . ': ' . $reasonText) : $reasonText;
$allowedDates = $this->allowedAbsenceDates();
$allowedSet = array_fill_keys($allowedDates, true);
$roleName = $this->user->getUserRole($userId) ?: null;
$saved = 0; $invalid = []; $savedDates = [];
$dates = array_values(array_unique(array_map('strval', $dates)));
foreach ($dates as $d) {
$d = trim((string)$d);
if ($d === '') continue;
$dt = date_create_from_format('Y-m-d', $d);
if (!$dt || $dt->format('Y-m-d') !== $d || empty($allowedSet[$d])) {
$invalid[] = $d;
continue;
}
$ok = $this->staffAttendance->upsertOne(
userId: $userId,
roleName: $roleName,
date: $d,
semester: $semester,
schoolYear: $schoolYear,
status: StaffAttendance::STATUS_ABSENT,
reason: $reason,
editorId: $userId
);
if ($ok) { $saved++; $savedDates[] = $d; }
}
// Best-effort principal email
try {
$user = $this->user->find($userId) ?: [];
$fullName = trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')) ?: 'Administrator';
$userEmail = $user['email'] ?? '';
$role = $roleName ?: 'admin';
$dateList = !empty($savedDates) ? implode(', ', $savedDates) : implode(', ', $dates);
$subject = sprintf('TimeOff Request: %s (%s) — %s', $fullName, ucfirst((string)$role), $dateList ?: 'No dates');
$body = '<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">'
. '<h3 style="margin:0 0 8px;">New TimeOff Request</h3>'
. '<p style="margin:0 0 12px;">A staff time-off request was submitted from the administrator portal.</p>'
. '<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;">'
. '<tr><td><strong>Name</strong></td><td>' . esc($fullName) . '</td></tr>'
. '<tr><td><strong>Email</strong></td><td>' . esc($userEmail) . '</td></tr>'
. '<tr><td><strong>Role</strong></td><td>' . esc((string)$role) . '</td></tr>'
. '<tr><td><strong>Semester</strong></td><td>' . esc($semester) . '</td></tr>'
. '<tr><td><strong>School Year</strong></td><td>' . esc($schoolYear) . '</td></tr>'
. '<tr><td><strong>Reason Type</strong></td><td>' . esc($reasonType ?: '-') . '</td></tr>'
. '<tr><td><strong>Reason</strong></td><td>' . esc($reasonText) . '</td></tr>'
. '<tr><td><strong>Dates</strong></td><td>' . esc($dateList ?: '-') . '</td></tr>'
. '<tr><td><strong>Submitted At</strong></td><td>' . esc(utc_now()) . '</td></tr>'
. '</table>'
. '</div>';
$mailer = \Config\Services::emailService();
$principalEmail = env('PRINCIPAL_EMAIL') ?: 'principal@alrahmaisgl.org';
$mailer->send($principalEmail, $subject, $body, 'notifications');
} catch (\Throwable $e) {
log_message('error', 'Failed to send TimeOff email (api admin): ' . $e->getMessage());
}
return $this->success([
'saved' => $saved,
'invalid' => $invalid,
'saved_dates' => $savedDates,
], $saved . ' day(s) saved as absent.');
}
/**
* Search users, students, parents, staff, emergency contacts
* GET /api/v1/administrator/search
*/
public function search()
{
$q = trim((string)($this->request->getGet('q') ?? ''));
if ($q === '') {
return $this->error('Search query is required', 400);
}
try {
$db = $this->db;
// 1) Tokenize
$rawTokens = preg_split('/[,\s]+/u', $q, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$tokens = array_values(array_filter(array_map('trim', $rawTokens)));
// 2) Phone variants per token
$phoneMap = [];
foreach ($tokens as $t) {
$digits = preg_replace('/\D+/', '', $t);
if ($digits === '') continue;
$v = [];
if (strlen($digits) >= 7) {
$v[] = $digits;
if (strlen($digits) === 10) {
$v[] = sprintf('(%s)-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = sprintf('%s %s %s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = '1' . $digits;
$v[] = '+1' . $digits;
$v[] = '+1 ' . sprintf('(%s) %s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = '+1-' . sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
} elseif (strlen($digits) === 11 && str_starts_with($digits, '1')) {
$ten = substr($digits, 1);
$v[] = $ten;
$v[] = sprintf('(%s)-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = sprintf('%s %s %s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = '+1' . $ten;
$v[] = '+1 ' . sprintf('(%s) %s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = '+1-' . sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
} else {
$v[] = $digits; // partials
}
}
if (!empty($v)) $phoneMap[$t] = array_values(array_unique($v));
}
// Helper
$applyMultiTokenLike = function ($qb, array $columns, array $tokens, array $phoneCols = []) use ($phoneMap) {
foreach ($tokens as $t) {
$qb->groupStart();
foreach ($columns as $i => $col) {
if ($i === 0) $qb->like($col, $t); else $qb->orLike($col, $t);
}
if (!empty($phoneMap[$t]) && !empty($phoneCols)) {
foreach ($phoneMap[$t] as $pv) {
foreach ($phoneCols as $pcol) { $qb->orLike($pcol, $pv); }
}
}
$qb->groupEnd();
}
return $qb;
};
// USERS (phone: cellphone)
$uCols = ['firstname', 'lastname', 'email', 'cellphone', 'school_id', 'city', 'state'];
$uQB = $db->table('users')->select('id, firstname, lastname, email, cellphone, school_id, city, state, school_year, semester');
$applyMultiTokenLike($uQB, $uCols, $tokens, ['cellphone']);
$users = $uQB->limit(150)->get()->getResultArray();
// STUDENTS (no phone field)
$sCols = ['firstname', 'lastname', 'school_id', 'rfid_tag', 'dob', 'gender'];
$sQB = $db->table('students')->select('id, parent_id, school_id, firstname, lastname, dob, gender, school_year, semester, rfid_tag');
$applyMultiTokenLike($sQB, $sCols, $tokens, []);
$students = $sQB->limit(150)->get()->getResultArray();
// PARENTS (phone: secondparent_phone)
$pCols = ['secondparent_firstname', 'secondparent_lastname', 'secondparent_email', 'secondparent_phone'];
$pQB = $db->table('parents')->select('id, firstparent_id, secondparent_firstname, secondparent_lastname, secondparent_email, secondparent_phone, school_year, semester');
$applyMultiTokenLike($pQB, $pCols, $tokens, ['secondparent_phone']);
foreach ($tokens as $t) { if (ctype_digit($t)) { $pQB->orWhere('firstparent_id', (int)$t)->orWhere('id', (int)$t); } }
$parents = $pQB->limit(150)->get()->getResultArray();
// STAFF (phone: phone)
$stCols = ['firstname', 'lastname', 'email', 'role_name', 'phone'];
$stQB = $db->table('staff')->select('id, user_id, firstname, lastname, email, phone, role_name, school_year, active_role');
$applyMultiTokenLike($stQB, $stCols, $tokens, ['phone']);
$staff = $stQB->limit(150)->get()->getResultArray();
// EMERGENCY CONTACTS (phone: cellphone)
$ecCols = ['emergency_contact_name', 'relation', 'email', 'cellphone'];
$ecQB = $db->table('emergency_contacts')->select('id, parent_id, emergency_contact_name, relation, cellphone, email, school_year, semester');
$applyMultiTokenLike($ecQB, $ecCols, $tokens, ['cellphone']);
$emergency = $ecQB->limit(150)->get()->getResultArray();
$results = [
'users' => $users,
'students' => $students,
'parents' => $parents,
'staff' => $staff,
'emergency_contacts' => $emergency,
'total' => count($users) + count($students) + count($parents) + count($staff) + count($emergency),
];
return $this->success($results, 'Search completed successfully');
} catch (\Throwable $e) {
log_message('error', 'Search error: ' . $e->getMessage());
return $this->error('Failed to perform search', 500);
}
}
/**
* Get enrollment/withdrawal data
* GET /api/v1/administrator/enrollment-withdrawal
*/
public function enrollmentWithdrawal()
{
$page = (int) ($this->request->getGet('page') ?? 1);
$perPage = min(100, (int) ($this->request->getGet('per_page') ?? 20));
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
try {
$query = $this->enrollment
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC');
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Enrollment/withdrawal data retrieved successfully');
} catch (\Exception $e) {
log_message('error', 'Enrollment withdrawal error: ' . $e->getMessage());
return $this->error('Failed to retrieve enrollment/withdrawal data', 500);
}
}
/**
* GET /api/v1/administrator/enrollment-withdrawal/data
* Rich data used by admin UI: students, classes, school years
*/
public function enrollmentWithdrawalData()
{
try {
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? ''));
if ($selectedYear === '') { $selectedYear = (string)$this->schoolYear; }
// Distinct school years
$yearsRows = $this->db->table('enrollments')->distinct()->select('school_year')->orderBy('school_year', 'DESC')->get()->getResultArray();
$schoolYears = array_values(array_filter(array_map(static fn($r) => isset($r['school_year']) ? (string)$r['school_year'] : null, $yearsRows)));
$students = $this->student->getStudentsWithClassAndEnrollment();
foreach ($students as &$s) {
$s['student_id'] = (int)($s['id'] ?? 0);
if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) {
$s['parent_id'] = (int)$s['secondparent_user_id'];
} else {
$s['parent_id'] = (int)($s['parent_id'] ?? 0);
}
$pf = trim((string)($s['parent_firstname'] ?? ''));
$pl = trim((string)($s['parent_lastname'] ?? ''));
if ($pf === '' && $pl === '' && !empty($s['parent_fullname'])) {
$parts = preg_split('/\s+/', trim((string)$s['parent_fullname']), 2);
$pf = $parts[0] ?? '';
$pl = $parts[1] ?? '';
}
$s['parent_label'] = trim($pf . ' ' . $pl) ?: 'Unknown Parent';
$s['parent_sort'] = trim(($pl !== '' ? $pl : $pf) . ' ' . $pf) ?: 'ZZZ Unknown Parent';
$s['is_new'] = (int) ($s['is_new'] ?? 0);
$s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
$statusForYear = $this->enrollment->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
if (!empty($statusForYear)) {
$s['enrollment_status'] = $statusForYear;
} elseif (($s['admission_status'] ?? null) === 'denied') {
$s['enrollment_status'] = 'denied';
}
$className = $this->studentClass->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
$s['class_section'] = $className ?: 'Class not Assigned';
$s['registration_date_order'] = !empty($s['registration_date']) ? date('Y-m-d', strtotime($s['registration_date'])) : '';
}
unset($s);
usort($students, function (array $a, array $b) {
$pa = $a['parent_sort'] ?? '';
$pb = $b['parent_sort'] ?? '';
if (strcasecmp($pa, $pb) === 0) {
$la = $a['lastname'] ?? '';
$lb = $b['lastname'] ?? '';
$cmp = strcasecmp($la, $lb);
if ($cmp !== 0) return $cmp;
return strcasecmp($a['firstname'] ?? '', $b['firstname'] ?? '');
}
return strcasecmp($pa, $pb);
});
$classes = $this->classSection
->select('id, class_section_id, class_section_name, school_year, semester')
->where('school_year', (string)$selectedYear)
->where('semester', (string)$this->semester)
->orderBy('class_section_name', 'ASC')
->findAll();
if (empty($classes)) {
$classes = $this->classSection
->select('id, class_section_id, class_section_name')
->orderBy('class_section_name', 'ASC')
->findAll();
}
return $this->success([
'students' => $students,
'classes' => $classes,
'semester' => (string)$this->semester,
'school_year' => (string)$selectedYear,
'schoolYears' => $schoolYears,
], 'Enrollment/withdrawal data');
} catch (\Throwable $e) {
log_message('error', 'enrollmentWithdrawalData error: {msg}', ['msg' => $e->getMessage()]);
return $this->error('Server error', 500);
}
}
/**
* POST /api/v1/administrator/enrollment-withdrawal/update
* Update enrollment statuses in batch and trigger notifications/refunds
* Payload: { enrollment_status: { student_id: status, ... } }
*/
public function updateEnrollmentStatuses()
{
$payload = $this->request->getJSON(true) ?? [];
$enrollmentStatuses = $payload['enrollment_status'] ?? null;
if (empty($enrollmentStatuses) || !is_array($enrollmentStatuses)) {
return $this->error('No enrollment statuses were submitted.', 422);
}
$refundService = new FeeCalculationService();
$this->db->transStart();
try {
$errors = [];
$groupsByParentStatus = [];
$parentInfo = [];
$refundParents = [];
$refundAmountByParent = [];
$validStatuses = [
'admission under review', 'payment pending', 'enrolled',
'withdraw under review', 'refund pending', 'withdrawn', 'denied', 'waitlist',
];
foreach ($enrollmentStatuses as $studentId => $newEnrollmentStatus) {
if (!in_array($newEnrollmentStatus, $validStatuses, true)) {
$errors[] = "Invalid enrollment status '$newEnrollmentStatus' for student ID $studentId.";
continue;
}
if ($newEnrollmentStatus === 'denied') { $admissionStatus = 'denied'; }
elseif (in_array($newEnrollmentStatus, ['enrolled', 'payment pending'], true)) { $admissionStatus = 'accepted'; }
else { $admissionStatus = 'pending'; }
$enrollmentRow = $this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $this->schoolYear)
->get()
->getRowArray();
if (!$enrollmentRow) {
$stu = $this->student->find((int)$studentId) ?? [];
$parentId = (int)($stu['parent_id'] ?? ($stu['secondparent_user_id'] ?? 0));
if (!$parentId) { $errors[] = "No parent ID found for student ID $studentId."; continue; }
$isWithdrawn = in_array($newEnrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review'], true) ? 1 : 0;
$ok = $this->db->table('enrollments')->insert([
'student_id' => (int)$studentId,
'parent_id' => $parentId,
'school_year' => (string)$this->schoolYear,
'semester' => (string)$this->semester,
'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
'is_withdrawn' => $isWithdrawn,
'enrollment_status' => $newEnrollmentStatus,
'admission_status' => $admissionStatus,
'created_at' => utc_now(),
'updated_at' => utc_now(),
]);
if (!$ok) { $errors[] = "Failed to create enrollment for student ID $studentId."; continue; }
$studentRow = $this->student->find($studentId) ?? [];
$studentName = trim(($studentRow['firstname'] ?? '') . ' ' . ($studentRow['lastname'] ?? '')) ?: "Student #{$studentId}";
if (!isset($parentInfo[$parentId])) {
$p = $this->user->find($parentId) ?? [];
$parentInfo[$parentId] = [
'user_id' => $p['id'] ?? $parentId,
'email' => $p['email'] ?? null,
'firstname' => $p['firstname'] ?? '',
'lastname' => $p['lastname'] ?? '',
];
}
$groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [
'student_id' => (int) $studentId,
'student_name' => $studentName,
];
if ($newEnrollmentStatus === 'refund pending') { $refundParents[$parentId] = true; }
continue;
}
$oldStatus = $enrollmentRow['enrollment_status'] ?? null;
$parentId = $enrollmentRow['parent_id'] ?? null;
if (!$parentId) { $errors[] = "No parent ID found for student ID $studentId."; continue; }
if ($oldStatus === $newEnrollmentStatus) { continue; }
$updated = $this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $this->schoolYear)
->update([
'enrollment_status' => $newEnrollmentStatus,
'admission_status' => $admissionStatus,
'updated_at' => utc_now(),
]);
if (!$updated) { $errors[] = "Failed to update enrollment for student ID $studentId."; continue; }
$studentRow = $this->student->find($studentId);
$studentName = trim(($studentRow['firstname'] ?? '') . ' ' . ($studentRow['lastname'] ?? '')) ?: "Student #{$studentId}";
if (!isset($parentInfo[$parentId])) {
$p = $this->user->find($parentId) ?? [];
$parentInfo[$parentId] = [
'user_id' => $p['id'] ?? $parentId,
'email' => $p['email'] ?? null,
'firstname' => $p['firstname'] ?? '',
'lastname' => $p['lastname'] ?? '',
];
}
$groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [
'student_id' => (int) $studentId,
'student_name' => $studentName,
];
if ($newEnrollmentStatus === 'refund pending') { $refundParents[$parentId] = true; }
}
// Refunds
foreach (array_keys($refundParents) as $pid) {
$students = $this->enrollment
->where('parent_id', $pid)
->where('school_year', $this->schoolYear)
->findAll();
if (empty($students)) { continue; }
$invoice = $this->invoice->where('parent_id', $pid)
->where('school_year', $this->schoolYear)
->orderBy('created_at', 'DESC')
->first();
if (!$invoice) { $errors[] = "No invoice found for parent ID $pid (for refund calc)."; continue; }
$refundAmount = $refundService->calculateRefund($students, $pid);
$refundAmountByParent[$pid] = $refundAmount;
$existingRefund = $this->refund->where('invoice_id', $invoice['id'])->first();
if ($existingRefund) {
$this->refund->update($existingRefund['id'], [
'refund_amount' => $refundAmount,
'status' => 'Pending',
'updated_by' => $this->getCurrentUserId(),
]);
} else {
$this->refund->insert([
'parent_id' => $pid,
'school_year' => $invoice['school_year'],
'invoice_id' => $invoice['id'],
'refund_amount' => $refundAmount,
'refund_paid_amount' => 0.0,
'status' => 'Pending',
'requested_at' => utc_now(),
'updated_by' => $this->getCurrentUserId(),
]);
}
}
$this->db->transComplete();
if (!$this->db->transStatus()) {
return $this->error('A database error occurred. Changes were rolled back.', 500);
}
// Build response summary
return $this->success([
'errors' => $errors,
'groupsByParentStatus' => $groupsByParentStatus,
], empty($errors) ? 'Enrollment statuses updated.' : 'Enrollment updated with some errors.');
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Enrollment withdrawal error: ' . $e->getMessage());
return $this->error('An unexpected error occurred while processing enrollments.', 500);
}
}
/**
* GET /api/v1/administrator/students
* Return student profiles with parent/emergency and class section name
*/
public function studentProfiles()
{
$db = $this->db;
$isPg = ($db->getPlatform() === 'Postgre');
if (!$isPg) { $db->query('SET SESSION group_concat_max_len = 8192'); }
$b = $db->table('students');
if ($isPg) {
$students = $b->select([
'students.*',
'users.firstname AS parent_firstname',
'users.lastname AS parent_lastname',
'users.email AS parent_email',
'users.cellphone AS parent_phone',
"(SELECT ec.emergency_contact_name FROM emergency_contacts ec WHERE ec.parent_id = students.parent_id ORDER BY ec.id ASC LIMIT 1) AS emergency_name",
"(SELECT ec.relation FROM emergency_contacts ec WHERE ec.parent_id = students.parent_id ORDER BY ec.id ASC LIMIT 1) AS emergency_relationship",
"(SELECT ec.cellphone FROM emergency_contacts ec WHERE ec.parent_id = students.parent_id ORDER BY ec.id ASC LIMIT 1) AS emergency_phone",
"(SELECT ec.email FROM emergency_contacts ec WHERE ec.parent_id = students.parent_id ORDER BY ec.id ASC LIMIT 1) AS emergency_email",
"(SELECT STRING_AGG(DISTINCT sa.allergy, ', ' ORDER BY sa.allergy) FROM student_allergies sa WHERE sa.student_id = students.id) AS allergies",
"(SELECT STRING_AGG(DISTINCT smc.condition_name, ', ' ORDER BY smc.condition_name) FROM student_medical_conditions smc WHERE smc.student_id = students.id) AS medical_conditions",
])->join('users', 'users.id = students.parent_id', 'left')
->orderBy('students.lastname', 'ASC')->orderBy('students.firstname', 'ASC')
->get()->getResultArray();
} else {
$students = $b->select([
'students.*',
'users.firstname AS parent_firstname',
'users.lastname AS parent_lastname',
'users.email AS parent_email',
'users.cellphone AS parent_phone',
'MIN(emergency_contacts.emergency_contact_name) AS emergency_name',
'MIN(emergency_contacts.relation) AS emergency_relationship',
'MIN(emergency_contacts.cellphone) AS emergency_phone',
'MIN(emergency_contacts.email) AS emergency_email',
"GROUP_CONCAT(DISTINCT student_allergies.allergy ORDER BY student_allergies.allergy SEPARATOR ', ') AS allergies",
"GROUP_CONCAT(DISTINCT student_medical_conditions.condition_name ORDER BY student_medical_conditions.condition_name SEPARATOR ', ') AS medical_conditions",
])->join('users', 'users.id = students.parent_id', 'left')
->join('emergency_contacts', 'emergency_contacts.parent_id = students.parent_id', 'left')
->join('student_allergies', 'student_allergies.student_id = students.id', 'left')
->join('student_medical_conditions', 'student_medical_conditions.student_id = students.id', 'left')
->groupBy('students.id')
->orderBy('students.lastname', 'ASC')->orderBy('students.firstname', 'ASC')
->get()->getResultArray();
}
foreach ($students as $i => $row) {
$sid = (int) ($row['id'] ?? 0);
$students[$i]['class_section_name'] = $sid > 0 ? (string) ($this->studentClass->getClassSectionNameByStudentId($sid) ?? '') : '';
}
return $this->success(['students' => $students]);
}
/**
* GET /api/v1/administrator/parents
* Return parent profiles with latest invoice paid/balance
*/
public function parentProfiles()
{
$allUsers = $this->user->findAll();
$parents = [];
foreach ($allUsers as $user) {
$roles = $this->userRole->getRolesByUserId($user['id']);
$isParent = false;
if (is_array($roles)) {
foreach ($roles as $role) {
if (isset($role['role_name']) && $role['role_name'] === 'parent') { $isParent = true; break; }
}
}
if ($isParent) {
$paidAmount = $this->invoice->getLatestInvoicePaidAmount($user['id']) ?? 0;
$balance = $this->invoice->getLatestInvoiceBalance($user['id']) ?? 0;
$parents[] = [
'id' => $user['id'],
'school_id' => $user['school_id'],
'firstname' => $user['firstname'],
'lastname' => $user['lastname'],
'email' => $user['email'],
'cellphone' => $user['cellphone'],
'gender' => $user['gender'],
'created_at' => $user['created_at'],
'school_year' => $user['school_year'],
'paid_amount' => $paidAmount,
'balance' => $balance,
];
}
}
return $this->success(['parents' => $parents]);
}
/**
* GET /api/v1/administrator/enrollment/new-students
* Return students marked as new with parent/emergency + enrollment metadata.
*/
public function newStudents()
{
try {
$rows = $this->student->getStudentsWithParentsAndEmergency($this->schoolYear, 1);
$newStudents = [];
foreach ($rows as $row) {
$studentId = (int) ($row['id'] ?? 0);
$classSection = $studentId > 0
? ($this->studentClass->getClassSectionsByStudentId($studentId, $this->schoolYear) ?: 'Class not Assigned')
: 'Class not Assigned';
$enrollmentStatus = $studentId > 0
? $this->enrollment->getEnrollmentStatus($studentId, $this->schoolYear)
: null;
$registrationDate = $row['registration_date'] ?? null;
if (!empty($registrationDate)) {
try {
$registrationDate = (new \DateTime($registrationDate))->format('Y-m-d');
} catch (\Throwable $e) {
$registrationDate = null;
}
}
$newStudents[] = array_merge($row, [
'student_id' => $studentId,
'class_section' => $classSection,
'enrollment_status' => $enrollmentStatus,
'new_student' => ((int) ($row['is_new'] ?? 0) === 1) ? 'Yes' : 'No',
'modalIdContact' => 'contact_' . $studentId,
'registration_date' => $registrationDate,
]);
}
return $this->success([
'new_students' => $newStudents,
'total_new' => count($newStudents),
'school_year' => $this->schoolYear,
'semester' => $this->semester,
], 'New students retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'newStudents error: ' . $e->getMessage());
return $this->error('Failed to fetch new students', 500);
}
}
}
Binary file not shown.
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api\Assignment;
use App\Http\Controllers\Controller;
use App\Http\Requests\Assignment\StoreAssignmentRequest;
use App\Http\Resources\Assignment\AssignmentOverviewResource;
use App\Http\Resources\Assignment\AssignmentSectionResource;
use App\Services\Assignment\AssignmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AssignmentApiController extends Controller
{
public function __construct(
protected AssignmentService $assignmentService
) {}
public function index(Request $request): JsonResponse
{
$data = $this->assignmentService->getAssignmentsOverview(
schoolYear: $request->query('school_year'),
semester: $request->query('semester'),
);
return response()->json([
'message' => 'Assignments retrieved successfully.',
'data' => new AssignmentOverviewResource((object) $data),
]);
}
public function store(StoreAssignmentRequest $request): JsonResponse
{
$assignment = $this->assignmentService->storeAssignment(
data: $request->validated(),
updatedBy: $request->user()?->id
);
return response()->json([
'message' => 'Assignment saved successfully.',
'data' => $assignment,
], 201);
}
public function classAssignmentData(): JsonResponse
{
$data = $this->assignmentService->getClassAssignmentData();
return response()->json([
'message' => 'Class assignment data retrieved successfully.',
'data' => [
'classSections' => AssignmentSectionResource::collection(collect($data['classSections'])),
'semester' => $data['semester'],
'school_year' => $data['school_year'],
],
]);
}
}
@@ -1,47 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreStudentAssignmentRequest;
use App\Services\AssignmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AssignmentController extends Controller
{
public function index(Request $request, AssignmentService $service): JsonResponse
{
$payload = $service->buildClassAssignments([
'semester' => (string) $request->query('semester', ''),
'school_year' => (string) $request->query('school_year', ''),
'for_api' => true,
]);
return response()->json($payload);
}
/**
* Save / update a student-class assignment
*/
public function store(StoreStudentAssignmentRequest $request, AssignmentService $service): JsonResponse
{
$assignment = $service->saveStudentAssignment(
$request->validated(),
$request->user()?->id // fallback handled in service
);
return response()->json([
'message' => 'Assignment saved successfully',
'data' => $assignment,
], 201);
}
/**
* Optional endpoint if you want old `classAssignmentData()` naming preserved
*/
public function classAssignmentData(Request $request, AssignmentService $service): JsonResponse
{
return $this->index($request, $service);
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Api\Attendance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Attendance\AdminAddAttendanceEntryRequest;
use App\Http\Requests\Attendance\UpdateAttendanceManagementRequest;
use App\Http\Resources\Attendance\DailyAttendanceResource;
use App\Services\Attendance\AttendanceQueryService;
use App\Services\Attendance\AttendanceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;
class AdminAttendanceApiController extends Controller
{
public function __construct(
protected AttendanceQueryService $queryService,
protected AttendanceService $attendanceService
) {}
public function dailyData(Request $request): DailyAttendanceResource
{
return new DailyAttendanceResource(
$this->queryService->buildDailyAttendanceData(
(string)$request->query('semester', $this->attendanceService->currentSemester()),
(string)$request->query('school_year', $this->attendanceService->currentSchoolYear())
)
);
}
public function updateManagement(UpdateAttendanceManagementRequest $request): JsonResponse
{
try {
return response()->json(
$this->attendanceService->updateAttendanceManagement(auth()->user(), $request->validated())
);
} catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
}
public function addEntry(AdminAddAttendanceEntryRequest $request): JsonResponse
{
try {
return response()->json(
$this->attendanceService->adminAddEntry(auth()->user(), $request->validated())
);
} catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
}
}
@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Api;
namespace App\Http\Controllers\Api\Attendance;
use App\Http\Controllers\Controller;
use App\Http\Requests\AttendanceCommentTemplate\StoreAttendanceCommentTemplateRequest;
@@ -12,90 +12,71 @@ use Illuminate\Http\Request;
class AttendanceCommentTemplateController extends Controller
{
public function __construct(
private readonly AttendanceCommentTemplateService $service
) {}
protected AttendanceCommentTemplateService $service
) {
}
/**
* GET /api/attendance-comment-templates
*/
public function index(Request $request): JsonResponse
{
$activeOnly = $request->boolean('active_only', false);
$templates = $this->service->list($activeOnly);
$activeOnly = filter_var(
$request->query('active_only'),
FILTER_VALIDATE_BOOL
) === true;
return response()->json([
'status' => 'success',
'data' => $templates,
'data' => $this->service->list($activeOnly),
]);
}
/**
* POST /api/attendance-comment-templates
*/
public function store(StoreAttendanceCommentTemplateRequest $request): JsonResponse
public function listData(Request $request): JsonResponse
{
$template = $this->service->create($request->validated());
$activeOnly = filter_var(
$request->query('active_only'),
FILTER_VALIDATE_BOOL
) === true;
return response()->json([
'status' => 'success',
'templates' => $this->service->list($activeOnly),
]);
}
public function show(int $id): JsonResponse
{
return response()->json([
'status' => 'success',
'data' => $this->service->findOrFail($id),
]);
}
public function store(StoreAttendanceCommentTemplateRequest $request): JsonResponse
{
$created = $this->service->create($request->validated());
return response()->json([
'status' => 'success',
'message' => 'Template created successfully.',
'data' => $template,
'data' => $created,
], 201);
}
/**
* GET /api/attendance-comment-templates/{id}
*/
public function show(int $id): JsonResponse
public function update(UpdateAttendanceCommentTemplateRequest $request, int $id): JsonResponse
{
$template = $this->service->findOrFail($id);
$updated = $this->service->update($id, $request->validated());
return response()->json([
'status' => 'success',
'data' => $template,
'data' => $updated,
]);
}
/**
* PUT/PATCH /api/attendance-comment-templates/{id}
*/
public function update(UpdateAttendanceCommentTemplateRequest $request, int $id): JsonResponse
{
$template = $this->service->update($id, $request->validated());
return response()->json([
'status' => 'success',
'message' => 'Template updated successfully.',
'data' => $template,
]);
}
/**
* DELETE /api/attendance-comment-templates/{id}
*/
public function destroy(int $id): JsonResponse
{
$this->service->delete($id);
return response()->json([
'status' => 'success',
'status' => 'success',
'message' => 'Template deleted successfully.',
]);
}
/**
* Optional compatibility endpoint with your old CI shape:
* GET /api/attendance-comment-templates/list-data
*/
public function listData(Request $request): JsonResponse
{
$activeOnly = $request->boolean('active_only', false);
$templates = $this->service->list($activeOnly);
return response()->json([
'templates' => $templates,
]);
}
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Api\Attendance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Attendance\SaveAdminAttendanceRequest;
use App\Http\Requests\Attendance\SaveStaffAttendanceCellRequest;
use App\Http\Resources\Attendance\AdminAttendanceResource;
use App\Http\Resources\Attendance\StaffMonthResource;
use App\Services\Attendance\StaffAttendanceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class StaffAttendanceApiController extends Controller
{
public function __construct(
protected StaffAttendanceService $staffAttendanceService
) {}
public function monthData(Request $request): StaffMonthResource
{
return new StaffMonthResource(
$this->staffAttendanceService->monthData(
(string)$request->query('semester'),
(string)$request->query('school_year')
)
);
}
public function admins(Request $request): AdminAttendanceResource
{
return new AdminAttendanceResource(
$this->staffAttendanceService->adminsAttendanceData(
(string)$request->query('date'),
(string)$request->query('semester'),
(string)$request->query('school_year')
)
);
}
public function saveAdmins(SaveAdminAttendanceRequest $request): JsonResponse
{
try {
return response()->json(
$this->staffAttendanceService->saveAdmins(auth()->user(), $request->validated())
);
} catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
}
public function saveCell(SaveStaffAttendanceCellRequest $request): JsonResponse
{
try {
return response()->json(
$this->staffAttendanceService->saveCell(auth()->user(), $request->validated())
);
} catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
}
public function monthCsv(Request $request): StreamedResponse
{
return $this->staffAttendanceService->monthCsv(
(string)$request->query('semester'),
(string)$request->query('school_year'),
(string)$request->query('scope')
);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api\Attendance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Attendance\TeacherSubmitAttendanceRequest;
use App\Http\Resources\Attendance\TeacherAttendanceFormResource;
use App\Http\Resources\Attendance\TeacherGridResource;
use App\Services\Attendance\AttendanceQueryService;
use App\Services\Attendance\AttendanceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;
class TeacherAttendanceApiController extends Controller
{
public function __construct(
protected AttendanceQueryService $queryService,
protected AttendanceService $attendanceService
) {}
public function grid(Request $request): TeacherGridResource
{
$semester = (string)($request->query('semester') ?: $this->attendanceService->currentSemester());
$schoolYear = (string)($request->query('school_year') ?: $this->attendanceService->currentSchoolYear());
$date = (string)($request->query('date') ?: now()->toDateString());
$sectionCode = (int)$request->query('class_section_id', 0);
return new TeacherGridResource(
$this->queryService->teacherGrid($semester, $schoolYear, $date, $sectionCode)
);
}
public function form(Request $request): JsonResponse|TeacherAttendanceFormResource
{
try {
return new TeacherAttendanceFormResource(
$this->queryService->teacherAttendanceFormData(
auth()->id(),
(int)$request->query('class_section_id', 0)
)
);
} catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
}
public function submit(TeacherSubmitAttendanceRequest $request): JsonResponse
{
try {
return response()->json(
$this->attendanceService->submitTeacherAttendance(auth()->user(), $request->validated())
);
} catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
}
}
@@ -1,519 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\AttendanceData;
use App\Models\AttendanceDay;
use App\Models\Calendar;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\StaffAttendance;
use App\Models\StudentClass;
use App\Models\Student;
use App\Models\TeacherClass;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class AttendanceController extends BaseApiController
{
protected AttendanceData $attendance;
protected StudentClass $studentClass;
protected ClassSection $classSection;
protected Configuration $config;
protected TeacherClass $teacherClass;
protected StaffAttendance $staffAttendance;
protected AttendanceDay $attendanceDay;
protected Student $student;
protected Calendar $calendar;
protected User $user;
protected string $schoolYear;
protected string $semester;
protected int $enableAttendance;
public function __construct()
{
$this->attendance = model(AttendanceData::class);
$this->studentClass = model(StudentClass::class);
$this->classSection = model(ClassSection::class);
$this->config = model(Configuration::class);
$this->teacherClass = model(TeacherClass::class);
$this->staffAttendance = model(StaffAttendance::class);
$this->attendanceDay = model(AttendanceDay::class);
$this->student = model(Student::class);
$this->calendar = model(Calendar::class);
$this->user = model(User::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
$this->enableAttendance = (int) ($this->config->getConfig('enable_attendance') ?? 0);
}
public function index()
{
$page = (int) ($this->request->getGet('page') ?? 1);
$perPage = min(100, (int) ($this->request->getGet('per_page') ?? 20));
$studentId = $this->request->getGet('student_id');
$date = $this->request->getGet('date');
$builder = $this->attendance;
if ($studentId) {
$builder = $builder->where('student_id', $studentId);
}
if ($date) {
$builder = $builder->where('date', $date);
}
$data = $builder->paginate($perPage, 'default', $page);
$pager = $builder->pager;
return $this->respondSuccess([
'data' => $data,
'pagination' => [
'current_page' => $pager->getCurrentPage(),
'per_page' => $pager->getPerPage(),
'total' => $pager->getTotal(),
'total_pages' => $pager->getPageCount(),
],
], 'Attendance records retrieved successfully');
}
public function show($id = null)
{
$record = $this->attendance->find($id);
if (!$record) {
return $this->respondError('Attendance record not found', Response::HTTP_NOT_FOUND);
}
return $this->respondSuccess($record, 'Attendance record retrieved successfully');
}
public function store()
{
$data = $this->request->getJSON(true);
$rules = [
'student_id' => 'required|integer',
'date' => 'required|valid_date[Y-m-d]',
'status' => 'required|in_list[present,absent,late]',
'notes' => 'permit_empty|string',
];
if (!$this->validate($rules)) {
return $this->respondValidationError($this->validator->getErrors());
}
$existing = $this->attendance
->where('student_id', $data['student_id'])
->where('date', $data['date'])
->first();
if ($existing) {
return $this->respondError('Attendance already recorded for this student and date', 409);
}
$this->attendance->insert($data);
$id = $this->attendance->getInsertID();
return $this->respondCreated([
'id' => $id,
'data' => $data,
], 'Attendance record created successfully');
}
public function update($id = null)
{
$record = $this->attendance->find($id);
if (!$record) {
return $this->respondError('Attendance record not found', Response::HTTP_NOT_FOUND);
}
$data = $this->request->getJSON(true);
$rules = [
'status' => 'permit_empty|in_list[present,absent,late]',
'notes' => 'permit_empty|string',
];
if (!$this->validate($rules)) {
return $this->respondValidationError($this->validator->getErrors());
}
$this->attendance->update($id, $data);
return $this->respondSuccess($this->attendance->find($id), 'Attendance record updated successfully');
}
public function delete($id = null)
{
$record = $this->attendance->find($id);
if (!$record) {
return $this->respondError('Attendance record not found', Response::HTTP_NOT_FOUND);
}
$this->attendance->delete($id);
return $this->respondDeleted(null, 'Attendance record deleted successfully');
}
public function student($id = null)
{
$records = $this->attendance
->where('student_id', $id)
->orderBy('date', 'DESC')
->findAll();
return $this->respondSuccess($records, 'Student attendance retrieved successfully');
}
public function classRoster($classSectionId)
{
$date = $this->request->getGet('date') ?: date('Y-m-d');
$db = $this->getDatabaseConnection();
$rows = $db->table('student_class sc')
->select('s.id AS student_id, s.firstname, s.lastname')
->join('students s', 's.id = sc.student_id', 'inner')
->where('sc.class_section_id', (int)$classSectionId)
->where('sc.school_year', $this->schoolYear)
->orderBy('s.lastname', 'ASC')->orderBy('s.firstname', 'ASC')
->get()->getResultArray();
$list = [];
foreach ($rows as $r) {
$att = $this->attendance->getAttendance((int)$r['student_id'], (int)$classSectionId, $date);
$list[] = [
'student_id' => (int)$r['student_id'],
'firstname' => $r['firstname'],
'lastname' => $r['lastname'],
'status' => $att['status'] ?? null,
'reason' => $att['reason'] ?? null,
];
}
return $this->success([
'class_section_id' => (int)$classSectionId,
'date' => $date,
'students' => $list,
], 'Class roster attendance retrieved');
}
public function recordClass($classSectionId)
{
$payload = $this->request->getJSON(true) ?? [];
$date = isset($payload['date']) ? trim((string)$payload['date']) : '';
$records = isset($payload['records']) && is_array($payload['records']) ? $payload['records'] : [];
if ($date === '' || empty($records)) {
return $this->respondError('Invalid payload: date and records are required.', Response::HTTP_BAD_REQUEST);
}
$allowedStatuses = ['present', 'absent', 'late'];
$now = utc_now();
DB::beginTransaction();
try {
foreach ($records as $record) {
$studentId = (int) ($record['student_id'] ?? 0);
$status = strtolower(trim((string) ($record['status'] ?? '')));
$reason = isset($record['reason']) ? trim((string) $record['reason']) : null;
if ($studentId <= 0 || !in_array($status, $allowedStatuses, true)) {
DB::rollBack();
return $this->respondError('Invalid record provided.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$existing = $this->attendance
->where('student_id', $studentId)
->where('class_section_id', (int)$classSectionId)
->where('date', $date)
->first();
$payload = [
'class_section_id' => (int) $classSectionId,
'student_id' => $studentId,
'status' => $status,
'reason' => $reason ?: null,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'date' => $date,
'updated_at' => $now,
];
if ($existing) {
$payload['modified_by'] = $this->getCurrentUserId();
$this->attendance->update($existing['id'], $payload);
} else {
$payload['created_at'] = $now;
$payload['modified_by'] = $this->getCurrentUserId();
$this->attendance->insert($payload);
}
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
return $this->respondError('Failed to record attendance.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success(null, 'Class attendance recorded successfully');
}
public function teacherSectionGrid(): JsonResponse
{
$semester = (string) ($this->request->get('semester') ?? $this->semester);
$schoolYear = (string) ($this->request->get('school_year') ?? $this->schoolYear);
$date = (string) ($this->request->get('date') ?? local_date(utc_now(), 'Y-m-d'));
$sectionCode = (int) ($this->request->get('class_section_id') ?? 0);
$assignedBySection = (array) ($this->teacherClass->assignedBySectionForTerm($semester, $schoolYear) ?? []);
$sectionCodes = array_keys($assignedBySection);
$labels = [];
if (!empty($sectionCodes)) {
$labelRows = $this->classSection
->select('class_section_id, class_section_name')
->whereIn('class_section_id', $sectionCodes)
->orderBy('class_section_id', 'ASC')
->findAll();
foreach ($labelRows as $row) {
$code = (int) ($row['class_section_id'] ?? 0);
if ($code <= 0) continue;
$name = trim((string) ($row['class_section_name'] ?? ''));
$labels[$code] = $name !== '' ? $name : ('Section #' . $code);
}
foreach ($sectionCodes as $code) {
if (!isset($labels[$code])) {
$labels[$code] = 'Section #' . $code;
}
}
}
$grid = [];
$locked = false;
if ($sectionCode > 0) {
$assigned = (array) ($this->teacherClass->assignedForSectionTerm($sectionCode, $semester, $schoolYear) ?? []);
$teacherIds = array_map(static fn($row) => (int) ($row['teacher_id'] ?? 0), $assigned);
$teacherIds = array_values(array_filter($teacherIds));
$statusMap = [];
if (!empty($teacherIds)) {
$rows = DB::table('staff_attendance as sa')
->select('sa.user_id', 'sa.status', 'sa.reason')
->where('sa.semester', $semester)
->where('sa.school_year', $schoolYear)
->where('sa.date', $date)
->whereIn('sa.user_id', $teacherIds)
->get();
foreach ($rows as $row) {
$statusMap[(int) $row->user_id] = [
'status' => $row->status ?? null,
'reason' => $row->reason ?? null,
];
}
}
foreach ($assigned as $teacherRow) {
$tid = (int) ($teacherRow['teacher_id'] ?? 0);
$name = trim(($teacherRow['firstname'] ?? '') . ' ' . ($teacherRow['lastname'] ?? ''));
$name = $name !== '' ? $name : 'User #' . $tid;
$posRaw = strtolower((string) ($teacherRow['position'] ?? 'main'));
$pos = in_array($posRaw, ['main', 'ta'], true) ? $posRaw : 'main';
$cell = $statusMap[$tid] ?? ['status' => null, 'reason' => null];
$grid[] = [
'teacher_id' => $tid,
'name' => $name,
'position' => $pos,
'status' => $cell['status'],
'reason' => $cell['reason'],
];
}
try {
$locked = (bool) $this->attendanceDay->isFinalized($sectionCode, $date, $semester, $schoolYear);
} catch (\Throwable $e) {
$locked = false;
}
}
return $this->success([
'semester' => $semester,
'school_year' => $schoolYear,
'date' => $date,
'class_section_id' => $sectionCode,
'sections' => $labels,
'grid' => $grid,
'locked' => $locked,
], 'Teacher attendance grid retrieved');
}
public function teacherUpdateData(): JsonResponse
{
$teacherId = (int) ($this->request->get('teacher_id') ?? $this->getCurrentUserId() ?? 0);
if ($teacherId <= 0) {
return $this->respondError('Teacher not found or unauthorized.', Response::HTTP_UNAUTHORIZED);
}
$classSectionParam = (int) ($this->request->get('class_section_id') ?? 0);
if ($classSectionParam <= 0) {
$classSection = $this->teacherClass->getClassSectionIdByUserId($teacherId);
if (empty($classSection)) {
return $this->respondError('No class assigned to this teacher.', Response::HTTP_BAD_REQUEST);
}
$classSectionId = (int) ($classSection['class_section_id'] ?? 0);
} else {
$classSectionId = $classSectionParam;
}
if ($classSectionId <= 0) {
return $this->respondError('Invalid class section.', Response::HTTP_BAD_REQUEST);
}
$semester = (string) ($this->request->get('semester') ?? $this->semester);
$schoolYear = (string) ($this->request->get('school_year') ?? $this->schoolYear);
$today = local_date(utc_now(), 'Y-m-d');
$recentDatesRows = $this->attendance
->select('date')
->distinct()
->orderBy('date', 'DESC')
->limit(3)
->findAll();
$recentDates = array_map(static fn($row) => (string) ($row['date'] ?? ''), $recentDatesRows);
$recentDates = array_values(array_filter($recentDates, static fn($d) => $d !== ''));
$allDates = array_values(array_unique(array_merge([$today], $recentDates)));
$dateSlots = [
'day_before_before_yesterday' => $allDates[3] ?? null,
'day_before_yesterday' => $allDates[2] ?? null,
'yesterday' => $allDates[1] ?? null,
'today' => $allDates[0] ?? $today,
];
$teacher = $this->user->find($teacherId);
$teacherName = $teacher ? trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')) : 'Teacher';
$students = (array) ($this->student->getByClassAndYear($classSectionId, $schoolYear) ?? []);
$studentIds = array_map(static fn($row) => (int) ($row['id'] ?? 0), $students);
$studentIds = array_values(array_filter($studentIds));
$studentAttendance = [];
if (!empty($studentIds) && !empty($allDates)) {
$rows = $this->attendance
->newQuery()
->whereIn('student_id', $studentIds)
->whereIn('date', $allDates)
->get()
->map(fn($row) => (array) $row)
->toArray();
foreach ($rows as $row) {
$studentAttendance[(int) $row['student_id']][$row['date']] = $row['status'] ?? null;
}
}
$studentsPayload = [];
foreach ($students as $student) {
$sid = (int) ($student['id'] ?? 0);
$slots = [];
foreach ($dateSlots as $key => $date) {
$slots[$key] = $date ? ($studentAttendance[$sid][$date] ?? 'N/A') : 'N/A';
}
$studentsPayload[] = [
'id' => $sid,
'firstname' => $student['firstname'] ?? '',
'lastname' => $student['lastname'] ?? '',
'school_id' => $student['school_id'] ?? '',
'statuses' => $slots,
];
}
$assignedTeachers = (array) ($this->teacherClass->assignedForSectionTerm($classSectionId, $semester, $schoolYear) ?? []);
$teacherIds = array_map(static fn($row) => (int) ($row['teacher_id'] ?? 0), $assignedTeachers);
$teacherIds = array_values(array_filter($teacherIds));
$teacherStatusMap = [];
if (!empty($teacherIds) && !empty($allDates)) {
$rows = DB::table('staff_attendance')
->select('user_id', 'date', 'status')
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('user_id', $teacherIds)
->whereIn('date', $allDates)
->get();
foreach ($rows as $row) {
$teacherStatusMap[(int) $row->user_id][$row->date] = $row->status ?? null;
}
}
$teacherRows = [];
foreach ($assignedTeachers as $row) {
$tid = (int) ($row['teacher_id'] ?? 0);
$posRaw = strtolower((string) ($row['position'] ?? 'main'));
$position = in_array($posRaw, ['main', 'ta'], true) ? $posRaw : 'main';
$name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? ''));
if ($name === '') {
$name = 'User #' . $tid;
}
$slots = [];
foreach ($dateSlots as $key => $date) {
$slots[$key] = $date ? ($teacherStatusMap[$tid][$date] ?? 'N/A') : 'N/A';
}
$teacherRows[] = [
'teacher_id' => $tid,
'name' => $name,
'position' => $position,
'statuses' => $slots,
];
}
$wantedDates = array_values(array_filter(array_unique(array_filter($dateSlots))));
$noSchool = [];
if (!empty($wantedDates)) {
$events = $this->calendar
->where('no_school', 1)
->whereIn('date', $wantedDates)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
foreach ($events as $event) {
$d = (string) ($event['date'] ?? '');
if ($d === '') continue;
$noSchool[$d] = [
'title' => $event['title'] ?? 'No School',
'description' => $event['description'] ?? '',
];
}
}
return $this->success([
'teacher' => [
'id' => $teacherId,
'name' => $teacherName,
],
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear,
'dates' => $dateSlots,
'students' => $studentsPayload,
'teachers' => $teacherRows,
'no_school_days' => $noSchool,
'enable_attendance'=> $this->enableAttendance,
], 'Teacher attendance update data retrieved');
}
}
@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers\Api\AttendanceTracking;
use App\Http\Controllers\Controller;
use App\Http\Requests\AttendanceTracking\ComposeAttendanceEmailRequest;
use App\Http\Requests\AttendanceTracking\ParentsInfoRequest;
use App\Http\Requests\AttendanceTracking\RecordAttendanceTrackingRequest;
use App\Http\Requests\AttendanceTracking\SaveAttendanceNotificationNoteRequest;
use App\Http\Requests\AttendanceTracking\SendAttendanceManualEmailRequest;
use App\Services\AttendanceTrackingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AttendanceTrackingController extends Controller
{
public function __construct(
protected AttendanceTrackingService $service
) {
}
public function pendingViolations(Request $request): JsonResponse
{
$result = $this->service->getPendingViolations(
$request->query('school_year'),
$request->query('semester')
);
return response()->json([
'success' => true,
'data' => $result,
]);
}
public function notifiedViolations(Request $request): JsonResponse
{
$result = $this->service->getNotifiedViolations(
$request->query('school_year'),
$request->query('semester')
);
return response()->json([
'success' => true,
'data' => $result,
]);
}
public function studentCase(int $studentId, Request $request): JsonResponse
{
$result = $this->service->getStudentCase(
$studentId,
$request->query('code'),
$request->query('incident_date'),
filter_var($request->query('include_notified'), FILTER_VALIDATE_BOOL) === true,
filter_var($request->query('pending_only'), FILTER_VALIDATE_BOOL) === true,
$request->query('school_year'),
$request->query('semester'),
);
return response()->json(
$result,
$result['status'] ?? 200
);
}
public function record(RecordAttendanceTrackingRequest $request): JsonResponse
{
$result = $this->service->record($request->validated());
return response()->json(
$result,
$result['status'] ?? 200
);
}
public function sendAutoEmails(Request $request): JsonResponse
{
$result = $this->service->sendAutoEmails($request->input('variant'));
return response()->json($result);
}
public function compose(ComposeAttendanceEmailRequest $request): JsonResponse
{
$result = $this->service->compose(
(int) $request->validated('student_id'),
(string) $request->validated('code', 'ABS_1'),
(string) $request->validated('variant', 'default'),
$request->validated('incident_date'),
);
return response()->json(
$result,
$result['status'] ?? 200
);
}
public function sendManualEmail(SendAttendanceManualEmailRequest $request): JsonResponse
{
$result = $this->service->sendEmailManual($request->validated());
return response()->json(
$result,
$result['status'] ?? 200
);
}
public function parentsInfo(ParentsInfoRequest $request): JsonResponse
{
$result = $this->service->parentsInfo((int) $request->validated('student_id'));
return response()->json(
$result,
$result['status'] ?? 200
);
}
public function saveNotificationNote(SaveAttendanceNotificationNoteRequest $request): JsonResponse
{
$result = $this->service->saveNotificationNote($request->validated());
return response()->json(
$result,
$result['status'] ?? 200
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,87 +0,0 @@
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
class AuthController extends Controller
{
public function login(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => ['required', 'email'],
'password' => ['required'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors(),
], 422);
}
$credentials = $validator->validated();
$email = strtolower($credentials['email']);
$user = User::whereRaw('LOWER(email) = ?', [$email])->first();
if (!$user) {
log_message('warning', 'Login failed, user not found', ['email' => $credentials['email']]);
return response()->json(['error' => 'Invalid login'], 401);
}
$hashPreview = substr((string) $user->password, 0, 32);
log_message('debug', 'Login hash preview for {email}: {hash}', [
'email' => $credentials['email'],
'hash' => $hashPreview,
]);
if (!ci_password_verify($credentials['password'], $user->password)) {
log_message('warning', 'Login failed, password mismatch', [
'email' => $credentials['email'],
'hash' => $hashPreview,
]);
return response()->json(['error' => 'Invalid login'], 401);
}
$token = JWTAuth::fromUser($user);
return response()->json($this->respondWithToken($token));
}
public function me()
{
try {
$user = JWTAuth::parseToken()->authenticate();
} catch (\Throwable $e) {
return response()->json(['error' => 'Unauthorized'], 401);
}
return response()->json($user);
}
public function logout()
{
try {
$token = JWTAuth::getToken();
if ($token) {
JWTAuth::invalidate($token);
}
} catch (\Throwable $e) {
// ignore invalid token
}
return response()->json(['message' => 'Logged out']);
}
protected function respondWithToken($token)
{
return [
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => auth()->factory()->getTTL() * 60,
];
}
}
@@ -1,249 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Controllers\View\EmailController;
use App\Models\AuthorizedUser;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class AuthorizedUsersController extends BaseApiController
{
protected AuthorizedUser $authorizedUsers;
protected User $user;
public function __construct()
{
parent::__construct();
$this->authorizedUsers = new AuthorizedUser();
$this->user = model(User::class);
}
public function index(): JsonResponse
{
$userId = $this->getCurrentUserId();
if (!$userId) {
return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED);
}
$users = $this->authorizedUsers->newQuery()
->where('user_id', $userId)
->orderBy('created_at', 'DESC')
->get()
->toArray();
return $this->success($users, 'Authorized users retrieved successfully');
}
public function show($id = null): JsonResponse
{
$userId = $this->getCurrentUserId();
if (!$userId) {
return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED);
}
$record = $this->authorizedUsers->newQuery()
->where('user_id', $userId)
->where('id', $id)
->first();
if (!$record) {
return $this->respondError('Authorized user not found', Response::HTTP_NOT_FOUND);
}
return $this->success($record, 'Authorized user retrieved successfully');
}
public function store(): JsonResponse
{
$payload = $this->payloadData();
$rules = [
'email' => 'required|valid_email',
];
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$email = strtolower(trim($payload['email']));
$user = $this->user->where('email', $email)->first();
if (!$user) {
return $this->respondError('No user found with this email.', Response::HTTP_NOT_FOUND);
}
$mainUserId = $this->getCurrentUserId();
if (!$mainUserId) {
return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED);
}
$existing = $this->authorizedUsers->newQuery()
->where('user_id', $mainUserId)
->where('authorized_user_id', $user['id'])
->first();
if ($existing) {
return $this->respondError('This user is already authorized.', Response::HTTP_CONFLICT);
}
$token = bin2hex(random_bytes(48));
$record = $this->authorizedUsers->newQuery()->create([
'user_id' => $mainUserId,
'authorized_user_id' => $user['id'],
'firstname' => $user['firstname'] ?? null,
'lastname' => $user['lastname'] ?? null,
'email' => $email,
'token' => $token,
'status' => 'Pending',
]);
$this->sendAuthorizedUserConfirmationEmail($email, $token);
return $this->respondCreated([
'id' => $record->id,
'message' => 'Authorized user added. A confirmation email has been sent.',
]);
}
public function update($id = null): JsonResponse
{
$mainUserId = $this->getCurrentUserId();
if (!$mainUserId) {
return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED);
}
$record = $this->authorizedUsers->newQuery()
->where('user_id', $mainUserId)
->where('id', $id)
->first();
if (!$record) {
return $this->respondError('Authorized user not found', Response::HTTP_NOT_FOUND);
}
$payload = $this->payloadData();
$rules = ['email' => 'permit_empty|valid_email'];
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
if (!empty($payload['email'])) {
$record->email = strtolower(trim($payload['email']));
}
$record->save();
return $this->success($record->fresh(), 'Authorized user updated successfully');
}
public function destroy($id = null): JsonResponse
{
$mainUserId = $this->getCurrentUserId();
if (!$mainUserId) {
return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED);
}
$record = $this->authorizedUsers->newQuery()
->where('user_id', $mainUserId)
->where('id', $id)
->first();
if (!$record) {
return $this->respondError('Authorized user not found', Response::HTTP_NOT_FOUND);
}
$record->delete();
return $this->respondDeleted(null, 'Authorized user deleted successfully');
}
public function confirm(): JsonResponse
{
$payload = $this->payloadData();
$token = (string) ($payload['token'] ?? $this->request->get('token') ?? '');
if ($token === '') {
return $this->respondError('Invalid confirmation token.', Response::HTTP_BAD_REQUEST);
}
$record = $this->authorizedUsers->newQuery()
->where('token', $token)
->first();
if (!$record) {
return $this->respondError('Invalid or expired confirmation token.', Response::HTTP_BAD_REQUEST);
}
$record->status = 'Active';
$record->token = null;
$record->save();
return $this->success([
'authorized_user_id' => $record->authorized_user_id,
'message' => 'Authorized user confirmed.',
]);
}
public function setPassword($authorizedUserId): JsonResponse
{
$user = $this->user->find($authorizedUserId);
if (!$user) {
return $this->respondError('User not found.', Response::HTTP_NOT_FOUND);
}
return $this->success([
'user_id' => $authorizedUserId,
'email' => $user['email'] ?? null,
], 'Authorized user retrieved.');
}
public function savePassword($authorizedUserId): JsonResponse
{
$payload = $this->payloadData();
$rules = [
'password' => 'required|min_length[8]',
'password_confirm' => 'required|matches[password]',
];
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$user = $this->user->find($authorizedUserId);
if (!$user) {
return $this->respondError('User not found.', Response::HTTP_NOT_FOUND);
}
$hashed = pbkdf2_hash($payload['password']);
$this->user->update($authorizedUserId, ['password' => $hashed]);
return $this->success(['message' => 'Password has been set.']);
}
private function sendAuthorizedUserConfirmationEmail(string $email, string $token): void
{
$confirmLink = url('/confirm_authorized_user?token=' . $token);
try {
$message = view('emails/authorized_user_confirmation', [
'confirmLink' => $confirmLink,
], ['saveData' => false]);
} catch (\Throwable $e) {
$message = '<p>You have been added as an authorized user for another account.</p>'
. '<p><a href="' . e($confirmLink) . '">Confirm Access</a></p>'
. '<p>If you did not request this, please ignore this email.</p>';
}
$mailer = new EmailController();
$subject = 'Authorized User Confirmation';
if ($mailer->sendEmail($email, $subject, $message)) {
log_message('info', 'Authorized user confirmation email sent to ' . $email);
} else {
log_message('error', 'Failed to send authorized user confirmation email to ' . $email);
}
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Api\Badges;
use App\Http\Controllers\Controller;
use App\Http\Requests\Badges\BadgePrintStatusRequest;
use App\Http\Requests\Badges\GenerateBadgePdfRequest;
use App\Http\Requests\Badges\LogBadgePrintRequest;
use App\Services\Badges\BadgeFormDataService;
use App\Services\Badges\BadgePdfService;
use App\Services\Badges\BadgePrintLogService;
use Illuminate\Http\JsonResponse;
use Throwable;
class BadgeController extends Controller
{
public function __construct(
protected BadgePdfService $badgePdfService,
protected BadgePrintLogService $badgePrintLogService,
protected BadgeFormDataService $badgeFormDataService
) {
}
public function generatePdf(GenerateBadgePdfRequest $request)
{
return $this->badgePdfService->generate(
userIds: $request->input('user_ids', []),
schoolYear: $request->input('school_year'),
rolesMap: $request->input('roles', []),
classesMap: $request->input('classes', []),
actorId: optional($request->user())->id
);
}
public function printStatus(BadgePrintStatusRequest $request): JsonResponse
{
try {
return response()->json([
'ok' => true,
'data' => $this->badgePrintLogService->getStatus(
$request->normalizedUserIds(),
$request->input('school_year')
),
]);
} catch (Throwable) {
return response()->json([
'ok' => false,
'error' => 'Failed to query status',
], 500);
}
}
public function logPrint(LogBadgePrintRequest $request): JsonResponse
{
try {
$inserted = $this->badgePrintLogService->log(
userIds: $request->input('user_ids', []),
actorId: optional($request->user())->id,
schoolYear: $request->input('school_year'),
rolesMap: $request->input('roles', []),
classesMap: $request->input('classes', [])
);
return response()->json([
'ok' => true,
'inserted' => $inserted,
]);
} catch (Throwable $e) {
return response()->json([
'ok' => false,
'error' => $e->getMessage(),
], 500);
}
}
public function formData(BadgePrintStatusRequest $request): JsonResponse
{
return response()->json([
'ok' => true,
'data' => $this->badgeFormDataService->build(
schoolYear: $request->input('school_year'),
selectedUserIds: $request->normalizedUserIds(),
activeRole: $request->input('active_role')
),
]);
}
}
@@ -1,244 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Services\EmailService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class BroadcastEmailController extends BaseApiController
{
protected User $user;
protected EmailService $mailer;
public function __construct()
{
parent::__construct();
$this->user = model(User::class);
$this->mailer = app(EmailService::class);
}
public function metadata(): JsonResponse
{
$parents = array_values(array_filter(
$this->user->getParents(),
static fn ($parent) => !empty($parent['email'])
));
return $this->success([
'parents' => $parents,
'fromOptions' => $this->senderOptionsFromEnv(),
], 'Broadcast email metadata retrieved');
}
public function send(): JsonResponse
{
$data = $this->payloadData();
$rules = [
'subject' => 'required|string',
'body_html' => 'required|string',
'mode' => 'permit_empty|string',
'from_key' => 'permit_empty|string',
'wrap_layout'=> 'permit_empty',
'preheader' => 'permit_empty|string',
'cta_text' => 'permit_empty|string',
'cta_url' => 'permit_empty|string',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$isTest = !empty($data['send_test_only']);
$mode = (string) ($data['mode'] ?? 'standard');
$subject = trim((string) $data['subject']);
$fromKey = trim((string) ($data['from_key'] ?? 'general'));
$wrapLayout = !empty($data['wrap_layout']);
$preheader = (string) ($data['preheader'] ?? '');
$ctaText = (string) ($data['cta_text'] ?? '');
$ctaUrl = (string) ($data['cta_url'] ?? '');
$body = $this->sanitizeEmailHtml((string) $data['body_html']);
if ($subject === '' || $body === '') {
return $this->respondError('Subject and body are required.', Response::HTTP_BAD_REQUEST);
}
if ($isTest) {
$testEmail = trim((string) ($data['test_email'] ?? ''));
if ($testEmail === '' || !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) {
return $this->respondError('Provide a valid test email address.', Response::HTTP_BAD_REQUEST);
}
$html = $this->composeEmailHtml(
$wrapLayout,
$subject,
$body,
'Parent',
$preheader,
$ctaText,
$ctaUrl,
$mode === 'personalized'
);
$ok = $this->mailer->send($testEmail, '[TEST] ' . $subject, $html, $fromKey);
return $ok
? $this->success(['message' => "Test email sent to {$testEmail}."], 'Test email sent')
: $this->respondError('Test email failed to send. Check logs.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
$parentIds = $this->parseParentIds($data['parent_ids'] ?? []);
if (empty($parentIds)) {
return $this->respondError('Please select at least one parent.', Response::HTTP_BAD_REQUEST);
}
$rows = $this->user->newQuery()
->select('users.id', 'users.email', 'users.firstname', 'users.lastname')
->whereIn('users.id', $parentIds)
->whereNotNull('users.email')
->get();
if ($rows->isEmpty()) {
return $this->respondError('No valid parent emails found.', Response::HTTP_BAD_REQUEST);
}
$stats = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'mode' => $mode];
$personalized = ($mode === 'personalized');
foreach ($rows as $row) {
$stats['attempted']++;
$recipientName = trim(($row->firstname ?? '') . ' ' . ($row->lastname ?? '')) ?: 'Parent';
$html = $this->composeEmailHtml(
$wrapLayout,
$subject,
$body,
$recipientName,
$preheader,
$ctaText,
$ctaUrl,
$personalized
);
$ok = $this->mailer->send($row->email, $subject, $html, $fromKey);
$ok ? $stats['sent']++ : $stats['failed']++;
}
$message = "Broadcast finished. Mode: {$stats['mode']}. Sent: {$stats['sent']}/{$stats['attempted']}. Failures: {$stats['failed']}.";
$status = $stats['failed'] > 0 ? Response::HTTP_OK : Response::HTTP_OK;
return $this->success(array_merge($stats, ['message' => $message]), 'Broadcast completed', $status);
}
public function uploadImage(): JsonResponse
{
if (!$this->request->is('post')) {
return $this->respondError('Method not allowed', Response::HTTP_METHOD_NOT_ALLOWED);
}
$file = request()->file('image');
if (!$file || !$file->isValid()) {
return $this->respondError('No image uploaded.', Response::HTTP_BAD_REQUEST);
}
$mime = strtolower((string) $file->getMimeType());
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
if (!isset($allowed[$mime])) {
return $this->respondError('Unsupported image type.', Response::HTTP_UNSUPPORTED_MEDIA_TYPE);
}
if ($file->getSize() > 5 * 1024 * 1024) {
return $this->respondError('Image too large (max 5MB).', Response::HTTP_REQUEST_ENTITY_TOO_LARGE);
}
$targetDir = public_path('uploads/email');
if (!is_dir($targetDir)) {
@mkdir($targetDir, 0755, true);
}
$newName = uniqid('em_', true) . '.' . $allowed[$mime];
$file->move($targetDir, $newName);
$url = url('uploads/email/' . $newName);
return $this->success([
'success' => true,
'url' => $url,
], 'Image uploaded successfully');
}
private function composeEmailHtml(
bool $wrap,
string $subject,
string $body,
string $recipientName,
string $preheader,
string $ctaText,
string $ctaUrl,
bool $doPersonalize
): string {
$content = $doPersonalize ? str_replace('{{name}}', $recipientName, $body) : $body;
if (!$wrap) {
return $content;
}
try {
return view('emails/broadcast_wrapper', [
'subject' => $subject,
'content' => $content,
'preheader' => $preheader,
'cta_text' => $ctaText,
'cta_url' => $ctaUrl,
], ['saveData' => false]);
} catch (\Throwable $e) {
Log::warning('broadcast_wrapper view missing: ' . $e->getMessage());
return $content;
}
}
private function sanitizeEmailHtml(string $html): string
{
$html = preg_replace('#<(script|iframe)[^>]*>.*?</\\1>#is', '', $html);
$html = preg_replace('/\son\w+="[^"]*"/i', '', $html);
$html = preg_replace("/\son\w+='[^']*'/i", '', $html);
return $html;
}
private function senderOptionsFromEnv(): array
{
$json = env('MAIL_SENDERS', '{}');
$arr = json_decode($json, true);
if (!is_array($arr) || empty($arr)) {
$smtpUser = env('SMTP_USER', '');
$name = 'Al Rahma Sunday School';
return [[
'key' => 'general',
'label' => trim($name . ($smtpUser ? " <{$smtpUser}>" : '')),
]];
}
$out = [];
foreach ($arr as $key => $info) {
$nm = $info['name'] ?? 'Sender';
$em = $info['email'] ?? '';
$out[] = ['key' => (string) $key, 'label' => trim($nm . ($em ? " <{$em}>" : ''))];
}
return $out;
}
private function parseParentIds($raw): array
{
$raw = is_array($raw) ? $raw : [$raw];
$ids = [];
foreach ($raw as $value) {
if (is_string($value) && str_contains($value, ',')) {
$ids = array_merge($ids, array_map('intval', explode(',', $value)));
} else {
$ids[] = (int) $value;
}
}
return array_values(array_unique(array_filter($ids)));
}
}
@@ -1,111 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Calendar;
use Symfony\Component\HttpFoundation\Response;
class CalendarController extends BaseApiController
{
protected Calendar $calendar;
public function __construct()
{
parent::__construct();
$this->calendar = model(Calendar::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$month = $this->request->getGet('month');
$year = $this->request->getGet('year');
$query = $this->calendar->newQuery();
if ($month && $year) {
$query->whereMonth('start_date', $month)->whereYear('start_date', $year);
} elseif ($year) {
$query->whereYear('start_date', $year);
}
$query->orderBy('start_date', 'ASC');
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Calendar events retrieved successfully');
}
public function show($id = null)
{
$event = $this->calendar->find($id);
if (!$event) {
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
}
return $this->success($event, 'Calendar event retrieved successfully');
}
public function store()
{
$data = $this->payloadData();
$rules = [
'title' => 'required|string|max_length[255]',
'description' => 'permit_empty|string',
'start_date' => 'required|valid_date[Y-m-d]',
'end_date' => 'permit_empty|valid_date[Y-m-d]',
'location' => 'permit_empty|string|max_length[255]',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$event = $this->calendar->create($data);
return $this->respondCreated([
'id' => $event->id,
'data' => $event,
], 'Calendar event created successfully');
}
public function update($id = null)
{
$event = $this->calendar->find($id);
if (!$event) {
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
$rules = [
'title' => 'permit_empty|string|max_length[255]',
'description' => 'permit_empty|string',
'start_date' => 'permit_empty|valid_date[Y-m-d]',
'end_date' => 'permit_empty|valid_date[Y-m-d]',
'location' => 'permit_empty|string|max_length[255]',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$event->fill($data);
$event->save();
return $this->success($event->fresh(), 'Calendar event updated successfully');
}
public function delete($id = null)
{
$event = $this->calendar->find($id);
if (!$event) {
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
}
$event->delete();
return $this->respondDeleted(null, 'Calendar event deleted successfully');
}
}
@@ -1,63 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\ClassModel;
use App\Models\StudentClass;
use Symfony\Component\HttpFoundation\Response;
class ClassController extends BaseApiController
{
protected Class $class;
protected StudentClass $studentClass;
public function __construct()
{
parent::__construct();
$this->class = model(ClassModel::class);
$this->studentClass = model(StudentClass::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$grade = $this->request->getGet('grade');
$teacher = $this->request->getGet('teacher_id');
$query = $this->class->newQuery();
if ($grade) {
$query->where('grade', $grade);
}
if ($teacher) {
$query->where('teacher_id', $teacher);
}
$query->orderBy('grade', 'ASC');
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Classes retrieved successfully');
}
public function show($id = null)
{
$class = $this->class->find($id);
if (!$class) {
return $this->respondError('Class not found', Response::HTTP_NOT_FOUND);
}
return $this->success($class, 'Class details retrieved successfully');
}
public function students($id = null)
{
$students = $this->studentClass->newQuery()
->select('students.id', 'students.firstname', 'students.lastname', 'students.school_id')
->join('students', 'students.id', '=', 'student_class.student_id')
->where('student_class.class_id', $id)
->orderBy('students.lastname', 'ASC')
->get()
->toArray();
return $this->success($students, 'Students retrieved successfully for class');
}
}
@@ -1,411 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\ClassPrepAdjustment;
use App\Models\ClassPreparationLog;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\StudentClass;
use Illuminate\Support\Facades\DB;
class ClassPreparationController extends BaseApiController
{
protected StudentClass $studentClass;
protected ClassPreparationLog $prepLog;
protected ClassSection $classSection;
protected Configuration $config;
protected ClassPrepAdjustment $adjustment;
protected ?string $schoolYear;
protected ?string $semester;
private array $allowedPrepCategories = [
'Grade Box',
'Large Table',
'Regular Chair',
'Small Chair',
'Small Table',
'Teacher Chair',
'Trash Bin',
'White Board',
];
public function __construct(
StudentClass $studentClass,
ClassPreparationLog $prepLog,
ClassSection $classSection,
Configuration $config,
ClassPrepAdjustment $adjustment
) {
parent::__construct();
$this->studentClass = $studentClass;
$this->prepLog = $prepLog;
$this->classSection = $classSection;
$this->config = $config;
$this->adjustment = $adjustment;
$this->schoolYear = $this->config->getConfig('school_year');
$this->semester = $this->config->getConfig('semester');
}
public function index()
{
$schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? '');
$semester = (string) ($this->request->getGet('semester') ?? $this->semester ?? '');
$allowed = $this->allowedPrepCategories;
$classSections = $this->studentClass->newQuery()
->selectRaw('class_section_id, COUNT(DISTINCT student_id) AS student_count')
->where('school_year', $schoolYear)
->where('semester', $semester)
->groupBy('class_section_id')
->get()
->map(fn ($row) => (array) $row)
->toArray();
$inventoryMap = $this->buildInventoryMap($schoolYear, $semester);
$requiredTotals = array_fill_keys($allowed, 0);
$results = [];
foreach ($classSections as $section) {
$classSectionId = (string) ($section['class_section_id'] ?? '');
$studentCount = (int) ($section['student_count'] ?? 0);
$classLevel = $this->getClassLevelBySection($classSectionId);
$className = $this->classSection->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
$rawAdjustments = $this->adjustment->newQuery()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->where('adjustable', 1)
->get()
->map(fn ($row) => (array) $row)
->toArray();
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
foreach ($rawAdjustments as $adjustment) {
$item = $adjustment['item_name'] ?? '';
$delta = (int) ($adjustment['adjustment'] ?? 0);
if (array_key_exists($item, $adjMap)) {
$adjMap[$item] = $delta;
}
if (isset($baseItems[$item])) {
$baseItems[$item] = max(0, (int) $baseItems[$item] + $delta);
} elseif (in_array($item, $allowed, true)) {
$baseItems[$item] = max(0, $delta);
}
}
foreach ($allowed as $category) {
$requiredTotals[$category] += (int) ($baseItems[$category] ?? 0);
}
$oldSnap = $this->prepLog->newQuery()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')
->first();
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'] ?? '[]', true) : [];
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
$results[] = [
'class_section' => $className,
'class_section_id' => $classSectionId,
'student_count' => $studentCount,
'class_level' => $classLevel,
'prep_items' => $baseItems,
'needs_print' => $hasChanged,
'last_printed_at' => $oldSnap['created_at'] ?? null,
'adjustments' => $adjMap,
];
}
$shortages = $this->calculateShortages($requiredTotals, $inventoryMap);
return $this->success([
'schoolYear' => $schoolYear,
'semester' => $semester,
'results' => $results,
'totals' => $requiredTotals,
'shortages' => $shortages,
'available' => $inventoryMap,
], 'Class preparation data retrieved');
}
public function saveAdjustment()
{
$data = $this->request->all();
$sectionId = (string) ($data['class_section_id'] ?? '');
$schoolYear = (string) ($data['school_year'] ?? $this->schoolYear ?? '');
$adjustments = is_array($data['adjustments'] ?? null) ? $data['adjustments'] : [];
$updated = 0;
foreach ($adjustments as $itemName => $adjustment) {
$row = $this->adjustment->newQuery()
->where('class_section_id', $sectionId)
->where('school_year', $schoolYear)
->where('item_name', $itemName)
->first();
$payload = [
'class_section_id' => $sectionId,
'item_name' => $itemName,
'adjustment' => (int) $adjustment,
'school_year' => $schoolYear,
'adjustable' => 1,
];
if ($row) {
$this->adjustment->newQuery()
->where('id', $row['id'])
->update(['adjustment' => $payload['adjustment'], 'adjustable' => 1]);
} else {
$payload['created_at'] = utc_now();
$this->adjustment->insert($payload);
}
$updated++;
}
return $this->success(['updated' => $updated], 'Adjustments saved successfully');
}
public function markPrinted()
{
$schoolYear = (string) ($this->request->getPost('school_year') ?? $this->schoolYear ?? '');
$ids = $this->request->getPost('class_section_ids') ?? [];
if (!is_array($ids)) {
$ids = $ids ? [$ids] : [];
}
$ids = array_values(array_unique(array_filter(array_map('strval', $ids))));
$now = utc_now();
$count = 0;
foreach ($ids as $classSectionId) {
$studentRow = $this->studentClass->newQuery()
->selectRaw('COUNT(DISTINCT student_id) AS cnt')
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->where('semester', $this->semester)
->first();
$studentCount = (int) ($studentRow['cnt'] ?? 0);
$classLevel = $this->getClassLevelBySection((string) $classSectionId);
$className = $this->classSection->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
$items = $this->calculatePrepItems($studentCount, $classLevel, (string) $classSectionId);
$rawAdjustments = $this->adjustment->newQuery()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->where('adjustable', 1)
->get()
->map(fn ($row) => (array) $row)
->toArray();
foreach ($rawAdjustments as $adjustment) {
$item = $adjustment['item_name'] ?? '';
$delta = (int) ($adjustment['adjustment'] ?? 0);
if (isset($items[$item])) {
$items[$item] = max(0, (int) $items[$item] + $delta);
}
}
try {
$this->prepLog->insert([
'class_section_id' => (string) $classSectionId,
'class_section' => $className,
'school_year' => $schoolYear,
'prep_data' => json_encode($items),
'created_at' => $now,
]);
$count++;
} catch (\Throwable $e) {
// ignore and continue
}
}
return $this->success(['updated' => $count], 'Marked sections as printed');
}
private function calculateShortages(array $requiredTotals, array $inventoryMap): array
{
$shortages = [];
foreach ($requiredTotals as $item => $reqQty) {
$have = (int) ($inventoryMap[$item] ?? 0);
if ($have < (int) $reqQty) {
$shortages[$item] = (int) $reqQty - $have;
}
}
return $shortages;
}
private function getTeacherCountForSection(string $classSectionId, ?string $schoolYear = null): int
{
$schoolYear = $schoolYear ?? $this->schoolYear;
$row = DB::table('teacher_class')
->selectRaw('COUNT(*) AS cnt')
->where('class_section_id', $classSectionId)
->whereIn('position', ['main', 'ta'])
->where('school_year', $schoolYear)
->first();
return (int) ($row->cnt ?? 0);
}
private function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array
{
$allowed = $this->allowedPrepCategories;
$items = array_fill_keys($allowed, 0);
$categories = DB::table('inventory_categories')
->select('name', 'grade_min', 'grade_max')
->where('type', 'classroom')
->whereIn('name', $allowed)
->orderBy('name')
->get()
->map(fn ($row) => (array) $row)
->toArray();
$isLowerGrades = in_array($classLevel, [1, 2], true);
$teacherCount = $this->getTeacherCountForSection($classSectionId);
foreach ($categories as $category) {
$name = $category['name'] ?? '';
$gradeMin = $category['grade_min'];
$gradeMax = $category['grade_max'];
if ($gradeMin !== null && $classLevel < (int) $gradeMin) {
$items[$name] = 0;
continue;
}
if ($gradeMax !== null && $classLevel > (int) $gradeMax) {
$items[$name] = 0;
continue;
}
$qty = 0;
switch (strtolower($name)) {
case 'small table':
$qty = $isLowerGrades ? (int) ceil($students / 3) : 0;
break;
case 'large table':
$qty = $isLowerGrades ? 0 : (int) ceil($students / 4);
break;
case 'small chair':
$qty = $isLowerGrades ? $students : 0;
break;
case 'regular chair':
$qty = $isLowerGrades ? 0 : $students;
break;
case 'teacher chair':
$qty = $teacherCount;
break;
case 'trash bin':
case 'white board':
case 'grade box':
$qty = 1;
break;
default:
$qty = 0;
}
if (array_key_exists($name, $items)) {
$items[$name] = $qty;
}
}
return $items;
}
private function hasPrepChanged(array $new, array $old): bool
{
ksort($new);
ksort($old);
return json_encode($new) !== json_encode($old);
}
private function buildInventoryMap(string $schoolYear, string $semester): array
{
$allowed = $this->allowedPrepCategories;
$inventoryMap = array_fill_keys($allowed, 0);
$queries = [
DB::table('inventory_items as ii')
->selectRaw('ic.name AS item_name, COALESCE(SUM(ii.good_qty),0) AS available')
->join('inventory_categories as ic', 'ic.id', '=', 'ii.category_id')
->where('ii.type', 'classroom')
->where('ii.school_year', $schoolYear)
->where('ii.semester', $semester)
->whereIn('ic.name', $allowed)
->groupBy('ic.name'),
DB::table('inventory_items')
->selectRaw('name AS item_name, COALESCE(SUM(good_qty),0) AS available')
->where('type', 'classroom')
->where('school_year', $schoolYear)
->where('semester', $semester)
->whereIn('name', $allowed)
->groupBy('name'),
DB::table('inventory_items as ii')
->selectRaw('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.`condition`="good" THEN ii.quantity ELSE 0 END),0) AS available')
->join('inventory_categories as ic', 'ic.id', '=', 'ii.category_id')
->where('ii.type', 'classroom')
->where('ii.school_year', $schoolYear)
->where('ii.semester', $semester)
->whereIn('ic.name', $allowed)
->groupBy('ic.name'),
DB::table('inventory_items')
->selectRaw('name AS item_name, COALESCE(SUM(CASE WHEN `condition`="good" THEN quantity ELSE 0 END),0) AS available')
->where('type', 'classroom')
->where('school_year', $schoolYear)
->where('semester', $semester)
->whereIn('name', $allowed)
->groupBy('name'),
];
foreach ($queries as $query) {
foreach ($query->get() as $row) {
$row = (array) $row;
$name = (string) ($row['item_name'] ?? $row['name'] ?? '');
$val = (int) ($row['available'] ?? 0);
if ($name !== '' && isset($inventoryMap[$name])) {
$inventoryMap[$name] = max($inventoryMap[$name], $val);
}
}
}
return $inventoryMap;
}
private function getClassLevelBySection(string $classSectionId): int
{
if (preg_match('/^(kg|k)(\b|[^a-z0-9])/i', $classSectionId)) {
return 1;
}
$numeric = (int) preg_replace('/\D/', '', $classSectionId);
if ($numeric > 0 && $numeric < 30) {
$firstDigit = (int) substr((string) $numeric, 0, 1);
return match ($firstDigit) {
1 => 1,
2 => 2,
default => 3,
};
}
return 3;
}
}
@@ -1,351 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\CommunicationLog;
use App\Models\Communication;
use App\Models\EmailTemplate;
use App\Models\FamilyStudent;
use App\Models\Student;
use Illuminate\Mail\Message;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Symfony\Component\HttpFoundation\Response;
class CommunicationController extends BaseApiController
{
protected Communication $communication;
protected Student $student;
protected FamilyStudent $familyStudent;
protected EmailTemplate $template;
protected CommunicationLog $log;
public function __construct()
{
parent::__construct();
$this->communication = model(Communication::class);
$this->student = model(Student::class);
$this->familyStudent = model(FamilyStudent::class);
$this->template = model(EmailTemplate::class);
$this->log = model(CommunicationLog::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$senderId = $this->request->getGet('sender_id');
$receiverId = $this->request->getGet('receiver_id');
$query = $this->communication->newQuery();
if ($senderId) {
$query->where('sender_id', $senderId);
}
if ($receiverId) {
$query->where('receiver_id', $receiverId);
}
$query->orderBy('created_at', 'DESC');
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Communications retrieved successfully');
}
public function send()
{
$data = $this->payloadData();
$rules = [
'sender_id' => 'required|integer',
'receiver_id' => 'required|integer',
'subject' => 'required|string|max_length[255]',
'message' => 'required|string',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$communication = $this->communication->create([
'sender_id' => $data['sender_id'],
'receiver_id' => $data['receiver_id'],
'subject' => $data['subject'],
'message' => $data['message'],
]);
return $this->respondCreated([
'id' => $communication->id,
'data' => $communication,
], 'Message sent successfully');
}
public function conversation()
{
$senderId = $this->request->getGet('sender_id');
$receiverId = $this->request->getGet('receiver_id');
if (!$senderId || !$receiverId) {
return $this->respondValidationError([
'sender_id' => 'sender_id is required',
'receiver_id' => 'receiver_id is required',
]);
}
$conversation = $this->communication->newQuery()
->where(function ($query) use ($senderId, $receiverId) {
$query->where('sender_id', $senderId)
->where('receiver_id', $receiverId);
})
->orWhere(function ($query) use ($senderId, $receiverId) {
$query->where('sender_id', $receiverId)
->where('receiver_id', $senderId);
})
->orderBy('created_at', 'ASC')
->get()
->toArray();
return $this->success($conversation, 'Conversation retrieved successfully');
}
public function delete($id = null)
{
$message = $this->communication->find($id);
if (!$message) {
return $this->respondError('Message not found', Response::HTTP_NOT_FOUND);
}
$message->delete();
return $this->respondDeleted(null, 'Message deleted successfully');
}
public function metadata()
{
$students = $this->student->newQuery()
->selectRaw('id, firstname, lastname, COALESCE(registration_grade, \'\') AS grade')
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->get()
->toArray();
$templates = $this->template->getActiveTemplates();
return $this->success([
'students' => $students,
'templates' => $templates,
], 'Communication metadata retrieved');
}
public function families(int $studentId)
{
$families = $this->familyStudent->getFamiliesForStudent($studentId);
return $this->success(['data' => $families], 'Families retrieved');
}
public function guardians(int $familyId)
{
$rows = DB::table('family_guardians as fg')
->select('u.id AS user_id', 'u.firstname', 'u.lastname', 'u.email', 'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms')
->join('users as u', 'u.id', '=', 'fg.user_id')
->where('fg.family_id', $familyId)
->get()
->map(fn ($row) => (array) $row)
->toArray();
return $this->success(['data' => $rows], 'Guardians retrieved');
}
public function preview()
{
$data = $this->payloadData();
$rules = [
'template_key' => 'required|string',
'student_id' => 'required|integer',
'family_id' => 'required|integer',
'vars' => 'permit_empty',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$template = $this->template->findByKey((string) ($data['template_key'] ?? ''));
if (!$template) {
return $this->respondError('Template not found', Response::HTTP_NOT_FOUND);
}
$student = $this->student->find($data['student_id']);
if (!$student) {
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
}
$guardianRows = DB::table('family_guardians as fg')
->select('u.firstname', 'u.lastname')
->join('users as u', 'u.id', '=', 'fg.user_id')
->where('fg.family_id', $data['family_id'])
->where('fg.receive_emails', 1)
->orderByDesc('fg.is_primary')
->orderBy('u.lastname')
->orderBy('u.firstname')
->get()
->map(fn ($row) => (array) $row)
->toArray();
$salutation = 'Parent/Guardian';
if (!empty($guardianRows)) {
$names = array_map(fn ($row) => trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? '')), $guardianRows);
$names = array_filter($names, fn ($name) => $name !== '');
if (!empty($names)) {
$salutation = implode(' & ', $names);
}
}
$varsPost = $data['vars'] ?? null;
$vars = $this->normalizeVars($varsPost);
$autoVars = [
'student_fullname' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
'student_grade' => $student['grade'] ?? $student['registration_grade'] ?? '',
'parent_salutation'=> $salutation,
'date' => local_date(utc_now(), 'Y-m-d'),
'school_name' => 'Al Rahma Sunday School',
'teacher_name' => (string) (session('display_name') ?? 'Teacher'),
];
$context = array_merge($autoVars, $vars);
$subject = $this->renderTwig($template['subject'] ?? '', $context);
$body = nl2br($this->renderTwig($template['body'] ?? '', $context));
return $this->success([
'subject' => $subject,
'html' => $body,
], 'Template preview generated');
}
public function sendEmail()
{
$data = $this->payloadData();
$rules = [
'student_id' => 'required|integer',
'family_id' => 'required|integer',
'template_key' => 'required|string',
'subject' => 'required|string',
'body' => 'required|string',
'recipients' => 'required',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$student = $this->student->find($data['student_id']);
if (!$student) {
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
}
$recipients = $this->normalizeRecipients($data['recipients']);
$cc = $this->normalizeRecipients($data['cc'] ?? null);
$bcc = $this->normalizeRecipients($data['bcc'] ?? null);
if (empty($recipients)) {
return $this->respondError('At least one recipient is required');
}
$bodyHtml = (string) ($data['body'] ?? '');
$subject = (string) ($data['subject'] ?? '');
$fromAddress = config('mail.from.address', 'no-reply@alrahmaisgl.org');
$fromName = config('mail.from.name', config('app.name', 'Al Rahma'));
$status = 'failed';
$error = null;
try {
Mail::send([], [], function (Message $message) use ($fromAddress, $fromName, $subject, $bodyHtml, $recipients, $cc, $bcc) {
$message->from($fromAddress, $fromName);
$message->to($recipients);
if (!empty($cc)) {
$message->cc($cc);
}
if (!empty($bcc)) {
$message->bcc($bcc);
}
$message->subject($subject);
$message->setBody($bodyHtml, 'text/html');
$message->addPart(strip_tags($bodyHtml), 'text/plain');
});
$status = 'sent';
} catch (\Throwable $t) {
$error = $t->getMessage();
}
$studentName = trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''));
$this->log->insert([
'student_id' => $data['student_id'],
'family_id' => $data['family_id'],
'student_name' => $studentName,
'template_key' => $data['template_key'],
'subject' => $subject,
'body' => $bodyHtml,
'recipients' => json_encode(array_values($recipients)),
'cc' => json_encode(array_values($cc)),
'bcc' => json_encode(array_values($bcc)),
'status' => $status,
'error_message' => $error,
'sent_by' => (int) (session('user_id') ?? 0),
'metadata' => null,
]);
if ($status === 'sent') {
return $this->success(['status' => 'sent'], 'Email sent successfully');
}
return $this->error('Failed to send email: ' . ($error ?? 'Unknown error'), Response::HTTP_INTERNAL_SERVER_ERROR);
}
private function normalizeVars($value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
return $decoded;
}
return [];
}
if (is_array($value)) {
return $value;
}
return [];
}
private function normalizeRecipients($input): array
{
$values = [];
if (is_string($input)) {
$decoded = json_decode($input, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$values = $decoded;
} elseif ($input !== '') {
$values = [$input];
}
} elseif (is_array($input)) {
$values = $input;
}
$sanitized = array_values(array_filter(array_map(fn ($item) => trim((string) $item), $values)));
return $sanitized;
}
private function renderTwig(string $template, array $vars): string
{
return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_\.]+)\s*\}\}/', function ($match) use ($vars) {
$key = $match[1];
return htmlspecialchars((string) ($vars[$key] ?? ''), ENT_QUOTES, 'UTF-8');
}, $template);
}
}
@@ -1,148 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use Symfony\Component\HttpFoundation\Response;
class ConfigurationController extends BaseApiController
{
protected Configuration $config;
public function __construct()
{
parent::__construct();
$this->config = model(Configuration::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$key = $this->request->getGet('key');
$query = $this->config->newQuery()->orderBy('id', 'ASC');
if ($key) {
$query->where('config_key', 'LIKE', '%' . $key . '%');
}
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Configurations retrieved successfully');
}
public function show($id = null)
{
$config = $this->config->find($id);
if (!$config) {
return $this->respondError('Configuration not found', Response::HTTP_NOT_FOUND);
}
return $this->success($config, 'Configuration retrieved successfully');
}
public function getByKey($key = null)
{
$config = $this->config->where('config_key', $key)->first();
if (!$config) {
return $this->respondError('Configuration not found', Response::HTTP_NOT_FOUND);
}
return $this->success($config, 'Configuration retrieved successfully');
}
public function create()
{
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'config_key' => 'required|string|max_length[255]|is_unique[configuration.config_key]',
'config_value' => 'required',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
try {
$config = $this->config->create([
'config_key' => $data['config_key'],
'config_value' => $data['config_value'],
]);
} catch (\Throwable $e) {
log_message('error', 'Configuration creation error: ' . $e->getMessage());
return $this->respondError('Failed to create configuration', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->respondCreated([
'id' => $config->id,
'data' => $config,
], 'Configuration created successfully');
}
public function update($id = null)
{
$config = $this->config->find($id);
if (!$config) {
return $this->respondError('Configuration not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$updateData = array_intersect_key($data, array_flip(['config_key', 'config_value']));
if (empty($updateData)) {
return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST);
}
try {
$config->fill($updateData);
$config->save();
} catch (\Throwable $e) {
log_message('error', 'Configuration update error: ' . $e->getMessage());
return $this->respondError('Failed to update configuration', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success($config->fresh(), 'Configuration updated successfully');
}
public function delete($id = null)
{
$config = $this->config->find($id);
if (!$config) {
return $this->respondError('Configuration not found', Response::HTTP_NOT_FOUND);
}
try {
$config->delete();
} catch (\Throwable $e) {
log_message('error', 'Configuration deletion error: ' . $e->getMessage());
return $this->respondError('Failed to delete configuration', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success(null, 'Configuration deleted successfully');
}
public function listData()
{
$configs = $this->config->newQuery()
->orderBy('id', 'ASC')
->get()
->map(function ($row) {
return [
'id' => (int) ($row->id ?? 0),
'config_key' => (string) ($row->config_key ?? ''),
'config_value' => (string) ($row->config_value ?? ''),
];
})
->values()
->toArray();
return $this->success(['configs' => $configs], 'Configuration list retrieved');
}
}
@@ -1,59 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Services\EmailService;
use Symfony\Component\HttpFoundation\Response;
class ContactController extends BaseApiController
{
public function submit()
{
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'name' => 'required|min_length[3]',
'email' => 'required|valid_email',
'subject' => 'required|min_length[3]',
'message' => 'required|min_length[10]',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$name = trim((string) ($data['name'] ?? ''));
$email = strtolower(trim((string) ($data['email'] ?? '')));
$subject = trim((string) ($data['subject'] ?? ''));
$message = trim((string) ($data['message'] ?? ''));
$body = "<p><strong>From:</strong> {$name} ({$email})</p>"
. "<p><strong>Subject:</strong> {$subject}</p>"
. "<p>{$message}</p>";
$supportEmail = env('SUPPORT_EMAIL', 'support@alrahmaisgl.org');
try {
/** @var EmailService $mailer */
$mailer = app(EmailService::class);
$sent = $mailer->setFrom($email, $name)
->setTo($supportEmail)
->setSubject($subject)
->setMessage($body)
->send();
if (!$sent) {
return $this->respondError('There was an error sending your message. Please try again later.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success(null, 'Thank you for contacting us! We will get back to you soon.');
} catch (\Throwable $e) {
log_message('error', 'Contact form submission error: ' . $e->getMessage());
return $this->respondError('Failed to submit contact form', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,45 +0,0 @@
<?php
namespace App\Http\Controllers\View;
use App\Http\Controllers\Controller;
use App\Models\Role;
use Illuminate\Http\RedirectResponse;
class DashboardRedirectController extends Controller
{
public function __construct(protected Role $role)
{
}
public function dashboard(): RedirectResponse
{
$session = session();
$roles = $session->get('roles') ?? [];
if (empty($roles) && $session->has('role')) {
$roles = [$session->get('role')];
}
return $this->redirectToDashboard($roles);
}
private function redirectToDashboard(array $roles): RedirectResponse
{
if (empty($roles)) {
log_message('error', 'Empty roles array passed to redirectToDashboard.');
return redirect()->to('/landing_page/guest_dashboard');
}
$rows = $this->role->findByNamesOrSlugs($roles);
if (!empty($rows)) {
$route = $rows[0]['dashboard_route'] ?? '/landing_page/guest_dashboard';
log_message('debug', 'Redirecting user to: ' . $route);
return redirect()->to($route);
}
log_message('warning', 'No matching role found. Redirecting to guest dashboard.');
return redirect()->to('/landing_page/guest_dashboard');
}
}
@@ -1,647 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\DiscountUsage;
use App\Models\DiscountVoucher;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
class DiscountController extends BaseApiController
{
protected DiscountVoucher $discount;
protected DiscountUsage $discountUsage;
protected Invoice $invoice;
protected User $user;
protected Payment $payment;
protected Configuration $config;
protected string $schoolYear;
protected ?string $semester;
public function __construct()
{
parent::__construct();
$this->discount = model(DiscountVoucher::class);
$this->discountUsage = model(DiscountUsage::class);
$this->invoice = model(Invoice::class);
$this->user = model(User::class);
$this->payment = model(Payment::class);
$this->config = model(Configuration::class);
$this->schoolYear = $this->config->getConfig('school_year') ?? '';
$sem = $this->config->getConfig('semester');
$this->semester = is_string($sem) && $sem !== '' ? $sem : null;
}
public function index()
{
$activeOnly = $this->request->getGet('active') === 'true';
$query = $this->discount->newQuery();
if ($activeOnly) {
$today = now()->toDateString();
$query->where('is_active', 1)
->where(function ($q) use ($today) {
$q->whereNull('valid_from')->orWhere('valid_from', '<=', $today);
})
->where(function ($q) use ($today) {
$q->whereNull('valid_until')->orWhere('valid_until', '>=', $today);
});
}
$discounts = $query->orderBy('created_at', 'DESC')->get()->toArray();
return $this->success($discounts, 'Discount vouchers retrieved successfully');
}
public function getByCode($code = null)
{
$discount = $this->findActiveDiscount($code);
if (!$discount) {
return $this->respondError('Discount voucher not found or expired', Response::HTTP_NOT_FOUND);
}
if ($this->isUsageLimitReached($discount)) {
return $this->respondError('Discount voucher has reached maximum usage limit', Response::HTTP_BAD_REQUEST);
}
return $this->success($discount->toArray(), 'Discount voucher retrieved successfully');
}
public function apply()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'code' => 'required',
'invoice_id' => 'required|integer',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$code = strtoupper(trim((string) ($data['code'] ?? '')));
$discount = $this->findActiveDiscount($code);
if (!$discount) {
return $this->respondError('Invalid or expired discount voucher', Response::HTTP_BAD_REQUEST);
}
if ($this->isUsageLimitReached($discount)) {
return $this->respondError('Discount voucher has reached maximum usage limit', Response::HTTP_BAD_REQUEST);
}
$exists = $this->discountUsage->newQuery()
->where('voucher_id', $discount->id)
->where('invoice_id', $data['invoice_id'])
->exists();
if ($exists) {
return $this->respondError('Discount voucher already applied to this invoice', Response::HTTP_BAD_REQUEST);
}
try {
$usage = $this->discountUsage->create([
'voucher_id' => $discount->id,
'invoice_id' => $data['invoice_id'],
'used_by' => $user->id,
'used_at' => now()->toDateTimeString(),
]);
$discount->increment('times_used');
return $this->success([
'discount' => $discount->fresh()->toArray(),
'applied' => true,
], 'Discount voucher applied successfully');
} catch (\Throwable $e) {
log_message('error', 'Discount apply error: ' . $e->getMessage());
return $this->respondError('Failed to apply discount voucher', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function parents()
{
$parents = $this->user->getParents();
$result = [];
foreach ($parents as $parent) {
$discount = DB::table('discount_usages as du')
->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_discount')
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
->where('i.parent_id', $parent['id'])
->where('i.school_year', $this->schoolYear)
->first();
$totalDiscount = (float) ($discount->total_discount ?? 0);
$result[] = [
'id' => $parent['id'],
'firstname' => $parent['firstname'],
'lastname' => $parent['lastname'],
'email' => $parent['email'],
'school_id' => $parent['school_id'] ?? null,
'total_discount' => $totalDiscount,
'has_discount' => $totalDiscount > 0 ? 1 : 0,
];
}
return $this->success($result, 'Parent discounts retrieved');
}
public function applyVoucher()
{
$data = $this->payloadData();
$rules = [
'voucher_id' => 'required|integer',
'parent_ids' => 'required',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$voucherId = (int) $data['voucher_id'];
$parentIds = $this->normalizeParentIds($data['parent_ids']);
if (empty($parentIds)) {
return $this->respondError('At least one parent is required', Response::HTTP_BAD_REQUEST);
}
$voucher = $this->discount->find($voucherId);
if (!$voucher) {
return $this->respondError('Voucher not found', Response::HTTP_NOT_FOUND);
}
$remainingUses = max(0, (int) ($voucher['max_uses'] ?? 1) - (int) ($voucher['times_used'] ?? 0));
if ($remainingUses <= 0) {
return $this->respondError('Voucher has reached its maximum allowed uses', Response::HTTP_BAD_REQUEST);
}
$description = $this->normalizeDescription($voucher['description'] ?? '');
$now = utc_now();
$fullyCoveredInvoiceIds = [];
$appliedInvoices = [];
$errors = [];
$usedCount = 0;
DB::beginTransaction();
try {
foreach ($parentIds as $parentId) {
$invoices = $this->invoice->getInvoicesByUserId($parentId, $this->schoolYear);
if (empty($invoices)) {
$errors[] = "Parent {$parentId} has no invoices for {$this->schoolYear}.";
continue;
}
foreach ($invoices as $invoice) {
if ($invoice['balance'] <= 0) {
continue;
}
if ($remainingUses <= 0) {
break 2;
}
$initialPreBalance = $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
$already = $this->discountUsage->newQuery()
->where('voucher_id', $voucherId)
->where('invoice_id', $invoice['id'])
->exists();
if ($already) {
continue;
}
$rawDiscount = ($voucher['discount_type'] === 'percent')
? round(((float) $invoice['total_amount'] * (float) $voucher['discount_value']) / 100, 2)
: (float) $voucher['discount_value'];
$discountAmount = min($rawDiscount, $initialPreBalance);
if ($discountAmount <= 0) {
continue;
}
$this->db->table('discount_usages')->insert([
'voucher_id' => $voucherId,
'invoice_id' => $invoice['id'],
'parent_id' => $parentId,
'discount_amount' => $discountAmount,
'description' => $description,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'updated_by' => $this->getCurrentUserId() ?? 0,
'used_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
$newBalance = max(0.0, round(((float) $invoice['balance']) - $discountAmount, 2));
$this->invoice->update($invoice['id'], [
'balance' => $newBalance,
'has_discount' => 1,
'updated_at' => $now,
]);
$postBalance = max(0.0, round($initialPreBalance - $discountAmount, 2));
$currentBalance = $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
$this->discount->newQuery()
->where('id', $voucherId)
->set('times_used', 'times_used + 1', false)
->update();
[$eventData, $studentData] = $this->buildPaymentEventData(
$invoice['id'],
(string) $voucherId,
$discountAmount,
'discount',
$now,
'',
0,
$initialPreBalance,
$postBalance
);
event('paymentReceived', [$eventData, $studentData]);
$epsilon = 0.01;
$fullyCovered = (abs($discountAmount - $initialPreBalance) <= $epsilon)
|| ($postBalance <= $epsilon)
|| ($currentBalance <= $epsilon);
if ($fullyCovered) {
$fullyCoveredInvoiceIds[] = (int) $invoice['id'];
}
$appliedInvoices[] = (int) $invoice['id'];
$remainingUses--;
$usedCount++;
if ($remainingUses <= 0) {
$this->discount->update($voucherId, [
'is_active' => 0,
'updated_at' => $now,
]);
break 2;
}
}
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
log_message('error', 'Voucher application failed: ' . $e->getMessage());
return $this->respondError('Voucher application failed. Transaction rolled back.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
$enrollmentUpdates = 0;
foreach (array_unique($fullyCoveredInvoiceIds) as $invoiceId) {
try {
$enrollmentUpdates += $this->updateEnrollmentStatusIfPaid($invoiceId);
} catch (\Throwable $e) {
log_message('error', 'updateEnrollmentStatusIfPaid failed for invoice {iid}: {err}', [
'iid' => $invoiceId,
'err' => $e->getMessage(),
]);
}
}
return $this->success([
'applied_invoices' => array_values(array_unique($appliedInvoices)),
'errors' => $errors,
'enrollments' => $enrollmentUpdates,
'used_count' => $usedCount,
], 'Voucher applied successfully');
}
public function createVoucher()
{
$data = $this->payloadData();
$rules = [
'code' => 'required|string',
'discount_type' => 'required|in:percent,fixed',
'discount_value' => 'required|numeric',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$code = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim((string) $data['code'])));
if ($code === '') {
return $this->respondError('Voucher code is required', Response::HTTP_BAD_REQUEST);
}
$payload = [
'code' => $code,
'discount_type' => strtolower((string) $data['discount_type']),
'discount_value' => (float) $data['discount_value'],
'max_uses' => isset($data['max_uses']) && $data['max_uses'] !== '' ? (int) $data['max_uses'] : null,
'valid_from' => $this->normalizeDate($data['valid_from'] ?? null),
'valid_until' => $this->normalizeDate($data['valid_until'] ?? null),
'is_active' => isset($data['is_active']) ? (bool) $data['is_active'] : true,
'description' => trim((string) ($data['description'] ?? '')),
];
if ($payload['valid_from'] && $payload['valid_until'] && $payload['valid_from'] > $payload['valid_until']) {
return $this->respondError('Valid until must be the same as or after valid from', Response::HTTP_BAD_REQUEST);
}
try {
$voucher = $this->discount->create($payload);
} catch (\Throwable $e) {
log_message('error', 'Voucher creation failed: ' . $e->getMessage());
return $this->respondError('Failed to save voucher: ' . $e->getMessage());
}
return $this->respondCreated($voucher->toArray(), 'Voucher created successfully');
}
public function editVoucher($id)
{
$voucher = $this->discount->find($id);
if (!$voucher) {
return $this->respondError('Voucher not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$update = [];
foreach (['code', 'discount_type', 'discount_value', 'max_uses', 'valid_from', 'valid_until', 'is_active', 'description'] as $field) {
if (array_key_exists($field, $data)) {
$value = $data[$field];
if (in_array($field, ['valid_from', 'valid_until'], true)) {
$value = $this->normalizeDate($value);
}
$update[$field] = $value;
}
}
if (!empty($update)) {
$this->discount->update($id, $update);
}
return $this->success($this->discount->find($id), 'Voucher updated successfully');
}
protected function findActiveDiscount(?string $code)
{
if (empty($code)) {
return null;
}
$today = now()->toDateString();
return $this->discount->newQuery()
->where('code', $code)
->where('is_active', 1)
->where(function ($q) use ($today) {
$q->whereNull('valid_from')->orWhere('valid_from', '<=', $today);
})
->where(function ($q) use ($today) {
$q->whereNull('valid_until')->orWhere('valid_until', '>=', $today);
})
->first();
}
protected function isUsageLimitReached($discount): bool
{
if (!$discount->max_uses) {
return false;
}
return isset($discount->times_used) && $discount->times_used >= $discount->max_uses;
}
private function normalizeParentIds($input): array
{
$values = [];
if (is_string($input)) {
$decoded = json_decode($input, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$values = $decoded;
} else {
$values = [$input];
}
} elseif (is_array($input)) {
$values = $input;
}
return array_values(array_filter(array_map('intval', $values), fn($id) => $id > 0));
}
private function normalizeDescription(?string $description): string
{
if ($description === null) {
return '';
}
$description = trim($description);
return preg_replace(
'/^Auto-generated on \d{4}-\d{2}-\d{2}\s*[—-]\s*reason:\s*/i',
'',
$description
);
}
private function normalizeDate($value): ?string
{
if ($value === null || $value === '') {
return null;
}
$date = preg_replace('/[^0-9\-]/', '', (string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) ? $date : null;
}
private function getCurrentInvoiceBalance($invoiceId, $schoolYear): float
{
$invoice = $this->invoice->find($invoiceId);
if (!$invoice) {
return 0.0;
}
$qb = $this->payment
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear);
$table = $this->payment->table;
if ($this->db->getSchemaBuilder()->hasColumn($table, 'status')) {
$qb->where(function ($q) {
$q->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
->orWhereNull('status');
});
}
if ($this->db->getSchemaBuilder()->hasColumn($table, 'is_void')) {
$qb->where(function ($q) {
$q->where('is_void', 0)
->orWhereNull('is_void');
});
}
$rowPaid = $qb->first();
$totalPaid = (float) ($rowPaid['total_paid'] ?? 0);
$rowDisc = DB::table('discount_usages as du')
->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_disc')
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
->where('du.invoice_id', $invoiceId)
->where('i.school_year', $schoolYear)
->first();
$totalDisc = (float) ($rowDisc->total_disc ?? 0);
$rowRefund = DB::table('refunds')
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->first();
$totalRefundPaid = (float) ($rowRefund->total_refund_paid ?? 0);
$total = (float) ($invoice['total_amount'] ?? 0);
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
}
private function updateEnrollmentStatusIfPaid(int $invoiceId): int
{
$invoice = $this->invoice->find($invoiceId);
if (!$invoice) {
log_message('warning', 'Invoice not found: {id}', ['id' => $invoiceId]);
return 0;
}
$total = (float) ($invoice['total_amount'] ?? 0);
$balance = (float) ($invoice['balance'] ?? 0);
if (!($total > 0 && $balance < $total)) {
log_message('info', 'No payment yet. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
return 0;
}
$parentId = (int) ($invoice['parent_id'] ?? 0);
if ($parentId <= 0 || $this->schoolYear === '') {
log_message('warning', 'Missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]);
return 0;
}
$paidEnrollmentIds = [];
try {
$rows = DB::table('invoice_items')
->select('enrollment_id')
->where('invoice_id', $invoiceId)
->whereNotNull('enrollment_id')
->get()
->pluck('enrollment_id')
->map('intval')
->filter(fn($eid) => $eid > 0)
->unique()
->values()
->toArray();
$paidEnrollmentIds = $rows;
} catch (\Throwable $e) {
// ignore
}
$builder = DB::table('enrollments')
->where('parent_id', $parentId)
->where('school_year', $this->schoolYear)
->where(function ($query) {
$query->where('enrollment_status', 'payment pending')
->orWhere('enrollment_status', 'Payment pending')
->orWhere('enrollment_status', 'Payment Pending')
->orWhere('enrollment_status', 'PAYMENT PENDING')
->orWhereRaw("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'");
});
if ($this->semester !== null) {
$builder->where('semester', $this->semester);
}
if (!empty($paidEnrollmentIds)) {
$builder->whereIn('id', $paidEnrollmentIds);
}
$toUpdateIds = $builder->pluck('id')->map('intval')->toArray();
if (empty($toUpdateIds)) {
return 0;
}
$updated = DB::table('enrollments')
->whereIn('id', $toUpdateIds)
->update([
'enrollment_status' => 'enrolled',
'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
]);
return $updated;
}
private function buildPaymentEventData(
int $invoiceId,
string $transactionId,
float $amount,
string $paymentMethod,
string $paymentDate,
?string $checkNumber,
int $installmentSeq,
float $preBalance,
float $postBalance
): array {
$invoice = $this->invoice->find($invoiceId);
if (!$invoice) {
throw new \RuntimeException("Invoice {$invoiceId} not found");
}
$parentRow = DB::table('users')
->select('id', 'firstname', 'lastname', 'email')
->where('id', $invoice['parent_id'])
->first();
if (!$parentRow) {
throw new \RuntimeException("Parent not found for invoice {$invoiceId}");
}
$studentRows = DB::table('students as s')
->select('s.id', 's.firstname', 's.lastname', 'sc.class_section_id')
->join('student_class as sc', 'sc.student_id', '=', 's.id')
->where('s.parent_id', $invoice['parent_id'])
->where('sc.school_year', $this->schoolYear)
->when($this->semester !== null, fn($q) => $q->where('sc.semester', $this->semester))
->get()
->toArray();
$data = [
'user_id' => (int) $parentRow->id,
'email' => $parentRow->email,
'firstname' => $parentRow->firstname,
'lastname' => $parentRow->lastname,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'invoice_id' => $invoiceId,
'transaction_id' => $transactionId,
'payment_date' => $paymentDate,
'amount' => $amount,
'method' => $paymentMethod,
'check_number' => $checkNumber,
'installment_seq' => $installmentSeq,
'invoice_total' => (float) $invoice['total_amount'],
'pre_balance' => $preBalance,
'post_balance' => $postBalance,
'portalLink' => URL::to('/parent/invoices/' . $invoiceId),
];
return [$data, $studentRows];
}
}
@@ -1,170 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class EmailExtractorController extends BaseApiController
{
/**
* GET /api/emails
* Returns JSON: { users: string[], parents: string[] }
* Pulls emails from users.email and parents.secondparent_email
*/
public function getEmails()
{
try {
$users = [];
$parents = [];
// Fetch users.email (non-null, non-empty)
$userRows = DB::table('users')->select('email')->get();
foreach ($userRows as $row) {
$email = strtolower(trim((string)($row->email ?? '')));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$users[] = $email;
}
}
// De-duplicate
$users = array_values(array_unique($users));
// Fetch parents.secondparent_email (non-null, non-empty)
$parentRows = DB::table('parents')->select('secondparent_email')->get();
foreach ($parentRows as $row) {
$email = strtolower(trim((string)($row->secondparent_email ?? '')));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$parents[] = $email;
}
}
// De-duplicate
$parents = array_values(array_unique($parents));
return $this->success([
'users' => $users,
'parents' => $parents,
], 'Emails retrieved successfully');
} catch (\Throwable $e) {
Log::error('Email extraction error: ' . $e->getMessage());
return $this->respondError('Database error: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/compare
* Accepts multipart/form-data with a CSV file named 'file' (optional),
* or JSON body with { csvEmails: string[] } (optional).
* Compares against DB and returns:
* {
* existed: string[], // in CSV AND in DB
* needToAdd: string[], // in DB BUT NOT in CSV
* counts: { csv: number, db: number, users: number, parents: number }
* }
*/
public function compare()
{
$csvEmails = [];
// 1) Try read from uploaded file
if ($this->laravelRequest->hasFile('file')) {
$file = $this->laravelRequest->file('file');
if ($file->isValid()) {
$contents = file_get_contents($file->getRealPath());
$csvEmails = $this->extractEmailsFromText($contents);
}
}
// 2) Or from JSON body
if (empty($csvEmails)) {
$data = $this->payloadData();
if (isset($data['csvEmails']) && is_array($data['csvEmails'])) {
$csvEmails = $this->normalizeEmailArray($data['csvEmails']);
}
}
// 3) Compare with DB
try {
// Fetch DB emails
$users = [];
$parents = [];
$userRows = DB::table('users')->select('email')->get();
foreach ($userRows as $row) {
$e = strtolower(trim((string)($row->email ?? '')));
if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) {
$users[] = $e;
}
}
$users = array_values(array_unique($users));
$parentRows = DB::table('parents')->select('secondparent_email')->get();
foreach ($parentRows as $row) {
$e = strtolower(trim((string)($row->secondparent_email ?? '')));
if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) {
$parents[] = $e;
}
}
$parents = array_values(array_unique($parents));
// Sets for comparison
$csvSet = array_flip(array_values(array_unique($csvEmails)));
$dbUnion = array_values(array_unique(array_merge($users, $parents)));
$dbSet = array_flip($dbUnion);
// existed: in CSV and in DB
$existed = [];
foreach ($csvSet as $email => $_) {
if (isset($dbSet[$email])) {
$existed[] = $email;
}
}
sort($existed);
// needToAdd: in DB but NOT in CSV
$needToAdd = [];
foreach ($dbSet as $email => $_) {
if (!isset($csvSet[$email])) {
$needToAdd[] = $email;
}
}
sort($needToAdd);
return $this->success([
'existed' => $existed,
'needToAdd' => $needToAdd,
'counts' => [
'csv' => count($csvEmails),
'db' => count($dbUnion),
'users' => count($users),
'parents' => count($parents),
],
], 'Comparison completed successfully');
} catch (\Throwable $e) {
Log::error('Email comparison error: ' . $e->getMessage());
return $this->respondError('Database error: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
// Helpers
private function normalizeEmailArray(array $arr): array
{
$out = [];
foreach ($arr as $e) {
$email = strtolower(trim((string)$e));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$out[] = $email;
}
}
return array_values(array_unique($out));
}
private function extractEmailsFromText(string $text): array
{
$pattern = '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i';
preg_match_all($pattern, $text, $matches);
$emails = $matches[0] ?? [];
return $this->normalizeEmailArray($emails);
}
}
@@ -1,251 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\EmergencyContact;
use App\Models\Student;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class EmergencyContactController extends BaseApiController
{
protected EmergencyContact $emergencyContact;
protected Configuration $config;
protected Student $student;
protected User $user;
public function __construct()
{
parent::__construct();
$this->emergencyContact = model(EmergencyContact::class);
$this->config = model(Configuration::class);
$this->student = model(Student::class);
$this->user = model(User::class);
}
public function index()
{
$parentId = $this->request->getGet('parent_id');
$schoolYear = $this->config->getConfig('school_year');
$semester = $this->config->getConfig('semester');
$query = $this->emergencyContact->newQuery();
if ($parentId) {
$query->where('parent_id', $parentId);
}
if ($schoolYear) {
$query->where('school_year', $schoolYear);
}
if ($semester) {
$query->where('semester', $semester);
}
$contacts = $query->get()->toArray();
return $this->success($contacts, 'Emergency contacts retrieved successfully');
}
public function show($id = null)
{
$contact = $this->emergencyContact->find($id);
if (!$contact) {
return $this->respondError('Emergency contact not found', Response::HTTP_NOT_FOUND);
}
return $this->success($contact, 'Emergency contact retrieved successfully');
}
public function getByParent($id = null)
{
$schoolYear = $this->config->getConfig('school_year');
$semester = $this->config->getConfig('semester');
$contacts = $this->emergencyContact->newQuery()
->where('parent_id', $id);
if ($schoolYear) {
$contacts->where('school_year', $schoolYear);
}
if ($semester) {
$contacts->where('semester', $semester);
}
return $this->success($contacts->get()->toArray(), 'Emergency contacts retrieved successfully');
}
public function create()
{
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'parent_id' => 'required|integer',
'emergency_contact_name' => 'required|max_length[255]',
'cellphone' => 'required|max_length[20]',
'relation' => 'required|max_length[50]',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$payload = [
'parent_id' => $data['parent_id'],
'emergency_contact_name'=> $data['emergency_contact_name'],
'cellphone' => $data['cellphone'],
'email' => $data['email'] ?? null,
'relation' => $data['relation'],
'school_year' => $this->config->getConfig('school_year'),
'semester' => $this->config->getConfig('semester'),
];
try {
$contact = $this->emergencyContact->create($payload);
return $this->respondCreated([
'id' => $contact->id,
'data' => $contact,
], 'Emergency contact created successfully');
} catch (\Throwable $e) {
Log::error('Emergency contact creation error: ' . $e->getMessage());
return $this->respondError('Failed to create emergency contact', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update($id = null)
{
$contact = $this->emergencyContact->find($id);
if (!$contact) {
return $this->respondError('Emergency contact not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$allowed = ['emergency_contact_name', 'cellphone', 'email', 'relation'];
$updateData = [];
foreach ($allowed as $field) {
if (array_key_exists($field, $data)) {
$updateData[$field] = $data[$field];
}
}
if (empty($updateData)) {
return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST);
}
try {
$contact->fill($updateData);
$contact->save();
return $this->success($contact->fresh(), 'Emergency contact updated successfully');
} catch (\Throwable $e) {
Log::error('Emergency contact update error: ' . $e->getMessage());
return $this->respondError('Failed to update emergency contact', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function delete($id = null)
{
$contact = $this->emergencyContact->find($id);
if (!$contact) {
return $this->respondError('Emergency contact not found', Response::HTTP_NOT_FOUND);
}
try {
$contact->delete();
return $this->success(null, 'Emergency contact deleted successfully');
} catch (\Throwable $e) {
Log::error('Emergency contact deletion error: ' . $e->getMessage());
return $this->respondError('Failed to delete emergency contact', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/emergency-contacts/data
* Returns JSON payload for emergency contacts grouped by parent with parent info, students, and contacts.
*/
public function data()
{
try {
// Get distinct parent IDs from emergency contacts
$parentRows = $this->emergencyContact->newQuery()
->distinct()
->select('parent_id')
->get()
->toArray();
$groups = [];
foreach ($parentRows as $row) {
$parentId = (int)($row['parent_id'] ?? 0);
if ($parentId <= 0) {
continue;
}
// Get parent info
$parent = $this->user->find($parentId);
$parentName = 'Unknown Parent';
$parentPhone = '';
if ($parent) {
$parentName = trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')) ?: 'Unknown Parent';
$parentPhone = (string)($parent['cellphone'] ?? '');
}
// Try to load second parent phone from parents table
$secondPhone = '';
try {
$secondParentRow = DB::table('parents')
->select('secondparent_phone')
->where('firstparent_id', $parentId)
->orderBy('updated_at', 'DESC')
->first();
if ($secondParentRow && !empty($secondParentRow->secondparent_phone)) {
$secondPhone = (string)$secondParentRow->secondparent_phone;
}
} catch (\Throwable $e) {
Log::debug('EmergencyContactController: could not load second parent phone: ' . $e->getMessage());
}
// Get students for this parent
$students = $this->student->newQuery()
->select('id', 'firstname', 'lastname', 'school_id')
->where('parent_id', $parentId)
->get()
->toArray();
// Get emergency contacts for this parent
$contacts = $this->emergencyContact->newQuery()
->where('parent_id', $parentId)
->orderBy('updated_at', 'DESC')
->get()
->toArray();
// Filter out empty phone numbers
$parentPhones = array_values(array_filter([$parentPhone, $secondPhone], static fn($v) => (string)$v !== ''));
$groups[] = [
'parent_id' => $parentId,
'parent_name' => $parentName,
'students' => $students,
'contacts' => $contacts,
'parent_phones' => $parentPhones,
];
}
return $this->success([
'groups' => $groups,
], 'Emergency contacts grouped by parent retrieved successfully');
} catch (\Throwable $e) {
Log::error('Emergency contact data error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve emergency contact data', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,382 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Event;
use App\Models\EventCharges;
use App\Models\Student;
use App\Models\User;
use App\Http\Controllers\Api\InvoiceController;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
class EventController extends BaseApiController
{
protected Event $event;
protected Configuration $config;
protected EventCharges $eventCharges;
protected Student $student;
protected User $user;
protected InvoiceController $invoiceController;
protected ?string $schoolYear;
protected ?string $semester;
public function __construct()
{
parent::__construct();
$this->event = model(Event::class);
$this->config = model(Configuration::class);
$this->eventCharges = model(EventCharges::class);
$this->student = model(Student::class);
$this->user = model(User::class);
$this->invoiceController = app(InvoiceController::class);
$this->schoolYear = $this->config->getConfig('school_year');
$this->semester = $this->config->getConfig('semester');
}
public function index()
{
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$upcomingOnly = $this->request->getGet('upcoming') === 'true';
$query = $this->event->newQuery();
if ($schoolYear) {
$query->where('school_year', $schoolYear);
}
if ($upcomingOnly) {
$today = Carbon::now()->toDateString();
$query->where('expiration_date', '>=', $today);
}
$events = $query->orderBy('created_at', 'DESC')->get()->toArray();
// Count active events
$today = Carbon::now()->toDateString();
$activeEventCount = $this->event->newQuery()
->where('expiration_date', '>=', $today)
->count();
return $this->success([
'events' => $events,
'active_event_count' => $activeEventCount,
], 'Events retrieved successfully');
}
public function show($id = null)
{
$event = $this->event->find($id);
if (!$event) {
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
}
return $this->success($event, 'Event retrieved successfully');
}
public function create()
{
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'event_name' => 'required|max_length[255]',
'description' => 'nullable',
'amount' => 'required|numeric',
'expiration_date' => 'required|date',
'semester' => 'nullable',
'school_year' => 'nullable',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$flyerPath = null;
// Handle file upload if present
if ($this->laravelRequest->hasFile('flyer')) {
$file = $this->laravelRequest->file('flyer');
if ($file->isValid()) {
$path = $file->store('event_flyers', 'public');
$flyerPath = $path;
}
}
$payload = [
'event_name' => $data['event_name'],
'description' => $data['description'] ?? null,
'amount' => $data['amount'],
'flyer' => $flyerPath,
'expiration_date' => $data['expiration_date'],
'semester' => $data['semester'] ?? $this->semester,
'school_year' => $data['school_year'] ?? $this->schoolYear,
'created_by' => $this->getCurrentUserId(),
];
try {
$event = $this->event->create($payload);
return $this->respondCreated($event->toArray(), 'Event created successfully');
} catch (\Throwable $e) {
Log::error('Event creation error: ' . $e->getMessage());
return $this->respondError('Failed to create event', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update($id = null)
{
$event = $this->event->find($id);
if (!$event) {
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$flyerPath = $event['flyer']; // Keep existing flyer by default
// Handle file upload if present
if ($this->laravelRequest->hasFile('flyer')) {
$file = $this->laravelRequest->file('flyer');
if ($file->isValid()) {
// Delete old flyer if exists
if ($flyerPath && Storage::disk('public')->exists($flyerPath)) {
Storage::disk('public')->delete($flyerPath);
}
$path = $file->store('event_flyers', 'public');
$flyerPath = $path;
}
}
$updateData = [];
$allowed = ['event_name', 'description', 'amount', 'expiration_date', 'semester', 'school_year'];
foreach ($allowed as $field) {
if (array_key_exists($field, $data)) {
$updateData[$field] = $data[$field];
}
}
$updateData['flyer'] = $flyerPath;
try {
$this->event->update($id, $updateData);
$updated = $this->event->find($id);
return $this->success($updated->toArray(), 'Event updated successfully');
} catch (\Throwable $e) {
Log::error('Event update error: ' . $e->getMessage());
return $this->respondError('Failed to update event', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function delete($id = null)
{
$event = $this->event->find($id);
if (!$event) {
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
}
try {
// Delete related charges and collect parent IDs
$charges = $this->eventCharges->newQuery()
->where('event_id', $id)
->get()
->toArray();
$parentIds = [];
foreach ($charges as $charge) {
$this->eventCharges->delete($charge['id']);
$parentIds[] = $charge['parent_id'];
}
// Delete event
$this->event->delete($id);
$parentIds = array_unique($parentIds);
// Regenerate invoices for affected parents
// Note: generateInvoice method may need to be implemented in InvoiceController
foreach ($parentIds as $parentId) {
// This would need to be implemented or called differently
// $this->invoiceController->generateInvoice($parentId);
}
return $this->success(null, 'Event, charges, and invoices updated successfully');
} catch (\Throwable $e) {
Log::error('Event deletion error: ' . $e->getMessage());
return $this->respondError('Failed to delete event', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/events/charges
* Returns event charges with parent and student information
*/
public function charges()
{
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$semester = $this->request->getGet('semester') ?? $this->semester;
try {
$parents = $this->user->getParents();
$events = $this->event->getActiveEvents($this->schoolYear);
$charges = $this->eventCharges->newQuery()
->select([
'event_charges.*',
'users.firstname AS parent_firstname',
'users.lastname AS parent_lastname',
'students.firstname AS student_firstname',
'students.lastname AS student_lastname',
'events.event_name'
])
->leftJoin('users', 'users.id', '=', 'event_charges.parent_id')
->leftJoin('students', 'students.id', '=', 'event_charges.student_id')
->leftJoin('events', 'events.id', '=', 'event_charges.event_id')
->where('event_charges.school_year', $schoolYear)
->where('event_charges.semester', $semester)
->orderBy('event_charges.created_at', 'DESC')
->get()
->toArray();
return $this->success([
'charges' => $charges,
'parents' => $parents,
'events' => $events,
'school_year' => $schoolYear,
'semester' => $semester,
], 'Event charges retrieved successfully');
} catch (\Throwable $e) {
Log::error('Event charges retrieval error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve event charges', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/events/charges/update
* Updates event charges for a parent and students
*/
public function updateCharges()
{
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$schoolYear = $data['school_year'] ?? $this->schoolYear;
$semester = $data['semester'] ?? $this->semester;
$parentId = $data['parent_id'] ?? null;
$eventId = $data['event_id'] ?? null;
$participations = $data['participation'] ?? [];
if (!$parentId || !$eventId || empty($participations)) {
return $this->respondError('Missing required information: parent_id, event_id, and participation are required', Response::HTTP_BAD_REQUEST);
}
try {
$userId = $this->getCurrentUserId();
$event = $this->event->getEvent($eventId, $schoolYear);
if (!$event) {
return $this->respondError('Event not found', Response::HTTP_NOT_FOUND);
}
foreach ($participations as $studentId => $value) {
$existing = $this->eventCharges->newQuery()
->where('parent_id', $parentId)
->where('student_id', $studentId)
->where('event_id', $eventId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->first();
if ($value === 'no') {
if ($existing) {
$this->eventCharges->delete($existing['id']);
}
continue;
}
// value is 'yes'
$chargeData = [
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
'participation' => 'yes',
'charged' => $event['amount'],
'school_year' => $schoolYear,
'semester' => $semester,
];
if ($existing) {
$chargeData['updated_by'] = $userId;
$this->eventCharges->update($existing['id'], $chargeData);
} else {
$chargeData['updated_by'] = $userId;
$this->eventCharges->insert($chargeData);
}
}
// Regenerate invoice for parent
// Note: generateInvoice method may need to be implemented
// $this->invoiceController->generateInvoice($parentId);
return $this->success(null, 'Event charges updated successfully');
} catch (\Throwable $e) {
Log::error('Event charges update error: ' . $e->getMessage());
return $this->respondError('Failed to update event charges', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/events/students-with-charges
* Returns students for a parent with their charge status
*/
public function getStudentsWithCharges()
{
$parentId = $this->request->getGet('parent_id');
$semester = $this->request->getGet('semester') ?? $this->semester;
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
if (!$parentId) {
return $this->respondError('parent_id is required', Response::HTTP_BAD_REQUEST);
}
try {
// Get students for parent
$students = $this->student->newQuery()
->where('parent_id', $parentId)
->get()
->toArray();
// Get student_ids that already have charges
$chargedStudentIds = $this->eventCharges->newQuery()
->where('parent_id', $parentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->groupBy('student_id')
->pluck('student_id')
->toArray();
$data = [];
foreach ($students as $student) {
$data[] = [
'id' => $student['id'],
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
'charged' => in_array($student['id'], $chargedStudentIds),
];
}
return $this->success($data, 'Students with charges retrieved successfully');
} catch (\Throwable $e) {
Log::error('Get students with charges error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve students with charges', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,429 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Expense;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;
class ExpenseController extends BaseApiController
{
protected Expense $expense;
protected User $user;
protected Configuration $config;
protected ?string $schoolYear;
protected ?string $semester;
protected array $retailors;
public function __construct()
{
parent::__construct();
$this->expense = model(Expense::class);
$this->user = model(User::class);
$this->config = model(Configuration::class);
$this->schoolYear = $this->config->getConfig('school_year');
$this->semester = $this->config->getConfig('semester');
// Default list of common retailors; adjust as needed
$this->retailors = [
'Amazon',
'Walmart',
'Costco',
'BJ\'s',
'Market Basket',
'Aldi',
'Hannaford',
'Sam\'s Club',
'HomeGoods',
'Hostinger',
'Wicked Cheesy',
'Shatila',
'Brothers Pizzeria',
'Paradise Biryani Pointe',
'Emad Leiman',
'Nova Trampoline Park',
'Lubin\'s Awards',
'Dollar Tree',
'Stop & Shop',
'Dunkin\' Donuts',
'Giovanni\'s Pizza'
];
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$status = $this->request->getGet('status');
$category = $this->request->getGet('category');
$query = $this->expense
->select("
expenses.*,
u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname,
approver.firstname AS approver_firstname, approver.lastname AS approver_lastname
")
->join('users u', 'u.id = expenses.purchased_by', 'left')
->join('users approver', 'approver.id = expenses.approved_by', 'left');
if ($this->schoolYear) {
$query->where('expenses.school_year', $this->schoolYear);
}
if ($this->semester) {
$query->where('expenses.semester', $this->semester);
}
if ($status) {
$query->where('expenses.status', $status);
}
if ($category) {
$query->where('expenses.category', $category);
}
$expenses = $query->orderBy('expenses.created_at', 'DESC')->findAll();
// Enrich each row with a URL that goes through Files::receipt($name)
$expenses = array_map(function ($row) {
$name = $row['receipt_path'] ?? null;
$row['receipt_url'] = $this->receiptUrl($name);
return $row;
}, $expenses);
// Apply pagination manually for array results
$total = count($expenses);
$offset = ($page - 1) * $perPage;
$paginatedExpenses = array_slice($expenses, $offset, $perPage);
$result = [
'data' => $paginatedExpenses,
'pagination' => [
'current_page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => (int) ceil($total / $perPage),
],
];
return $this->success($result, 'Expenses retrieved successfully');
}
public function show($id = null)
{
$expense = $this->expense
->select("
expenses.*,
u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname,
approver.firstname AS approver_firstname, approver.lastname AS approver_lastname
")
->join('users u', 'u.id = expenses.purchased_by', 'left')
->join('users approver', 'approver.id = expenses.approved_by', 'left')
->where('expenses.id', $id)
->first();
if (!$expense) {
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
}
// Add receipt URL
$expense['receipt_url'] = $this->receiptUrl($expense['receipt_path'] ?? null);
return $this->success($expense, 'Expense retrieved successfully');
}
/**
* Get metadata for creating an expense (users list and retailors)
*/
public function createMetadata()
{
// Limit users to non-teacher/non-parent
$users = $this->user
->select('users.id, users.firstname, users.lastname')
->join('user_roles', 'user_roles.user_id = users.id')
->join('roles', 'roles.id = user_roles.role_id')
->whereNotIn('roles.name', ['teacher', 'parent'])
->distinct()
->findAll();
return $this->success([
'users' => $users,
'retailors' => $this->retailors,
], 'Metadata retrieved successfully');
}
public function create()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'category' => 'required|in:Expense,Purchase,Reimbursement',
'amount' => 'required|numeric|gt:0',
'purchased_by' => 'required',
'retailor' => 'nullable|max:255',
'date_of_purchase'=> 'nullable|date',
];
// Handle file upload validation
if ($this->laravelRequest->hasFile('receipt')) {
$rules['receipt'] = 'file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf';
} else {
$rules['receipt'] = 'required|file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf';
}
// Use Laravel Validator directly for file validation
$validator = Validator::make($this->laravelRequest->all(), $rules);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
// Parse "purchased_by" as "7|John Doe" if provided in that format
$purchasedById = $user->id;
if (isset($data['purchased_by'])) {
$purchasedInfo = (string) $data['purchased_by'];
if (strpos($purchasedInfo, '|') !== false) {
[$purchasedById, $purchasedByName] = array_pad(explode('|', $purchasedInfo, 2), 2, null);
$purchasedById = (int) $purchasedById;
} else {
$purchasedById = (int) $purchasedInfo;
}
}
// Handle file upload
$receiptName = null;
if ($this->laravelRequest->hasFile('receipt')) {
$file = $this->laravelRequest->file('receipt');
if ($file->isValid()) {
$targetDir = storage_path('app/uploads/receipts');
if (!is_dir($targetDir)) {
@mkdir($targetDir, 0755, true);
}
$extension = $file->getClientOriginalExtension();
$newName = uniqid('', true) . '.' . $extension;
$file->move($targetDir, $newName);
$receiptName = $newName;
}
}
$schoolYear = $this->schoolYear ?: date('Y');
$semester = $this->semester ?: 'Fall';
$expenseData = [
'category' => $data['category'],
'amount' => $data['amount'],
'receipt_path' => $receiptName,
'description' => $data['description'] ?? null,
'retailor' => !empty($data['retailor']) ? trim($data['retailor']) : null,
'date_of_purchase' => $data['date_of_purchase'] ?? null,
'purchased_by' => $purchasedById,
'added_by' => $user->id,
'school_year' => $schoolYear,
'semester' => $semester,
'status' => 'pending',
];
try {
$expense = $this->expense->create($expenseData);
$expense['receipt_url'] = $this->receiptUrl($receiptName);
return $this->respondCreated($expense, 'Expense created successfully');
} catch (\Throwable $e) {
log_message('error', 'Expense creation error: ' . $e->getMessage());
return $this->respondError('Failed to create expense', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Get metadata for editing an expense
*/
public function editMetadata($id = null)
{
$expense = $this->expense->find($id);
if (!$expense) {
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
}
// Get users list
$users = $this->user
->select('users.id, users.firstname, users.lastname')
->join('user_roles', 'user_roles.user_id = users.id')
->join('roles', 'roles.id = user_roles.role_id')
->whereNotIn('roles.name', ['teacher', 'parent'])
->distinct()
->findAll();
$expense['receipt_url'] = $expense['receipt_path'] ? $this->receiptUrl(basename($expense['receipt_path'])) : null;
return $this->success([
'expense' => $expense,
'users' => $users,
'retailors' => $this->retailors,
], 'Metadata retrieved successfully');
}
public function update($id = null)
{
$expense = $this->expense->find($id);
if (!$expense) {
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
}
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'category' => 'required|in:Expense,Purchase,Reimbursement',
'amount' => 'required|numeric|gt:0',
'purchased_by' => 'required',
'retailor' => 'nullable|max:255',
'date_of_purchase'=> 'nullable|date',
];
// Optional new receipt validation (only if provided)
if ($this->laravelRequest->hasFile('receipt')) {
$rules['receipt'] = 'file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf';
}
// Use Laravel Validator directly for file validation
$validator = Validator::make($this->laravelRequest->all(), $rules);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
// Parse "purchased_by" as "id|Name" if provided in that format
$purchasedById = $expense['purchased_by'];
if (isset($data['purchased_by'])) {
$purchasedInfo = (string) $data['purchased_by'];
if (strpos($purchasedInfo, '|') !== false) {
[$purchasedById] = array_pad(explode('|', $purchasedInfo, 2), 2, null);
$purchasedById = (int) $purchasedById;
} else {
$purchasedById = (int) $purchasedInfo;
}
}
// Keep old receipt unless replaced or removed
$receiptName = $expense['receipt_path'];
if ($this->laravelRequest->hasFile('receipt')) {
$file = $this->laravelRequest->file('receipt');
if ($file->isValid()) {
$targetDir = storage_path('app/uploads/receipts');
if (!is_dir($targetDir)) {
@mkdir($targetDir, 0755, true);
}
$extension = $file->getClientOriginalExtension();
$newName = uniqid('', true) . '.' . $extension;
$file->move($targetDir, $newName);
$receiptName = $newName;
}
}
if (isset($data['remove_receipt']) && $data['remove_receipt'] === '1') {
$receiptName = null;
}
$updateData = [
'category' => $data['category'],
'amount' => $data['amount'],
'description' => $data['description'] ?? $expense['description'],
'retailor' => isset($data['retailor']) ? (trim($data['retailor']) ?: null) : $expense['retailor'],
'date_of_purchase'=> $data['date_of_purchase'] ?? $expense['date_of_purchase'],
'purchased_by' => $purchasedById,
'receipt_path' => $receiptName,
'updated_by' => $user->id,
];
try {
$this->expense->update($id, $updateData);
$updatedExpense = $this->expense->find($id);
$updatedExpense['receipt_url'] = $this->receiptUrl($receiptName);
return $this->success($updatedExpense, 'Expense updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Expense update error: ' . $e->getMessage());
return $this->respondError('Failed to update expense', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function updateStatus()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
$id = isset($data['id']) ? (int)$data['id'] : null;
$status = $data['status'] ?? null;
$reason = $data['reason'] ?? '';
if (!$id || !in_array($status, ['approved', 'denied'], true)) {
log_message('error', 'Invalid status or ID');
return $this->respondError('Invalid data', Response::HTTP_BAD_REQUEST);
}
$expense = $this->expense->find($id);
if (!$expense) {
log_message('error', 'Expense not found for ID ' . $id);
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
}
try {
$this->expense->update($id, [
'status' => $status,
'status_reason' => $reason,
'approved_by' => $user->id,
'updated_by' => $user->id
]);
$updatedExpense = $this->expense->find($id);
return $this->success($updatedExpense, 'Expense status updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Expense update failed for ID ' . $id . ': ' . $e->getMessage());
return $this->respondError('Update failed', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function delete($id = null)
{
$expense = $this->expense->find($id);
if (!$expense) {
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
}
try {
$expense->delete();
return $this->success(null, 'Expense deleted successfully');
} catch (\Throwable $e) {
log_message('error', 'Expense delete error: ' . $e->getMessage());
return $this->respondError('Failed to delete expense', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Build a public URL for a receipt filename through Files::receipt($name).
* Expects just the filename (e.g., "1759113425_1c443e607e1900f92f62.png").
*/
private function receiptUrl(?string $filename): ?string
{
if (!$filename) {
return null;
}
// Using API route pattern - ensure route exists in routes/api.php
// Example: Route::get('files/receipt/{name}', [FilesController::class, 'receipt']);
return url('/api/v1/files/receipt/' . $filename);
}
}
@@ -1,446 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\AdditionalCharge;
use App\Models\Configuration;
use App\Models\User;
use App\Models\Invoice;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Symfony\Component\HttpFoundation\Response;
class ExtraChargesController extends BaseApiController
{
protected AdditionalCharge $additionalCharge;
protected Configuration $config;
protected User $user;
protected Invoice $invoice;
protected ?string $schoolYear;
protected ?string $semester;
public function __construct()
{
parent::__construct();
$this->additionalCharge = model(AdditionalCharge::class);
$this->user = model(User::class);
$this->invoice = model(Invoice::class);
$this->config = model(Configuration::class);
$this->schoolYear = $this->config->getConfig('school_year');
$this->semester = $this->config->getConfig('semester');
}
public function index()
{
$year = (string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? '');
$sem = (string) ($this->request->getGet('semester') ?? $this->semester ?? '');
$status = $this->request->getGet('status') ?: null;
$q = trim((string) ($this->request->getGet('q') ?? '')) ?: null;
$per = (int) ($this->request->getGet('per_page') ?? 50);
$paginator = $this->additionalCharge->listAllForTerm($year, $sem, $status, $q, $per);
$meta = [
'perPage' => $paginator->perPage(),
'page' => $paginator->currentPage(),
'total' => $paginator->total(),
'pageCount' => $paginator->lastPage(),
];
return $this->success([
'school_year' => $year,
'semester' => $sem,
'rows' => $paginator->items(),
'pager' => $meta,
], 'Extra charges retrieved successfully');
}
public function show($id = null)
{
$charge = $this->additionalCharge->find($id);
if (!$charge) {
return $this->respondError('Extra charge not found', Response::HTTP_NOT_FOUND);
}
return $this->success($charge->toArray(), 'Extra charge retrieved successfully');
}
/**
* Get parent options for Select2 (with search)
*/
public function parentOptions()
{
$q = trim((string) ($this->request->getGet('q') ?? ''));
$limit = 20;
$query = $this->user
->select('users.id, users.firstname, users.lastname, users.email')
->join('user_roles', 'user_roles.user_id = users.id')
->join('roles', 'roles.id = user_roles.role_id')
->whereRaw('LOWER(roles.name) = ?', ['parent'])
->whereNull('user_roles.deleted_at');
if ($q !== '') {
$query->where(function ($builder) use ($q) {
$builder->where('users.firstname', 'like', "%{$q}%")
->orWhere('users.lastname', 'like', "%{$q}%")
->orWhere('users.email', 'like', "%{$q}%");
});
}
$rows = $query
->orderBy('users.lastname', 'ASC')
->orderBy('users.firstname', 'ASC')
->limit($limit)
->findAll();
$results = array_map(function ($r) {
$name = trim(($r['lastname'] ?? '') . ', ' . ($r['firstname'] ?? ''));
$email = $r['email'] ?? '';
$label = $name !== ',' ? $name : ('User #' . $r['id']);
if ($email) $label .= ' — ' . $email;
$label .= ' (ID: ' . $r['id'] . ')';
return ['id' => (int) $r['id'], 'text' => $label];
}, $rows);
return $this->success(['results' => $results], 'Parent options retrieved successfully');
}
/**
* Get invoices for a parent (for Select2)
*/
public function invoicesForParent()
{
$parentId = (int) ($this->request->getGet('parent_id') ?? 0);
$schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? '');
$rows = ($parentId > 0)
? ($this->invoice->getInvoicesByUserId($parentId, $schoolYear) ?? [])
: [];
$results = array_map(function ($inv) {
$id = (int) ($inv['id'] ?? 0);
$num = (string) ($inv['invoice_number'] ?? ('INV-' . $id));
$status = strtolower((string) ($inv['status'] ?? ''));
$balance = (float) ($inv['balance'] ?? 0);
$issue = $inv['issue_date'] ?? null;
return [
'id' => $id,
'invoice_number' => $num,
'status' => $status,
'balance' => $balance,
'issue_date' => $issue,
'text' => $num
. ($status ? ' — ' . ucfirst($status) : '')
. ' (Bal: $' . number_format($balance, 2) . ')',
];
}, $rows);
return $this->success(['results' => $results], 'Invoices retrieved successfully');
}
public function store()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'parent_id' => 'required|integer',
'title' => 'required|string|min:2',
'amount' => 'required|numeric',
'charge_type' => 'required|in:add,deduct',
'due_date' => 'nullable|date',
'invoice_id' => 'nullable|integer',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$invoiceId = !empty($data['invoice_id']) ? (int) $data['invoice_id'] : null;
$chargeType = (string) $data['charge_type'];
$amountAbs = round(abs((float) $data['amount']), 2);
$signedAmount = ($chargeType === 'add') ? $amountAbs : -$amountAbs;
$payload = [
'parent_id' => (int) $data['parent_id'],
'invoice_id' => $invoiceId,
'school_year' => $data['school_year'] ?? $this->schoolYear,
'semester' => $data['semester'] ?? $this->semester,
'charge_type' => $chargeType,
'title' => trim($data['title']),
'description' => trim($data['description'] ?? ''),
'amount' => $signedAmount,
'due_date' => !empty($data['due_date']) ? $data['due_date'] : null,
'status' => $invoiceId ? 'applied' : 'pending',
'created_by' => $user->id,
'created_at' => Carbon::now('UTC')->toDateTimeString(),
];
try {
DB::beginTransaction();
// Get invoice before
$invoiceBefore = $this->normalizeRow(
$this->invoice->getInvoicesByParentId($data['parent_id'], $this->schoolYear)
);
// Insert charge
$chargeId = $this->additionalCharge->insert($payload);
if (!$chargeId) {
throw new \Exception('Failed to insert charge');
}
// Apply to invoice if present
if ($invoiceId) {
try {
if ($chargeType === 'add') {
$this->invoice->applyAdditionalCharge($invoiceId, $amountAbs);
} else {
$this->invoice->deductAdditionalCharge($invoiceId, $amountAbs);
}
} catch (\Throwable $e) {
log_message('error', 'apply/deductAdditionalCharge failed: ' . $e->getMessage());
throw $e;
}
}
// Get invoice after
$invoiceAfter = $this->normalizeRow(
$this->invoice->getInvoicesByParentId($data['parent_id'], $this->schoolYear)
);
// Get parent user info
$parentUser = $this->user->getUserInfoById($data['parent_id']);
DB::commit();
// Build event payload
$first = $parentUser['first_name'] ?? $parentUser['firstname'] ?? '';
$last = $parentUser['last_name'] ?? $parentUser['lastname'] ?? '';
$parentName = trim($first . ' ' . $last);
$invoiceTotal = $invoiceAfter ? (float) ($invoiceAfter['total_amount'] ?? 0) : 0.0;
$preBalance = $invoiceBefore ? (float) ($invoiceBefore['balance'] ?? 0) : 0.0;
$postBalance = $invoiceAfter ? (float) ($invoiceAfter['balance'] ?? 0) : 0.0;
$eventData = [
'user_id' => (int) ($parentUser['id'] ?? 0),
'email' => $parentUser['email'] ?? null,
'firstname' => $first,
'lastname' => $last,
'parentName' => $parentName,
'school_year' => $payload['school_year'],
'semester' => $payload['semester'],
'invoice_id' => $invoiceId,
'invoice_number' => $invoiceAfter['invoice_number'] ?? ($invoiceId ? 'INV-' . $invoiceId : null),
'charge_id' => $chargeId,
'charge_title' => $payload['title'],
'charge_desc' => $payload['description'],
'charge_type' => $payload['charge_type'],
'amount_signed' => $signedAmount,
'amount_abs' => $amountAbs,
'due_date' => $payload['due_date'],
'created_at' => $payload['created_at'],
'pre_balance' => $preBalance,
'post_balance' => $postBalance,
'invoice_total' => $invoiceTotal,
'portal_link' => url('/login'),
'invoice_link' => $invoiceId ? url('parent/invoices/view/' . $invoiceId) : url('parent/invoices'),
];
// Dispatch event
Event::dispatch('extraCharge', [$eventData]);
return $this->respondCreated([
'id' => $chargeId,
'invoice_id' => $invoiceId,
'parent_id' => (int) $data['parent_id'],
], 'Charge recorded successfully');
} catch (\Throwable $e) {
DB::rollBack();
log_message('error', 'Transaction failed in ExtraChargesController::store: ' . $e->getMessage());
return $this->respondError('Failed to save charge: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update($id = null)
{
$row = $this->additionalCharge->find($id);
if (!$row) {
return $this->respondError('Charge not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$newAmount = isset($data['amount']) ? (float) $data['amount'] : (float) $row['amount'];
$delta = $newAmount - (float) $row['amount'];
try {
DB::beginTransaction();
// Update the charge
$this->additionalCharge->update($id, [
'title' => trim($data['title'] ?? $row['title']),
'description' => trim($data['description'] ?? $row['description']),
'amount' => $newAmount,
'due_date' => $data['due_date'] ?? $row['due_date'],
'charge_type' => $data['charge_type'] ?? $row['charge_type'],
]);
// If it's already applied on an invoice and the amount changed, reflect the delta
if ($row['status'] === 'applied' && !empty($row['invoice_id']) && abs($delta) > 0.00001) {
if ($delta > 0) {
$this->invoice->applyAdditionalCharge((int) $row['invoice_id'], $delta);
} else {
$this->invoice->reverseAdditionalCharge((int) $row['invoice_id'], -$delta);
}
}
DB::commit();
$updated = $this->additionalCharge->find($id);
return $this->success($updated->toArray(), 'Charge updated successfully');
} catch (\Throwable $e) {
DB::rollBack();
log_message('error', 'Failed to update charge: ' . $e->getMessage());
return $this->respondError('Failed to update charge', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Mark a charge as void and roll back its impact on the invoice if applied.
*/
public function void($id = null)
{
$row = $this->additionalCharge->find((int) $id);
if (!$row) {
return $this->respondError('Charge not found', Response::HTTP_NOT_FOUND);
}
$invoiceId = (int) ($row['invoice_id'] ?? 0);
$amountAbs = round(abs((float) ($row['amount'] ?? 0)), 2);
$chargeType = (string) ($row['charge_type'] ?? 'add');
$status = (string) ($row['status'] ?? 'pending');
try {
DB::beginTransaction();
if ($status === 'applied' && $invoiceId > 0 && $amountAbs > 0) {
try {
if ($chargeType === 'add') {
$this->invoice->reverseAdditionalCharge($invoiceId, $amountAbs);
} else {
// voiding a deduction -> add back
$this->invoice->applyAdditionalCharge($invoiceId, $amountAbs);
}
} catch (\Throwable $e) {
log_message('error', 'void(): invoice adjust failed: ' . $e->getMessage());
throw $e;
}
}
$this->additionalCharge->update((int) $id, [
'status' => 'void',
]);
DB::commit();
return $this->success(null, 'Charge voided successfully');
} catch (\Throwable $e) {
DB::rollBack();
log_message('error', 'Failed to void charge: ' . $e->getMessage());
return $this->respondError('Failed to void charge', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Reverse a previously applied charge: undo invoice impact and return to pending state.
*/
public function reverse($id = null)
{
$row = $this->additionalCharge->find((int) $id);
if (!$row) {
return $this->respondError('Charge not found', Response::HTTP_NOT_FOUND);
}
$invoiceId = (int) ($row['invoice_id'] ?? 0);
$amountAbs = round(abs((float) ($row['amount'] ?? 0)), 2);
$chargeType = (string) ($row['charge_type'] ?? 'add');
$status = (string) ($row['status'] ?? 'pending');
if ($status !== 'applied' || $invoiceId <= 0 || $amountAbs <= 0) {
return $this->respondError('Nothing to reverse', Response::HTTP_BAD_REQUEST);
}
try {
DB::beginTransaction();
try {
if ($chargeType === 'add') {
$this->invoice->reverseAdditionalCharge($invoiceId, $amountAbs);
} else {
$this->invoice->applyAdditionalCharge($invoiceId, $amountAbs);
}
} catch (\Throwable $e) {
log_message('error', 'reverse(): invoice adjust failed: ' . $e->getMessage());
throw $e;
}
$this->additionalCharge->update((int) $id, [
'status' => 'pending',
'invoice_id' => null,
]);
DB::commit();
return $this->success(null, 'Charge reversed to pending successfully');
} catch (\Throwable $e) {
DB::rollBack();
log_message('error', 'Failed to reverse charge: ' . $e->getMessage());
return $this->respondError('Failed to reverse charge', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function delete($id = null)
{
$charge = $this->additionalCharge->find($id);
if (!$charge) {
return $this->respondError('Extra charge not found', Response::HTTP_NOT_FOUND);
}
try {
$charge->delete();
return $this->success(null, 'Extra charge deleted successfully');
} catch (\Throwable $e) {
log_message('error', 'Failed to delete charge: ' . $e->getMessage());
return $this->respondError('Failed to delete charge', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Normalize query result into a single associative row
*/
private function normalizeRow($row): ?array
{
if (!$row) return null;
if (is_array($row) && isset($row[0]) && is_array($row[0])) {
return $row[0]; // unwrap first row from a result set
}
return is_array($row) ? $row : null;
}
}
@@ -1,525 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\StudentClass;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class FamilyAdminController extends BaseApiController
{
protected Configuration $config;
protected Invoice $invoice;
protected Payment $payment;
protected StudentClass $studentClass;
protected ?string $schoolYear;
public function __construct()
{
parent::__construct();
$this->config = model(Configuration::class);
$this->invoice = model(Invoice::class);
$this->payment = model(Payment::class);
$this->studentClass = model(StudentClass::class);
$this->schoolYear = $this->config->getConfig('school_year');
}
public function index()
{
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
$guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
try {
$data = [
'student' => null,
'families' => [],
'guardians' => [],
'students' => [],
'search_students' => [],
'search_guardians' => [],
];
// Simple student list for select
$data['students'] = DB::table('students')
->select('id', DB::raw("CONCAT(lastname, ', ', firstname) AS name"))
->orderBy('lastname')
->orderBy('firstname')
->get()
->map(fn($row) => (array) $row)
->toArray();
// Preload search datasets
$data['search_students'] = DB::table('students')
->select('id', 'firstname', 'lastname')
->orderBy('lastname')
->orderBy('firstname')
->get()
->map(fn($row) => (array) $row)
->toArray();
$data['search_guardians'] = DB::table('family_guardians as fg')
->distinct()
->select('u.id', 'u.firstname', 'u.lastname', 'u.email', 'u.cellphone')
->join('users as u', 'u.id', '=', 'fg.user_id')
->orderBy('u.lastname')
->orderBy('u.firstname')
->get()
->map(fn($row) => (array) $row)
->toArray();
// If a guardian is provided, resolve one of their students
if (!$studentId && $guardianId) {
$row = DB::table('family_guardians as fg')
->select('s.id as student_id')
->join('family_students as fs', 'fs.family_id', '=', 'fg.family_id')
->join('students as s', 's.id', '=', 'fs.student_id')
->where('fg.user_id', $guardianId)
->orderBy('s.lastname')
->orderBy('s.firstname')
->first();
if (!$row) {
$row = DB::table('students as s')
->select('s.id as student_id')
->where('s.parent_id', $guardianId)
->orderBy('s.lastname')
->orderBy('s.firstname')
->first();
}
if ($row && !empty($row->student_id)) {
$studentId = (int) $row->student_id;
}
}
if ($studentId > 0) {
$student = DB::table('students')
->select('id', 'firstname', 'lastname')
->where('id', $studentId)
->first();
if ($student) {
$data['student'] = (array) $student;
// Fetch all families for this student
$families = DB::table('family_students as fs')
->select(
'f.id', 'f.family_code', 'f.household_name', 'f.address_line1', 'f.address_line2',
'f.city', 'f.state', 'f.postal_code', 'f.country', 'f.primary_phone',
'f.preferred_lang', 'f.preferred_contact_method', 'f.is_active',
'fs.is_primary_home'
)
->join('families as f', 'f.id', '=', 'fs.family_id')
->where('fs.student_id', $studentId)
->orderByDesc('fs.is_primary_home')
->orderBy('f.household_name')
->get()
->map(fn($row) => (array) $row)
->toArray();
// Enrich each family with guardians, students, and financials
foreach ($families as &$fam) {
$fid = (int) $fam['id'];
// Guardians
$guardians = DB::table('family_guardians as fg')
->select(
'u.id AS user_id', 'u.firstname', 'u.lastname', 'u.email', 'u.cellphone',
'u.address_street', 'u.city', 'u.state', 'u.zip',
'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms'
)
->join('users as u', 'u.id', '=', 'fg.user_id')
->where('fg.family_id', $fid)
->orderByDesc('fg.is_primary')
->orderBy('u.lastname')
->orderBy('u.firstname')
->get()
->map(fn($row) => (array) $row)
->toArray();
$fam['guardians'] = $guardians;
// Students in this family
$studentsRows = DB::table('family_students as fs')
->select('s.id', 's.firstname', 's.lastname')
->join('students as s', 's.id', '=', 'fs.student_id')
->where('fs.family_id', $fid)
->orderBy('s.lastname')
->orderBy('s.firstname')
->get()
->map(fn($row) => (array) $row)
->toArray();
// Enrich with grade label
if (!empty($studentsRows)) {
foreach ($studentsRows as &$sr) {
$sid = (int) ($sr['id'] ?? 0);
$sr['grade'] = $sid && $this->schoolYear
? (string) ($this->studentClass->getClassSectionsByStudentId($sid, $this->schoolYear) ?? '')
: '';
}
unset($sr);
}
$fam['students'] = $studentsRows;
// Financials: invoices and payments for all guardians
$parentIds = array_map(static fn($g) => (int) ($g['user_id'] ?? 0), $guardians);
$parentIds = array_values(array_filter($parentIds));
$fam['invoices'] = [];
$fam['payments'] = [];
$fam['finance_summary'] = [
'invoices_count' => 0,
'total_amount' => 0.0,
'paid_amount' => 0.0,
'balance' => 0.0,
];
if (!empty($parentIds)) {
// Invoices
$invRows = DB::table('invoices')
->select(
'id', 'parent_id', 'invoice_number', 'status', 'total_amount',
'paid_amount', 'balance', 'issue_date', 'due_date'
)
->whereIn('parent_id', $parentIds)
->orderByDesc('issue_date')
->get()
->map(fn($row) => (array) $row)
->toArray();
$fam['invoices'] = $invRows;
// Map invoice id -> number
$invoiceMap = [];
foreach ($invRows as $ir) {
$invoiceMap[(int) $ir['id']] = (string) ($ir['invoice_number'] ?? '');
}
$fam['invoice_map'] = $invoiceMap;
// Aggregate summary
foreach ($invRows as $ir) {
$fam['finance_summary']['invoices_count']++;
$fam['finance_summary']['total_amount'] += (float) ($ir['total_amount'] ?? 0);
$fam['finance_summary']['paid_amount'] += (float) ($ir['paid_amount'] ?? 0);
$fam['finance_summary']['balance'] += (float) ($ir['balance'] ?? 0);
}
// Recent payments (limit 10)
$payRows = DB::table('payments')
->select(
'id', 'parent_id', 'invoice_id', 'paid_amount', 'balance',
'payment_method', 'payment_date', 'status'
)
->whereIn('parent_id', $parentIds)
->orderByDesc('payment_date')
->limit(10)
->get()
->map(fn($row) => (array) $row)
->toArray();
$fam['payments'] = $payRows;
}
}
unset($fam);
$data['families'] = $families;
// Back-compat: also expose guardians of first family
if (!empty($families)) {
$familyId = (int) $families[0]['id'];
$data['guardians'] = DB::table('family_guardians as fg')
->select(
'u.id', 'u.firstname', 'u.lastname', 'u.email',
'fg.relation', 'fg.is_primary', 'fg.receive_emails'
)
->join('users as u', 'u.id', '=', 'fg.user_id')
->where('fg.family_id', $familyId)
->orderByDesc('fg.is_primary')
->orderBy('u.lastname')
->orderBy('u.firstname')
->get()
->map(fn($row) => (array) $row)
->toArray();
}
}
}
return $this->success($data, 'Family admin data retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Family admin error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve family admin data', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function search()
{
$q = trim((string) ($this->request->getGet('q') ?? ''));
if ($q === '') {
return $this->success(['items' => []], 'Search completed');
}
try {
$qs = '%' . $q . '%';
// Students suggestions
$students = DB::table('students')
->select('id', 'firstname', 'lastname')
->whereRaw("CONCAT_WS(' ', firstname, lastname) LIKE ?", [$qs])
->orderBy('lastname')
->orderBy('firstname')
->limit(8)
->get()
->map(fn($row) => (array) $row)
->toArray();
// Parents/Guardians suggestions
$guardians = DB::table('users')
->select('id', 'firstname', 'lastname', 'email', 'cellphone')
->where(function ($query) use ($qs) {
$query->whereRaw("CONCAT_WS(' ', firstname, lastname) LIKE ?", [$qs])
->orWhere('email', 'like', $qs)
->orWhere('cellphone', 'like', $qs);
})
->orderBy('lastname')
->orderBy('firstname')
->limit(8)
->get()
->map(fn($row) => (array) $row)
->toArray();
$items = [];
foreach ($students as $s) {
$items[] = [
'type' => 'student',
'id' => (int) $s['id'],
'label' => trim(($s['firstname'] ?? '') . ' ' . ($s['lastname'] ?? '')),
'sub' => 'Student',
];
}
foreach ($guardians as $g) {
$items[] = [
'type' => 'guardian',
'id' => (int) $g['id'],
'label' => trim(($g['firstname'] ?? '') . ' ' . ($g['lastname'] ?? '')),
'sub' => trim(($g['email'] ?? '') . ' ' . ($g['cellphone'] ?? '')),
];
}
return $this->success(['items' => $items], 'Search completed successfully');
} catch (\Throwable $e) {
log_message('error', 'Family admin search error: ' . $e->getMessage());
return $this->respondError('Failed to perform search', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function card()
{
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
$guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
$familyId = (int) ($this->request->getGet('family_id') ?? 0);
try {
if (!$familyId) {
if ($studentId) {
$row = DB::table('family_students as fs')
->select('f.id')
->join('families as f', 'f.id', '=', 'fs.family_id')
->where('fs.student_id', $studentId)
->orderByDesc('fs.is_primary_home')
->orderBy('f.household_name')
->first();
if ($row && !empty($row->id)) {
$familyId = (int) $row->id;
}
} elseif ($guardianId) {
// Try via guardians link
$row = DB::table('family_guardians as fg')
->select('f.id')
->join('families as f', 'f.id', '=', 'fg.family_id')
->where('fg.user_id', $guardianId)
->orderBy('f.household_name')
->first();
if ($row && !empty($row->id)) {
$familyId = (int) $row->id;
} else {
// Fallback via students.parent_id → family_students
$row = DB::table('students as s')
->select('f.id')
->join('family_students as fs', 'fs.student_id', '=', 's.id')
->join('families as f', 'f.id', '=', 'fs.family_id')
->where('s.parent_id', $guardianId)
->orderByDesc('fs.is_primary_home')
->orderBy('f.household_name')
->first();
if ($row && !empty($row->id)) {
$familyId = (int) $row->id;
}
}
}
}
if (!$familyId) {
return $this->respondError('Family not found', Response::HTTP_NOT_FOUND);
}
$family = DB::table('families as f')
->select(
'f.id', 'f.family_code', 'f.household_name', 'f.address_line1', 'f.address_line2',
'f.city', 'f.state', 'f.postal_code', 'f.country', 'f.primary_phone',
'f.preferred_lang', 'f.preferred_contact_method', 'f.is_active'
)
->where('f.id', $familyId)
->first();
if (!$family) {
return $this->respondError('Family not found', Response::HTTP_NOT_FOUND);
}
$family = (array) $family;
// Guardians
$guardians = DB::table('family_guardians as fg')
->select(
'u.id AS user_id', 'u.firstname', 'u.lastname', 'u.email', 'u.cellphone',
'u.address_street', 'u.city', 'u.state', 'u.zip',
'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms'
)
->join('users as u', 'u.id', '=', 'fg.user_id')
->where('fg.family_id', $familyId)
->orderByDesc('fg.is_primary')
->orderBy('u.lastname')
->orderBy('u.firstname')
->get()
->map(fn($row) => (array) $row)
->toArray();
$family['guardians'] = $guardians;
// Students
$studentsRows = DB::table('family_students as fs')
->select('s.id', 's.firstname', 's.lastname')
->join('students as s', 's.id', '=', 'fs.student_id')
->where('fs.family_id', $familyId)
->orderBy('s.lastname')
->orderBy('s.firstname')
->get()
->map(fn($row) => (array) $row)
->toArray();
if (!empty($studentsRows)) {
foreach ($studentsRows as &$sr) {
$sid = (int) ($sr['id'] ?? 0);
$sr['grade'] = $sid && $this->schoolYear
? (string) ($this->studentClass->getClassSectionsByStudentId($sid, $this->schoolYear) ?? '')
: '';
}
unset($sr);
}
$family['students'] = $studentsRows;
// Financials
$parentIds = array_map(static fn($g) => (int) ($g['user_id'] ?? 0), $guardians);
$parentIds = array_values(array_filter($parentIds));
$family['invoices'] = [];
$family['payments'] = [];
$family['finance_summary'] = [
'invoices_count' => 0,
'total_amount' => 0.0,
'paid_amount' => 0.0,
'balance' => 0.0,
];
// Emergency contacts
$family['emergency_contacts'] = [];
if (!empty($parentIds)) {
// Map guardian name by user_id
$gmap = [];
foreach ($guardians as $g) {
$gid = (int) ($g['user_id'] ?? 0);
if ($gid > 0) {
$gmap[$gid] = trim(($g['firstname'] ?? '') . ' ' . ($g['lastname'] ?? ''));
}
}
$ecRows = DB::table('emergency_contacts')
->select(
'id', 'parent_id', 'emergency_contact_name', 'relation', 'cellphone', 'email',
'school_year', 'semester', 'created_at', 'updated_at'
)
->whereIn('parent_id', $parentIds)
->orderByDesc('updated_at')
->get()
->map(fn($row) => (array) $row)
->toArray();
if (!empty($ecRows)) {
foreach ($ecRows as &$ec) {
$pid = (int) ($ec['parent_id'] ?? 0);
$ec['parent_label'] = $gmap[$pid] ?? ('Parent #' . $pid);
}
unset($ec);
$family['emergency_contacts'] = $ecRows;
}
// Invoices
$invRows = DB::table('invoices')
->select(
'id', 'parent_id', 'invoice_number', 'status', 'total_amount',
'paid_amount', 'balance', 'issue_date', 'due_date'
)
->whereIn('parent_id', $parentIds)
->orderByDesc('issue_date')
->get()
->map(fn($row) => (array) $row)
->toArray();
$family['invoices'] = $invRows;
$invoiceMap = [];
foreach ($invRows as $ir) {
$invoiceMap[(int) $ir['id']] = (string) ($ir['invoice_number'] ?? '');
}
$family['invoice_map'] = $invoiceMap;
foreach ($invRows as $ir) {
$family['finance_summary']['invoices_count']++;
$family['finance_summary']['total_amount'] += (float) ($ir['total_amount'] ?? 0);
$family['finance_summary']['paid_amount'] += (float) ($ir['paid_amount'] ?? 0);
$family['finance_summary']['balance'] += (float) ($ir['balance'] ?? 0);
}
// Payments
$payRows = DB::table('payments')
->select(
'id', 'parent_id', 'invoice_id', 'paid_amount', 'balance',
'payment_method', 'payment_date', 'status'
)
->whereIn('parent_id', $parentIds)
->orderByDesc('payment_date')
->limit(10)
->get()
->map(fn($row) => (array) $row)
->toArray();
$family['payments'] = $payRows;
}
return $this->success(['family' => $family], 'Family card retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Family card error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve family card', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,651 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\FamilyGuardian;
use App\Models\Family;
use App\Models\FamilyStudent;
use App\Models\Student;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class FamilyController extends BaseApiController
{
protected Family $family;
protected FamilyStudent $familyStudent;
protected FamilyGuardian $familyGuardian;
protected Student $student;
protected User $user;
public function __construct()
{
parent::__construct();
$this->family = model(Family::class);
$this->familyStudent = model(FamilyStudent::class);
$this->familyGuardian = model(FamilyGuardian::class);
$this->student = model(Student::class);
$this->user = model(User::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$result = $this->paginate($this->family->newQuery()->orderBy('id'), $page, $perPage);
foreach ($result['data'] as &$family) {
$family['students'] = $this->familyStudent
->where('family_id', $family['id'])
->findAll();
$family['guardians'] = $this->familyGuardian
->where('family_id', $family['id'])
->findAll();
}
return $this->success($result, 'Families retrieved successfully');
}
public function show($id = null)
{
$family = $this->family->find($id);
if (!$family) {
return $this->respondError('Family not found', Response::HTTP_NOT_FOUND);
}
$family['students'] = $this->familyStudent->where('family_id', $id)->findAll();
$family['guardians'] = $this->familyGuardian->where('family_id', $id)->findAll();
return $this->success($family, 'Family retrieved successfully');
}
public function getByStudent($id = null)
{
$familyIds = $this->familyStudent->where('student_id', $id)->findColumn('family_id');
if (empty($familyIds)) {
return $this->success([], 'No families found for this student');
}
$families = $this->family->whereIn('id', $familyIds)->get();
$payload = [];
foreach ($families as $family) {
$family->students = $this->familyStudent->where('family_id', $family->id)->findAll();
$family->guardians = $this->familyGuardian->where('family_id', $family->id)->findAll();
$payload[] = $family;
}
return $this->success($payload, 'Families retrieved successfully');
}
public function getGuardians($id = null)
{
$family = $this->family->find($id);
if (!$family) {
return $this->respondError('Family not found', Response::HTTP_NOT_FOUND);
}
$guardians = $this->familyGuardian->where('family_id', $id)->findAll();
return $this->success($guardians, 'Guardians retrieved successfully');
}
/**
* Get families for a student
* GET /api/v1/families/by-student/{student_id}
*/
public function familiesByStudent(int $studentId)
{
$rows = DB::table('family_students as fs')
->select('f.*', 'fs.is_primary_home')
->join('families as f', 'f.id', '=', 'fs.family_id')
->where('fs.student_id', $studentId)
->where('f.is_active', 1)
->orderByDesc('fs.is_primary_home')
->orderBy('f.household_name')
->get()
->map(fn($row) => (array) $row)
->toArray();
return $this->success(['data' => $rows], 'Families retrieved successfully');
}
/**
* Get guardians for a family
* GET /api/v1/families/{family_id}/guardians
*/
public function guardiansByFamily(int $familyId)
{
$rows = DB::table('family_guardians as fg')
->select(
'u.id AS user_id', 'u.firstname', 'u.lastname', 'u.email',
'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms'
)
->join('users as u', 'u.id', '=', 'fg.user_id')
->where('fg.family_id', $familyId)
->orderByDesc('fg.is_primary')
->orderBy('u.lastname')
->orderBy('u.firstname')
->get()
->map(fn($row) => (array) $row)
->toArray();
return $this->success(['data' => $rows], 'Guardians retrieved successfully');
}
/**
* Bootstrap families from existing students.parent_id
* POST /api/v1/families/bootstrap
*/
public function bootstrap()
{
// Guard: ensure required tables exist
foreach (['families', 'family_students', 'family_guardians'] as $t) {
if (!DB::getSchemaBuilder()->hasTable($t)) {
return $this->respondError(
"Missing required table '{$t}'. Run migrations.",
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
}
try {
DB::beginTransaction();
// Get all distinct primary parents from students.parent_id
$primaryParents = DB::table('students')
->select('parent_id')
->whereNotNull('parent_id')
->distinct()
->get()
->map(fn($row) => (array) $row)
->toArray();
$created = 0;
$linkedStudents = 0;
$linkedGuardians = 0;
foreach ($primaryParents as $row) {
$primaryUserId = (int) $row['parent_id'];
if ($primaryUserId <= 0) continue;
$familyId = $this->ensureFamilyForPrimaryParent($primaryUserId, $created);
// Link all students of this primary parent
$students = DB::table('students')
->select('id')
->where('parent_id', $primaryUserId)
->get()
->map(fn($row) => (array) $row)
->toArray();
foreach ($students as $s) {
$linkedStudents += $this->attachStudentToFamily((int) $s['id'], $familyId);
}
// Ensure primary parent is guardian on that family
$linkedGuardians += $this->attachGuardianUser($familyId, $primaryUserId, 'primary', true, 1, 0);
}
DB::commit();
return $this->success([
'families_created' => $created,
'students_linked' => $linkedStudents,
'guardians_linked' => $linkedGuardians,
], 'Bootstrap completed successfully');
} catch (\Throwable $e) {
DB::rollBack();
log_message('error', 'Family bootstrap error: ' . $e->getMessage());
return $this->respondError('Failed to bootstrap families: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Attach second parent by user ID
* POST /api/v1/families/attach-second-by-user
*/
public function attachSecondByUser()
{
$data = $this->payloadData();
$studentId = (int) ($data['student_id'] ?? 0);
$userId = (int) ($data['user_id'] ?? 0);
$relation = (string) ($data['relation'] ?? 'secondary');
if (!$studentId || !$userId) {
return $this->respondError('student_id and user_id required', Response::HTTP_BAD_REQUEST);
}
$familyId = $this->familyIdFromStudentPrimary($studentId);
if (!$familyId) {
return $this->respondError('No primary family found for this student', Response::HTTP_NOT_FOUND);
}
$rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0);
return $this->success([
'attached' => $rows,
'family_id' => $familyId,
], 'Guardian attached successfully');
}
/**
* Attach second parent by email (creates user if needed)
* POST /api/v1/families/attach-second-by-email
*/
public function attachSecondByEmail()
{
$data = $this->payloadData();
$studentId = (int) ($data['student_id'] ?? 0);
$email = trim((string) ($data['email'] ?? ''));
$first = trim((string) ($data['firstname'] ?? ''));
$last = trim((string) ($data['lastname'] ?? ''));
$relation = (string) ($data['relation'] ?? 'secondary');
if (!$studentId || !$email) {
return $this->respondError('student_id and email required', Response::HTTP_BAD_REQUEST);
}
$familyId = $this->familyIdFromStudentPrimary($studentId);
if (!$familyId) {
return $this->respondError('No primary family found for this student', Response::HTTP_NOT_FOUND);
}
$user = $this->user->where('email', $email)->first();
if (!$user) {
// Create a minimal/stub user
$newUser = $this->user->create([
'firstname' => $first ?: '',
'lastname' => $last ?: '',
'email' => $email,
'status' => 'Active',
'user_type' => 'secondary',
]);
if (!$newUser || !isset($newUser['id'])) {
return $this->respondError('Failed to create user', Response::HTTP_INTERNAL_SERVER_ERROR);
}
$userId = (int) $newUser['id'];
} else {
$userId = (int) $user['id'];
}
$rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0);
return $this->success([
'attached' => $rows,
'family_id' => $familyId,
'user_id' => $userId,
], 'Guardian attached successfully');
}
/**
* Set primary home flag for a student-family relationship
* POST /api/v1/families/set-primary-home
*/
public function setPrimaryHome()
{
$data = $this->payloadData();
$familyId = (int) ($data['family_id'] ?? 0);
$studentId = (int) ($data['student_id'] ?? 0);
$flag = (int) ($data['is_primary_home'] ?? 0);
if (!$familyId || !$studentId) {
return $this->respondError('family_id and student_id required', Response::HTTP_BAD_REQUEST);
}
try {
$this->familyStudent
->where(['family_id' => $familyId, 'student_id' => $studentId])
->update(['is_primary_home' => $flag ? 1 : 0]);
return $this->success(null, 'Primary home flag updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Set primary home error: ' . $e->getMessage());
return $this->respondError('Failed to update primary home flag', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Set guardian flags (receive_emails, is_primary, receive_sms, relation)
* POST /api/v1/families/set-guardian-flags
*/
public function setGuardianFlags()
{
$data = $this->payloadData();
$familyId = (int) ($data['family_id'] ?? 0);
$userId = (int) ($data['user_id'] ?? 0);
if (!$familyId || !$userId) {
return $this->respondError('family_id and user_id required', Response::HTTP_BAD_REQUEST);
}
$updateData = [];
foreach (['receive_emails', 'is_primary', 'receive_sms', 'relation'] as $k) {
if (isset($data[$k])) {
$val = $data[$k];
$updateData[$k] = in_array($k, ['receive_emails', 'is_primary', 'receive_sms'])
? (int) $val
: (string) $val;
}
}
if (empty($updateData)) {
return $this->respondError('No flags provided', Response::HTTP_BAD_REQUEST);
}
try {
$this->familyGuardian
->where(['family_id' => $familyId, 'user_id' => $userId])
->update($updateData);
return $this->success(null, 'Guardian flags updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Set guardian flags error: ' . $e->getMessage());
return $this->respondError('Failed to update guardian flags', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Unlink a guardian from a family
* POST /api/v1/families/unlink-guardian
*/
public function unlinkGuardian()
{
$data = $this->payloadData();
$familyId = (int) ($data['family_id'] ?? 0);
$userId = (int) ($data['user_id'] ?? 0);
if (!$familyId || !$userId) {
return $this->respondError('family_id and user_id required', Response::HTTP_BAD_REQUEST);
}
try {
$this->familyGuardian
->where(['family_id' => $familyId, 'user_id' => $userId])
->delete();
return $this->success(null, 'Guardian unlinked successfully');
} catch (\Throwable $e) {
log_message('error', 'Unlink guardian error: ' . $e->getMessage());
return $this->respondError('Failed to unlink guardian', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Unlink a student from a family
* POST /api/v1/families/unlink-student
*/
public function unlinkStudent()
{
$data = $this->payloadData();
$familyId = (int) ($data['family_id'] ?? 0);
$studentId = (int) ($data['student_id'] ?? 0);
if (!$familyId || !$studentId) {
return $this->respondError('family_id and student_id required', Response::HTTP_BAD_REQUEST);
}
try {
$this->familyStudent
->where(['family_id' => $familyId, 'student_id' => $studentId])
->delete();
return $this->success(null, 'Student unlinked successfully');
} catch (\Throwable $e) {
log_message('error', 'Unlink student error: ' . $e->getMessage());
return $this->respondError('Failed to unlink student', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Import second parents from legacy 'parents' table
* POST /api/v1/families/import-second-parents
*/
public function importSecondParentsFromLegacy()
{
$L = [
'table' => 'parents',
'firstparent_id' => 'firstparent_id',
'second_user_id' => 'secondparent_id',
'second_email' => 'secondparent_email',
'second_firstname' => 'secondparent_firstname',
'second_lastname' => 'secondparent_lastname',
];
if (!DB::getSchemaBuilder()->hasTable($L['table'])) {
return $this->respondError("Legacy table '{$L['table']}' not found", Response::HTTP_NOT_FOUND);
}
foreach (['families', 'family_students', 'family_guardians'] as $t) {
if (!DB::getSchemaBuilder()->hasTable($t)) {
return $this->respondError(
"Missing required table '{$t}'. Run migrations.",
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
}
try {
$rows = DB::table($L['table'])
->select(
DB::raw("{$L['firstparent_id']} AS first_id"),
DB::raw("{$L['second_user_id']} AS second_id"),
DB::raw("{$L['second_email']} AS email"),
DB::raw("{$L['second_firstname']} AS firstname"),
DB::raw("{$L['second_lastname']} AS lastname")
)
->where(function ($query) use ($L) {
$query->where($L['second_user_id'], '!=', 0)
->orWhere($L['second_email'], '!=', '');
})
->get()
->map(fn($row) => (array) $row)
->toArray();
$createdUsers = 0;
$linked = 0;
$skipped = 0;
foreach ($rows as $r) {
$firstId = (int) ($r['first_id'] ?? 0);
$secondId = (int) ($r['second_id'] ?? 0);
$email = trim((string) ($r['email'] ?? ''));
if (!$firstId || (!$secondId && $email === '')) {
$skipped++;
continue;
}
// Ensure/find family by primary parent
$code = 'FAM-' . $firstId;
$fam = $this->family->where('family_code', $code)->first();
if ($fam) {
$familyId = (int) $fam['id'];
} else {
$tmp = 0; // counter sink
$familyId = $this->ensureFamilyForPrimaryParent($firstId, $tmp);
}
// Resolve/create user
$userId = 0;
if ($secondId > 0) {
$userId = $secondId;
} else {
$user = $this->user->where('email', $email)->first();
if (!$user) {
$newUser = $this->user->create([
'firstname' => $r['firstname'] ?? '',
'lastname' => $r['lastname'] ?? '',
'email' => $email,
'status' => 'Active',
'user_type' => 'secondary',
]);
if ($newUser && isset($newUser['id'])) {
$userId = (int) $newUser['id'];
$createdUsers++;
} else {
$userId = 0;
}
} else {
$userId = (int) $user['id'];
}
}
if (!$userId) {
$skipped++;
continue;
}
// Ensure all students for this primary parent are linked to this family
$stuRows = DB::table('students')
->select('id')
->where('parent_id', $firstId)
->get()
->map(fn($row) => (array) $row)
->toArray();
foreach ($stuRows as $sr) {
$sid = (int) ($sr['id'] ?? 0);
if ($sid > 0) {
$this->attachStudentToFamily($sid, $familyId);
}
}
// Link as guardian (idempotent)
$linked += $this->attachGuardianUser($familyId, $userId, 'guardian', false, 1, 0);
}
return $this->success([
'created_users' => $createdUsers,
'guardians_linked' => $linked,
'skipped' => $skipped,
'total_source' => count($rows),
], 'Legacy import completed successfully');
} catch (\Throwable $e) {
log_message('error', 'Import second parents error: ' . $e->getMessage());
return $this->respondError('Failed to import second parents: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/* =========================================================
* Private helpers
* =======================================================*/
/**
* Ensure there is exactly one family per primary parent (users.id)
* Returns $familyId and increments $createdCounter by reference if newly created.
*/
protected function ensureFamilyForPrimaryParent(int $primaryUserId, int &$createdCounter): int
{
$code = 'FAM-' . $primaryUserId;
$row = $this->family->where('family_code', $code)->first();
if ($row) {
return (int) $row['id'];
}
$family = $this->family->create([
'family_code' => $code,
'household_name' => 'Family of User ' . $primaryUserId,
'is_active' => 1,
]);
if ($family && isset($family['id'])) {
$createdCounter++;
return (int) $family['id'];
}
// If create failed, try to find it again (race condition)
$row = $this->family->where('family_code', $code)->first();
if ($row) {
return (int) $row['id'];
}
throw new \Exception('Failed to create family for primary parent ' . $primaryUserId);
}
/**
* Attach a student to a family (idempotent; returns rows affected >0 if inserted)
*/
protected function attachStudentToFamily(int $studentId, int $familyId): int
{
// Check if already exists
$exists = $this->familyStudent
->where('family_id', $familyId)
->where('student_id', $studentId)
->first();
if ($exists) {
return 0;
}
// Insert
$result = $this->familyStudent->create([
'family_id' => $familyId,
'student_id' => $studentId,
'is_primary_home' => 1,
]);
return ($result && isset($result['id'])) ? 1 : 0;
}
/**
* Attach a guardian user to family (idempotent)
*/
protected function attachGuardianUser(
int $familyId,
int $userId,
string $relation = 'primary',
bool $isPrimary = false,
int $receiveEmails = 1,
int $receiveSms = 0
): int {
// Check if already exists
$exists = $this->familyGuardian
->where('family_id', $familyId)
->where('user_id', $userId)
->first();
if ($exists) {
return 0;
}
// Insert
$result = $this->familyGuardian->create([
'family_id' => $familyId,
'user_id' => $userId,
'relation' => $relation,
'is_primary' => $isPrimary ? 1 : 0,
'receive_emails' => $receiveEmails,
'receive_sms' => $receiveSms,
]);
return ($result && isset($result['id'])) ? 1 : 0;
}
/**
* Find the "primary" family for a student = family created from parent_id
*/
protected function familyIdFromStudentPrimary(int $studentId): ?int
{
// Get primary parent id for the student
$st = $this->student->select('parent_id')->find($studentId);
if (!$st || empty($st['parent_id'])) {
return null;
}
// The canonical family uses code FAM-{parent_id}
$code = 'FAM-' . (int) $st['parent_id'];
$row = $this->family->select('id')->where('family_code', $code)->first();
if ($row) {
return (int) $row['id'];
}
// If not found, fall back to any family linked already
$row = DB::table('family_students')
->select('family_id')
->where('student_id', $studentId)
->orderByDesc('is_primary_home')
->first();
return $row ? (int) $row->family_id : null;
}
}
@@ -1,184 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Response as HttpResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class FilesController extends BaseApiController
{
protected array $allowedExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
public function receipt(string $name)
{
// 1) Path traversal guard
if ($name !== basename($name)) {
return $this->respondError('Invalid filename', Response::HTTP_BAD_REQUEST);
}
// 2) Allow-list extensions (optional but safer)
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext, $this->allowedExtensions, true)) {
throw new NotFoundHttpException();
}
// 3) Build path under storage/app/uploads/receipts
$path = $this->storagePath("uploads/receipts/{$name}");
if (!is_file($path)) {
throw new NotFoundHttpException();
}
return $this->streamFile($path, $name);
}
public function reimb(string $name)
{
// 1) Path traversal guard
if ($name !== basename($name)) {
return $this->respondError('Invalid filename', Response::HTTP_BAD_REQUEST);
}
// 2) Allow-list extensions
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext, $this->allowedExtensions, true)) {
throw new NotFoundHttpException();
}
// 3) Build path under storage/app/uploads/reimbursements
$path = $this->storagePath("uploads/reimbursements/{$name}");
if (!is_file($path)) {
throw new NotFoundHttpException();
}
return $this->streamFile($path, $name);
}
/**
* Alias for reimb() for backward compatibility
*/
public function reimbursement(string $name)
{
return $this->reimb($name);
}
public function index()
{
$type = $this->request->getGet('type') ?? 'receipts';
$allowedTypes = ['receipts', 'reimbursements', 'checks'];
if (!in_array($type, $allowedTypes, true)) {
return $this->respondError('Invalid file type', Response::HTTP_BAD_REQUEST);
}
try {
$path = $this->storagePath("uploads/{$type}/");
$files = [];
if (is_dir($path)) {
foreach (scandir($path) as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$filePath = $path . $file;
if (!is_file($filePath)) {
continue;
}
$files[] = [
'name' => $file,
'size' => filesize($filePath),
'modified' => date('Y-m-d H:i:s', filemtime($filePath)),
];
}
}
return $this->success([
'type' => $type,
'files' => $files,
'count' => count($files),
], 'Files retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Files list error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve files', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
protected function streamFile(string $path, string $name): HttpResponse
{
// 4) MIME detection (finfo is more reliable than mime_content_type)
$mime = 'application/octet-stream';
if (function_exists('finfo_open')) {
$fi = finfo_open(FILEINFO_MIME_TYPE);
if ($fi) {
$detected = finfo_file($fi, $path);
if ($detected) {
$mime = $detected;
}
finfo_close($fi);
}
} elseif (function_exists('mime_content_type')) {
$mime = mime_content_type($path) ?: $mime;
}
// 5) Caching (ETag + Last-Modified) and 304 support
$mtime = filemtime($path) ?: time();
$size = filesize($path) ?: 0;
$etag = md5($name . '|' . $mtime . '|' . $size);
$ifNoneMatch = trim($this->laravelRequest->header('If-None-Match', ''), '"');
$ifModifiedSince = $this->laravelRequest->header('If-Modified-Since');
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
($imsTime !== false && $imsTime >= $mtime)
) {
return response('', Response::HTTP_NOT_MODIFIED)
->header('ETag', $etag)
->header('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
}
// 6) Stream the file inline
return response()->make(file_get_contents($path), Response::HTTP_OK, [
'Content-Type' => $mime,
'X-Content-Type-Options' => 'nosniff',
'Content-Disposition' => 'inline; filename="' . $name . '"',
'Content-Length' => (string) $size,
'ETag' => $etag,
'Last-Modified' => gmdate('D, d M Y H:i:s', $mtime) . ' GMT',
'Cache-Control' => 'public, max-age=86400',
]);
}
protected function storagePath(string $relative): string
{
return storage_path('app/' . $relative);
}
protected function isAllowedExtension(string $name): bool
{
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
return in_array($ext, $this->allowedExtensions, true);
}
protected function detectMime(string $path): string
{
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = finfo_file($finfo, $path);
finfo_close($finfo);
if ($mime) {
return $mime;
}
}
} elseif (function_exists('mime_content_type')) {
$mime = mime_content_type($path);
if ($mime) {
return $mime;
}
}
return 'application/octet-stream';
}
}
@@ -1,230 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\FinalExam;
use App\Models\Student;
use Symfony\Component\HttpFoundation\Response;
class FinalController extends BaseApiController
{
protected FinalExam $final;
protected Configuration $config;
protected Student $student;
public function __construct()
{
parent::__construct();
$this->final = model(FinalExam::class);
$this->config = model(Configuration::class);
$this->student = model(Student::class);
}
public function getByStudent($id = null)
{
$schoolYear = $this->config->getConfig('school_year');
$semester = $this->config->getConfig('semester');
$final = $this->final->newQuery()
->when($id, fn($q) => $q->where('student_id', $id))
->when($schoolYear, fn($q) => $q->where('school_year', $schoolYear))
->when($semester, fn($q) => $q->where('semester', $semester))
->first();
return $this->success($final ?: null, 'Final exam retrieved successfully');
}
public function create()
{
$user = $this->getCurrentUser();
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'student_id' => 'required|integer',
'class_section_id' => 'required|integer',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$schoolYear = $this->config->getConfig('school_year');
$semester = $this->config->getConfig('semester');
$query = $this->final->newQuery()
->where('student_id', $data['student_id'])
->where('class_section_id', $data['class_section_id']);
if ($schoolYear) {
$query->where('school_year', $schoolYear);
}
if ($semester) {
$query->where('semester', $semester);
}
$existing = $query->first();
$finalData = [
'student_id' => $data['student_id'],
'class_section_id' => $data['class_section_id'],
'score' => $data['score'] ?? null,
'comment' => $data['comment'] ?? null,
'school_year' => $schoolYear,
'semester' => $semester,
'updated_by' => $user->id ?? null,
];
try {
if ($existing) {
$existing->fill($finalData);
$existing->save();
$final = $existing->fresh();
} else {
$final = $this->final->create($finalData);
}
return $this->success($final, 'Final exam saved successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'Final exam save error: ' . $e->getMessage());
return $this->respondError('Failed to save final exam', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update($id = null)
{
$final = $this->final->find($id);
if (!$final) {
return $this->respondError('Final exam not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$allowed = ['score', 'comment'];
$updateData = [];
foreach ($allowed as $field) {
if (array_key_exists($field, $data)) {
$updateData[$field] = $data[$field];
}
}
if (empty($updateData)) {
return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST);
}
$updateData['updated_by'] = $this->getCurrentUser()->id ?? null;
try {
$final->fill($updateData);
$final->save();
return $this->success($final->fresh(), 'Final exam updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Final exam update error: ' . $e->getMessage());
return $this->respondError('Failed to update final exam', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Get students with their final exam scores for a class section
*
* @param int|null $classSectionId
* @return \Illuminate\Http\JsonResponse
*/
public function getByClassSection($classSectionId = null)
{
// Get class_section_id from route parameter, query param, or request body
$incoming = $classSectionId
?? $this->request->getGet('class_section_id')
?? $this->request->getPost('class_section_id')
?? null;
$classSectionId = (int) ($incoming ?: 0);
if ($classSectionId <= 0) {
return $this->respondError('Missing or invalid class section ID', Response::HTTP_BAD_REQUEST);
}
try {
$result = $this->getSavedScores($classSectionId);
return $this->success($result, 'Students with final exam scores retrieved successfully');
} catch (\InvalidArgumentException $e) {
return $this->respondError($e->getMessage(), Response::HTTP_BAD_REQUEST);
} catch (\Throwable $e) {
log_message('error', 'Final exam getByClassSection error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve students with final exam scores', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Get students with their final exam scores for a class section
*
* @param int $classSectionId
* @return array
* @throws \InvalidArgumentException
*/
protected function getSavedScores(int $classSectionId): array
{
if ($classSectionId <= 0) {
throw new \InvalidArgumentException('Invalid class section id.');
}
$schoolYear = $this->config->getConfig('school_year');
$semester = $this->config->getConfig('semester');
$db = \Config\Database::connect();
// Subquery: pick ONE final exam row per student (latest by id)
$latestFinalSub = $db->table('final_exam')
->select('student_id, MAX(id) AS max_id')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->groupBy('student_id')
->getCompiledSelect();
// Main query: roster + LEFT JOIN the single final exam row
$builder = $this->student->builder();
$builder
->select('
s.id AS student_id,
s.school_id,
s.firstname,
s.lastname,
fe.score
')
->from('students s')
->join(
'student_class sc',
'sc.student_id = s.id
AND sc.class_section_id = ' . (int) $classSectionId . '
AND sc.school_year = ' . $db->escape($schoolYear),
'inner',
false
)
->join('(' . $latestFinalSub . ') fl', 'fl.student_id = s.id', 'left', false)
->join('final_exam fe', 'fe.id = fl.max_id', 'left')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC');
$builder->distinct();
$rows = $builder->get()->getResultArray();
// Ensure one row per student
$unique = [];
foreach ($rows as $r) {
$unique[(int)$r['student_id']] = $r;
}
$students = array_values($unique);
return [
'students' => $students,
'semester' => $semester,
'schoolYear' => $schoolYear,
'class_section_id' => $classSectionId,
];
}
}
@@ -1,724 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\DiscountUsage;
use App\Models\Expense;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Reimbursement;
use App\Models\Refund;
use App\Models\Configuration;
use App\Models\AdditionalCharge;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class FinancialController extends BaseApiController
{
protected Invoice $invoice;
protected Payment $payment;
protected Refund $refund;
protected Expense $expense;
protected Reimbursement $reimbursement;
protected DiscountUsage $discount;
protected Configuration $config;
protected AdditionalCharge $additionalCharge;
// Special recipients for donations (Masjid/Donation reimbursements)
protected const SPECIAL_RECIPIENTS = [
// Add recipient IDs here if needed
// Example: 1 => 'Masjid',
// 2 => 'Donation',
];
public function __construct()
{
parent::__construct();
$this->invoice = model(Invoice::class);
$this->payment = model(Payment::class);
$this->refund = model(Refund::class);
$this->expense = model(Expense::class);
$this->reimbursement = model(Reimbursement::class);
$this->discount = model(DiscountUsage::class);
$this->config = model(Configuration::class);
$this->additionalCharge = model(AdditionalCharge::class);
}
/**
* Detailed financial report with invoices, payments, payment breakdown, refunds, expenses, reimbursements, discounts
*/
public function report()
{
$dateFrom = $this->request->getGet('date_from');
$dateTo = $this->request->getGet('date_to');
$schoolYear = $this->request->getGet('school_year');
try {
$db = \Config\Database::connect();
// Build filtered models
$invoice = $this->invoice;
$refund = $this->refund;
$discount = $this->discount;
$expense = $this->expense;
$reimbursement = $this->reimbursement;
// Apply filters
if ($schoolYear) {
$invoice->where('invoices.school_year', $schoolYear);
$refund->where('school_year', $schoolYear);
$discount->where('school_year', $schoolYear);
$expense->where('school_year', $schoolYear);
$reimbursement->where('school_year', $schoolYear);
}
$hasFrom = !empty($dateFrom);
$hasTo = !empty($dateTo);
if ($hasFrom) {
$invoice->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom);
$refund->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
$discount->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
$expense->where('DATE(created_at) >=', $dateFrom);
$reimbursement->where('DATE(created_at) >=', $dateFrom);
}
if ($hasTo) {
$invoice->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo);
$refund->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
$discount->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
$expense->where('DATE(created_at) <=', $dateTo);
$reimbursement->where('DATE(created_at) <=', $dateTo);
}
// Get invoices
$invoices = $invoice
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
->join('users', 'users.id = invoices.parent_id')
->findAll();
// Helper to build filtered Payment
$buildPayment = function () use ($schoolYear, $dateFrom, $dateTo) {
$pm = $this->payment;
if ($schoolYear) {
$pm->where('school_year', $schoolYear);
}
if (!empty($dateFrom)) {
$pm->where('DATE(payment_date) >=', $dateFrom);
}
if (!empty($dateTo)) {
$pm->where('DATE(payment_date) <=', $dateTo);
}
return $pm;
};
// Payments aggregated by invoice_id
$payments = $buildPayment()
->select('invoice_id, SUM(paid_amount) AS paid_amount')
->where('invoice_id IS NOT NULL')
->groupBy('invoice_id')
->findAll();
// Payment breakdown by method (cash/credit/check)
$paymentBreakdownRows = $buildPayment()
->select("
invoice_id,
CASE
WHEN LOWER(TRIM(payment_method)) = 'cash' THEN 'cash'
WHEN LOWER(TRIM(payment_method)) IN ('check','cheque') THEN 'check'
WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN 'credit'
ELSE 'other'
END AS method,
SUM(paid_amount) AS amount
")
->where('invoice_id IS NOT NULL')
->groupBy('invoice_id, method')
->findAll();
$paymentBreakdown = [];
foreach ($paymentBreakdownRows as $row) {
$iid = (int)($row['invoice_id'] ?? 0);
if ($iid <= 0) continue;
$method = (string)$row['method'];
$amount = (float)$row['amount'];
if (!isset($paymentBreakdown[$iid])) $paymentBreakdown[$iid] = [];
$paymentBreakdown[$iid][$method] = $amount;
}
// Overall payment totals by method
$paymentTotalsRow = $buildPayment()
->select("
SUM(paid_amount) AS total_all,
SUM(CASE WHEN LOWER(TRIM(payment_method)) = 'cash' THEN paid_amount ELSE 0 END) AS total_cash,
SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('check','cheque') THEN paid_amount ELSE 0 END) AS total_check,
SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN paid_amount ELSE 0 END) AS total_credit
")
->where('invoice_id IS NOT NULL')
->first() ?? [];
$paymentTotals = [
'total_all' => (float)($paymentTotalsRow['total_all'] ?? 0),
'total_cash' => (float)($paymentTotalsRow['total_cash'] ?? 0),
'total_check' => (float)($paymentTotalsRow['total_check'] ?? 0),
'total_credit' => (float)($paymentTotalsRow['total_credit'] ?? 0),
];
// Refunds (grouped)
$refunds = $refund
->select('invoice_id, school_year, SUM(refund_paid_amount) AS total_refunded')
->where('invoice_id IS NOT NULL')
->groupBy(['invoice_id', 'school_year'])
->findAll();
// Discounts (grouped)
$discounts = $discount
->select('invoice_id, school_year, SUM(discount_amount) AS discount_amount')
->where('invoice_id IS NOT NULL')
->groupBy(['invoice_id', 'school_year'])
->get()
->getResultArray();
// Expenses
$expenses = $expense
->select('category, SUM(amount) AS total_amount')
->groupBy('category')
->findAll();
// Reimbursements
$reimbursements = $reimbursement
->select('status, SUM(amount) AS total_amount')
->groupBy('status')
->findAll();
// School year options
$schoolYears = [];
try {
$rows = $db->table('invoices')
->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')
->get()->getResultArray();
foreach ($rows as $r) {
$val = (string)($r['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
}
} catch (\Throwable $e) {}
if (empty($schoolYears) && !empty($schoolYear)) {
$schoolYears[] = (string)$schoolYear;
}
return $this->success([
'selectedYear' => $schoolYear,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'schoolYears' => $schoolYears,
'invoices' => $invoices,
'payments' => $payments,
'paymentBreakdown' => $paymentBreakdown,
'paymentTotals' => $paymentTotals,
'refunds' => $refunds,
'expenses' => $expenses,
'reimbursements' => $reimbursements,
'discounts' => $discounts,
], 'Financial report retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Financial report error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve financial report', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Financial report summary with aggregated totals
*/
public function reportSummary()
{
$dateFrom = $this->request->getGet('date_from');
$dateTo = $this->request->getGet('date_to');
$schoolYear = $this->request->getGet('school_year');
try {
$data = $this->getFinancialSummary($dateFrom, $dateTo, $schoolYear);
// Build school year options
$schoolYears = [];
try {
$db = \Config\Database::connect();
$rows = $db->table('invoices')
->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')
->get()->getResultArray();
foreach ($rows as $r) {
$val = (string)($r['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
}
} catch (\Throwable $e) {}
if (empty($schoolYears) && !empty($data['schoolYear'])) {
$schoolYears[] = (string)$data['schoolYear'];
}
$data['schoolYears'] = $schoolYears;
return $this->success($data, 'Financial report summary retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Financial report summary error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve financial report summary', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Download financial report as CSV
*/
public function downloadCsv()
{
$dateFrom = $this->request->getGet('date_from');
$dateTo = $this->request->getGet('date_to');
$schoolYear = $this->request->getGet('school_year');
try {
$invoice = $this->invoice;
$expense = $this->expense;
$reimbursement = $this->reimbursement;
// Invoices with filters
$invBuilder = $invoice
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
->join('users', 'users.id = invoices.parent_id');
if (!empty($schoolYear)) {
$invBuilder->where('invoices.school_year', $schoolYear);
}
if (!empty($dateFrom)) {
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom);
}
if (!empty($dateTo)) {
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo);
}
$invoices = $invBuilder->findAll();
// Expenses summary
$expBuilder = $expense->select('category, SUM(amount) AS total_amount');
if (!empty($schoolYear)) {
$expBuilder->where('school_year', $schoolYear);
}
if (!empty($dateFrom)) {
$expBuilder->where('DATE(created_at) >=', $dateFrom);
}
if (!empty($dateTo)) {
$expBuilder->where('DATE(created_at) <=', $dateTo);
}
$expenses = $expBuilder->groupBy('category')->findAll();
// Reimbursements summary
$reimbBuilder = $reimbursement->select('status, SUM(amount) AS total_amount');
if (!empty($schoolYear)) {
$reimbBuilder->where('school_year', $schoolYear);
}
if (!empty($dateFrom)) {
$reimbBuilder->where('DATE(created_at) >=', $dateFrom);
}
if (!empty($dateTo)) {
$reimbBuilder->where('DATE(created_at) <=', $dateTo);
}
$reimbursements = $reimbBuilder->groupBy('status')->findAll();
$filename = 'financial_report_' . date('Ymd_His') . '.csv';
$headers = [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
];
return new StreamedResponse(function () use ($invoices, $expenses, $reimbursements) {
$out = fopen('php://output', 'w');
// Invoices section
fputcsv($out, ['Invoice #', 'Parent', 'School Year', 'Total', 'Paid', 'Balance', 'Refund']);
foreach ($invoices as $inv) {
fputcsv($out, [
$inv['invoice_number'] ?? '',
$inv['parent_name'] ?? '',
$inv['school_year'] ?? '',
$inv['total_amount'] ?? 0,
$inv['paid_amount'] ?? 0,
$inv['balance'] ?? 0,
$inv['refund_amount'] ?? 0,
]);
}
// Expenses section
fputcsv($out, []);
fputcsv($out, ['Expenses Summary']);
fputcsv($out, ['Category', 'Total Amount']);
foreach ($expenses as $exp) {
fputcsv($out, [
$exp['category'] ?? '',
$exp['total_amount'] ?? 0,
]);
}
// Reimbursements section
fputcsv($out, []);
fputcsv($out, ['Reimbursements Summary']);
fputcsv($out, ['Status', 'Total Amount']);
foreach ($reimbursements as $r) {
fputcsv($out, [
$r['status'] ?? '',
$r['total_amount'] ?? 0,
]);
}
fclose($out);
}, Response::HTTP_OK, $headers);
} catch (\Throwable $e) {
log_message('error', 'CSV download error: ' . $e->getMessage());
return $this->respondError('Failed to generate CSV', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Get financial summary data
*/
protected function getFinancialSummary(?string $dateFrom = null, ?string $dateTo = null, ?string $schoolYear = null): array
{
$schoolYear = $schoolYear ?: $this->config->getConfig('school_year');
$invoice = $this->invoice;
$payment = $this->payment;
$refund = $this->refund;
$expense = $this->expense;
$reimbursement = $this->reimbursement;
$discount = $this->discount;
// Invoices: Total Charges
$invoiceBuilder = $invoice->where('school_year', $schoolYear);
if (!empty($dateFrom)) {
$invoiceBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom);
}
if (!empty($dateTo)) {
$invoiceBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo);
}
$invoices = $invoiceBuilder->findAll();
$totalCharges = array_sum(array_column($invoices, 'total_amount'));
// Additional Charges
$acBuilder = $this->additionalCharge->where('school_year', $schoolYear)
->where('status !=', 'void');
if ($dateFrom && $dateTo) {
$acBuilder->where('due_date >=', $dateFrom)
->where('due_date <=', $dateTo);
}
$acResult = $acBuilder->selectSum('amount')->first();
$totalExtraCharges = isset($acResult['amount']) ? (float) $acResult['amount'] : 0.00;
$totalCharges = $totalCharges + $totalExtraCharges;
// Payments: Total Paid
$paymentBuilder = $payment->where('school_year', $schoolYear);
if (!empty($dateFrom)) {
$paymentBuilder->where('DATE(payment_date) >=', $dateFrom);
}
if (!empty($dateTo)) {
$paymentBuilder->where('DATE(payment_date) <=', $dateTo);
}
$paymentResult = $paymentBuilder->selectSum('paid_amount')->first();
$totalPaid = isset($paymentResult['paid_amount']) ? (float) $paymentResult['paid_amount'] : 0.00;
// Expenses
$expenseBuilder = $expense->where('school_year', $schoolYear);
if (!empty($dateFrom)) {
$expenseBuilder->where('DATE(created_at) >=', $dateFrom);
}
if (!empty($dateTo)) {
$expenseBuilder->where('DATE(created_at) <=', $dateTo);
}
$expenseResult = $expenseBuilder->selectSum('amount')->first();
$totalExpenses = isset($expenseResult['amount']) ? (float) $expenseResult['amount'] : 0.00;
// Reimbursements
$reimbursementBuilder = $reimbursement->where('school_year', $schoolYear);
if (!empty($dateFrom)) {
$reimbursementBuilder->where('DATE(created_at) >=', $dateFrom);
}
if (!empty($dateTo)) {
$reimbursementBuilder->where('DATE(created_at) <=', $dateTo);
}
$reimbursementResult = $reimbursementBuilder->selectSum('amount')->first();
$totalReimbursements = isset($reimbursementResult['amount']) ? (float) $reimbursementResult['amount'] : 0.00;
// Donations line: reimbursements recorded against Masjid/Donation recipients
$donationToSchool = 0.0;
$specialRecipientIds = array_map('intval', array_keys(self::SPECIAL_RECIPIENTS));
if (!empty($specialRecipientIds)) {
$donationBuilder = $reimbursement
->where('school_year', $schoolYear)
->whereIn('reimbursed_to', $specialRecipientIds);
if (!empty($dateFrom)) {
$donationBuilder->where('DATE(created_at) >=', $dateFrom);
}
if (!empty($dateTo)) {
$donationBuilder->where('DATE(created_at) <=', $dateTo);
}
$donationRow = $donationBuilder->selectSum('amount')->first();
$donationToSchool = isset($donationRow['amount']) ? (float) $donationRow['amount'] : 0.00;
}
$totalReimbursements = max(0.0, $totalReimbursements - $donationToSchool);
// Refunds
$refundBuilder = $refund
->where('school_year', $schoolYear)
->where('status', 'Paid')
->where('refund_paid_amount IS NOT NULL');
if (!empty($dateFrom)) {
$refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
}
if (!empty($dateTo)) {
$refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
}
$refundResult = $refundBuilder->selectSum('refund_paid_amount')->first();
$totalRefunds = isset($refundResult['refund_paid_amount']) ? (float) $refundResult['refund_paid_amount'] : 0.00;
// Discounts
$discountBuilder = $discount
->join('invoices', 'invoices.id = discount_usages.invoice_id')
->where('invoices.school_year', $schoolYear);
if (!empty($dateFrom)) {
$discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) >=', $dateFrom);
}
if (!empty($dateTo)) {
$discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) <=', $dateTo);
}
$discountResult = $discountBuilder->selectSum('discount_amount')->first();
$totalDiscounts = isset($discountResult['discount_amount']) ? (float) $discountResult['discount_amount'] : 0.00;
// Net & Unpaid
$totalUnpaid = ($totalCharges - $totalDiscounts) - $totalPaid;
$amountCollected = $totalPaid;
$netAmount = ($totalCharges - $totalDiscounts - $totalRefunds);
return [
'schoolYear' => $schoolYear,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'totalCharges' => $totalCharges,
'totalExtraCharges' => $totalExtraCharges,
'totalDiscounts' => $totalDiscounts,
'totalRefunds' => $totalRefunds,
'totalExpenses' => $totalExpenses,
'totalReimbursements' => $totalReimbursements,
'donationToSchool' => $donationToSchool,
'totalPaid' => $totalPaid,
'amountCollected' => $amountCollected,
'totalUnpaid' => $totalUnpaid,
'netAmount' => $netAmount,
];
}
/**
* List parents with outstanding balances (> 0) for a school year
*/
public function unpaidParents()
{
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
if ($schoolYear === '') {
$schoolYear = (string) ($this->config->getConfig('school_year') ?? date('Y'));
}
try {
$db = \Config\Database::connect();
// Build school year options
$schoolYears = [];
try {
$rows = $db->table('invoices')
->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')
->get()->getResultArray();
foreach ($rows as $r) {
$val = (string)($r['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
}
} catch (\Throwable $e) {}
if (empty($schoolYears) && !empty($schoolYear)) {
$schoolYears[] = (string)$schoolYear;
}
// Aggregate balances by parent
$invRows = $db->table('invoices i')
->select('i.id, i.parent_id, i.total_amount, u.firstname, u.lastname, u.email')
->join('users u', 'u.id = i.parent_id', 'inner')
->where('i.school_year', $schoolYear)
->orderBy('i.parent_id', 'ASC')
->orderBy('i.id', 'ASC')
->get()->getResultArray();
$byParent = [];
$paymentsTbl = 'payments';
$hasStatus = $db->fieldExists('status', $paymentsTbl);
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
foreach ($invRows as $r) {
$iid = (int)($r['id'] ?? 0);
$pid = (int)($r['parent_id'] ?? 0);
if ($iid <= 0 || $pid <= 0) continue;
// Sum payments for this invoice
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
if ($hasStatus) {
$qb->groupStart()
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($hasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$paidRow = $qb->get()->getRowArray();
$paidSum = (float)($paidRow['tot'] ?? 0);
// Sum discounts
$discRow = $db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS tot')
->where('invoice_id', $iid)
->get()->getRowArray();
$discSum = (float)($discRow['tot'] ?? 0);
// Sum refunds PAID
$refRow = $db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
->where('invoice_id', $iid)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$refSum = (float)($refRow['tot'] ?? 0);
$total = (float)($r['total_amount'] ?? 0);
$balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2));
if (!isset($byParent[$pid])) {
$byParent[$pid] = [
'parent_id' => $pid,
'firstname' => (string)($r['firstname'] ?? ''),
'lastname' => (string)($r['lastname'] ?? ''),
'email' => (string)($r['email'] ?? ''),
'total_invoice' => 0.0,
'total_balance' => 0.0,
'total_paid' => 0.0,
];
}
$byParent[$pid]['total_invoice'] += $total;
$byParent[$pid]['total_balance'] += $balance;
$byParent[$pid]['total_paid'] += $paidSum;
}
// Build rows list (only parents with positive balance)
$rows = [];
$installmentEndRaw = (string) ($this->config->getConfig('installment_date') ?? '');
$tzName = (string) (config('School')->attendance['timezone'] ?? 'America/New_York');
$tzNY = new \DateTimeZone($tzName);
$todayNY = new \DateTimeImmutable('today', $tzNY);
$endDate = null;
if ($installmentEndRaw) {
try { $endDate = new \DateTimeImmutable($installmentEndRaw, $tzNY); } catch (\Throwable $e) { $endDate = null; }
}
$monthsUntil = function (?\DateTimeImmutable $end) use ($todayNY): int {
if (!$end) return 0;
$y = (int)$end->format('Y') - (int)$todayNY->format('Y');
$m = (int)$end->format('n') - (int)$todayNY->format('n');
$months = $y * 12 + $m;
if ((int)$end->format('j') > (int)$todayNY->format('j')) $months += 1;
return max(0, $months);
};
foreach ($byParent as $pid => $agg) {
$balance = (float)($agg['total_balance'] ?? 0);
if ($balance > 0.00001) {
$remMonths = $monthsUntil($endDate);
$remainingInstallments = max(1, $remMonths);
$instAmount = round($balance / $remainingInstallments, 2);
$rows[] = [
'parent_id' => $pid,
'firstname' => $agg['firstname'],
'lastname' => $agg['lastname'],
'email' => $agg['email'],
'total_invoice' => (float)$agg['total_invoice'],
'total_balance' => $balance,
'remaining_installments' => $remainingInstallments,
'installment_amount' => $instAmount,
];
}
}
// Order by total_balance desc
usort($rows, static function ($a, $b) {
return ($b['total_balance'] <=> $a['total_balance']);
});
// Compute next installment date
$tz = new \DateTimeZone($tzName);
$now = new \DateTime('now', $tz);
$nextInstallmentDt = (clone $now)->modify('first day of next month');
$nextInstallmentYmd = $nextInstallmentDt->format('Y-m-d');
// Determine payment types
$parentIds = array_values(array_unique(array_map(static fn($r) => (int)($r['parent_id'] ?? 0), $rows)));
$hasPayments = [];
$paidTotals = [];
if (!empty($parentIds)) {
try {
$pRows = $db->table('payments')
->select('parent_id, SUM(paid_amount) AS total_paid')
->whereIn('parent_id', $parentIds)
->where('school_year', $schoolYear)
->groupBy('parent_id')
->get()->getResultArray();
foreach ($pRows as $pr) {
$pid = (int)($pr['parent_id'] ?? 0);
if ($pid > 0) {
$hasPayments[$pid] = true;
$paidTotals[$pid] = (float)($pr['total_paid'] ?? 0);
}
}
} catch (\Throwable $e) {}
}
$dataRows = array_map(function(array $r) use ($hasPayments, $paidTotals, $nextInstallmentYmd) {
$pid = (int)($r['parent_id'] ?? 0);
$name = trim((string)($r['firstname'] ?? '') . ' ' . (string)($r['lastname'] ?? ''));
return [
'parent_id' => $pid,
'parent_name' => $name,
'email' => (string)($r['email'] ?? ''),
'total_invoice' => (float)($r['total_invoice'] ?? 0),
'total_balance' => (float)($r['total_balance'] ?? 0),
'remaining_installments' => (int)($r['remaining_installments'] ?? 0),
'installment_amount' => (float)($r['installment_amount'] ?? 0),
'type' => isset($hasPayments[$pid]) ? 'installment' : 'no_payment',
'total_paid' => isset($paidTotals[$pid]) ? (float)$paidTotals[$pid] : 0.0,
'has_installment'=> isset($hasPayments[$pid]) ? 1 : 0,
'next_installment' => $nextInstallmentYmd,
];
}, $rows);
return $this->success([
'school_year' => $schoolYear,
'schoolYears' => $schoolYears,
'results' => $dataRows,
], 'Unpaid parents retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Unpaid parents error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve unpaid parents', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
-399
View File
@@ -1,399 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\CurrentFlag;
use App\Models\Flag;
use App\Models\StudentClass;
use App\Models\Student;
use Symfony\Component\HttpFoundation\Response;
class FlagController extends BaseApiController
{
protected CurrentFlag $currentFlag;
protected Flag $flag;
protected Student $student;
protected ClassSection $classSection;
protected StudentClass $studentClass;
protected Configuration $config;
public function __construct()
{
parent::__construct();
$this->currentFlag = model(CurrentFlag::class);
$this->flag = model(Flag::class);
$this->student = model(Student::class);
$this->classSection = model(ClassSection::class);
$this->studentClass = model(StudentClass::class);
$this->config = model(Configuration::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$studentId = $this->request->getGet('student_id');
$flagType = $this->request->getGet('flag_type') ?? $this->request->getGet('flag');
$state = $this->request->getGet('state') ?? $this->request->getGet('flag_state');
$query = $this->currentFlag->newQuery()->orderBy('created_at', 'DESC');
if ($studentId) {
$query->where('student_id', $studentId);
}
if ($flagType) {
$query->where('flag', $flagType);
}
if ($state) {
$query->where('flag_state', $state);
}
$result = $this->paginate($query, $page, $perPage);
// Also return class sections (grades) for UI
$classSections = $this->classSection->findAll();
$grades = [];
foreach ($classSections as $section) {
$grades[] = [
'id' => $section['class_section_id'] ?? $section['id'] ?? null,
'name' => $section['class_section_name'] ?? $section['name'] ?? '',
];
}
$result['grades'] = $grades;
return $this->success($result, 'Flags retrieved successfully');
}
public function show($id = null)
{
$flag = $this->currentFlag->find($id);
if (!$flag) {
return $this->respondError('Flag not found', Response::HTTP_NOT_FOUND);
}
return $this->success($flag, 'Flag retrieved successfully');
}
public function create()
{
return $this->addFlag();
}
/**
* Add or update a flag for a student
* If a flag of the same type exists, it updates it; otherwise creates a new one
*/
public function addFlag()
{
$user = $this->getCurrentUser();
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$studentId = $data['student_id'] ?? $data['student'] ?? null;
$flagType = $data['flag'] ?? $data['flag_type'] ?? null;
$description = $data['description'] ?? $data['open_description'] ?? null;
$flagState = $data['flag_state'] ?? 'Open';
$stateDescription = $data['state_description'] ?? null;
$grade = $data['grade'] ?? null;
if (!$studentId || !$flagType || !$description) {
return $this->respondError('Missing required fields: student_id, flag, description', Response::HTTP_BAD_REQUEST);
}
try {
// Retrieve student information
$student = $this->student->find($studentId);
if (!$student) {
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
}
$userId = $user->id ?? null;
$semester = $this->config->getConfig('semester');
$schoolYear = $this->config->getConfig('school_year');
$currentDateTime = date('Y-m-d H:i:s');
// Check if a flag of the same type already exists for this student
$existingFlag = $this->currentFlag
->where('student_id', $studentId)
->where('flag', $flagType)
->first();
if ($existingFlag) {
// Update existing flag
$updateData = [
'flag_datetime' => $currentDateTime,
'updated_by_open' => $userId,
];
// Append description based on flag state
if ($flagState === 'Closed') {
$updateData['flag_state'] = 'Closed';
$existingCloseDesc = $existingFlag['close_description'] ?? '';
$updateData['close_description'] = $existingCloseDesc
? $existingCloseDesc . PHP_EOL . $stateDescription
: $stateDescription;
} elseif ($flagState === 'Canceled') {
$updateData['flag_state'] = 'Canceled';
$existingCancelDesc = $existingFlag['cancel_description'] ?? '';
$updateData['cancel_description'] = $existingCancelDesc
? $existingCancelDesc . PHP_EOL . $stateDescription
: $stateDescription;
} else {
$existingOpenDesc = $existingFlag['open_description'] ?? '';
$updateData['open_description'] = $existingOpenDesc
? $existingOpenDesc . PHP_EOL . $description
: $description;
}
$this->currentFlag->update($existingFlag['id'], $updateData);
$flag = $this->currentFlag->find($existingFlag['id']);
return $this->success($flag, 'Flag updated successfully');
} else {
// Create new flag
$flagData = [
'student_id' => $student['id'],
'student_name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
'grade' => $grade,
'flag' => $flagType,
'flag_datetime' => $currentDateTime,
'flag_state' => 'Open',
'updated_by_open' => $userId,
'open_description' => $description,
'semester' => $semester,
'school_year' => $schoolYear,
];
$flag = $this->currentFlag->create($flagData);
return $this->success($flag, 'New flag added successfully', Response::HTTP_CREATED);
}
} catch (\Throwable $e) {
log_message('error', 'Add flag error: ' . $e->getMessage());
return $this->respondError('Failed to add flag', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function updateState($id = null)
{
$flag = $this->currentFlag->find($id);
if (!$flag) {
return $this->respondError('Flag not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$newState = $data['flag_state'] ?? null;
if (!$newState) {
return $this->respondError('Flag state is required', Response::HTTP_BAD_REQUEST);
}
try {
$updateData = ['flag_state' => $newState];
// Update appropriate description field based on state
if (isset($data['state_description'])) {
if ($newState === 'Closed') {
$updateData['close_description'] = $data['state_description'];
} elseif ($newState === 'Canceled') {
$updateData['cancel_description'] = $data['state_description'];
} else {
$updateData['open_description'] = $data['state_description'];
}
}
$this->currentFlag->update($id, $updateData);
$flag = $this->currentFlag->find($id);
return $this->success($flag, 'Flag state updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Update flag state error: ' . $e->getMessage());
return $this->respondError('Failed to update flag state', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function close($id = null)
{
return $this->closeFlag($id);
}
public function cancel($id = null)
{
return $this->cancelFlag($id);
}
/**
* Close a flag and move it to history
*/
public function closeFlag($flagId = null)
{
$flagId = $flagId ?? $this->request->getPost('id') ?? $this->request->getGet('id');
if (!$flagId) {
return $this->respondError('Flag ID is required', Response::HTTP_BAD_REQUEST);
}
try {
$user = $this->getCurrentUser();
$userId = $user->id ?? null;
// Get the current flag data
$flagData = $this->currentFlag->find($flagId);
if (!$flagData) {
return $this->respondError('Flag not found', Response::HTTP_NOT_FOUND);
}
// Check if flag is already closed
if ($flagData['flag_state'] === 'Closed') {
return $this->respondError('Flag is already closed', Response::HTTP_BAD_REQUEST);
}
// Get close description from request
$data = $this->payloadData();
$closeDescription = $data['state_description'] ?? $data['close_description'] ?? null;
if (empty($closeDescription)) {
return $this->respondError('Close description is required', Response::HTTP_BAD_REQUEST);
}
// Update the flag state to Closed
$this->currentFlag->update($flagId, [
'flag_state' => 'Closed',
'updated_by_closed' => $userId,
'close_description' => $closeDescription,
]);
// Retrieve updated flag data
$flagData = $this->currentFlag->find($flagId);
// Move to history
return $this->moveToHistory($flagData);
} catch (\Throwable $e) {
log_message('error', 'Close flag error: ' . $e->getMessage());
return $this->respondError('Failed to close flag', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Cancel a flag and move it to history
*/
public function cancelFlag($flagId = null)
{
$flagId = $flagId ?? $this->request->getPost('id') ?? $this->request->getGet('id');
if (!$flagId) {
return $this->respondError('Flag ID is required', Response::HTTP_BAD_REQUEST);
}
try {
$user = $this->getCurrentUser();
$userId = $user->id ?? null;
// Get the current flag data
$flagData = $this->currentFlag->find($flagId);
if (!$flagData) {
return $this->respondError('Flag not found', Response::HTTP_NOT_FOUND);
}
// Check if flag is already canceled
if ($flagData['flag_state'] === 'Canceled') {
return $this->respondError('Flag is already canceled', Response::HTTP_BAD_REQUEST);
}
// Get cancel description from request
$data = $this->payloadData();
$cancelDescription = $data['state_description'] ?? $data['cancel_description'] ?? null;
if (empty($cancelDescription)) {
return $this->respondError('Cancel description is required', Response::HTTP_BAD_REQUEST);
}
// Update the flag state to Canceled
$this->currentFlag->update($flagId, [
'flag_state' => 'Canceled',
'updated_by_canceled' => $userId,
'cancel_description' => $cancelDescription,
]);
// Retrieve updated flag data
$flagData = $this->currentFlag->find($flagId);
// Move to history
return $this->moveToHistory($flagData);
} catch (\Throwable $e) {
log_message('error', 'Cancel flag error: ' . $e->getMessage());
return $this->respondError('Failed to cancel flag', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Move flag to history table and delete from current_flag table
*/
protected function moveToHistory($flagData)
{
try {
// Prepare data for the flag table
$dataToInsert = [
'student_id' => $flagData['student_id'] ?? null,
'student_name' => $flagData['student_name'] ?? null,
'grade' => $flagData['grade'] ?? null,
'flag' => $flagData['flag'] ?? null,
'flag_datetime' => $flagData['flag_datetime'] ?? null,
'flag_state' => $flagData['flag_state'] ?? null,
'updated_by_open' => $flagData['updated_by_open'] ?? null,
'open_description' => $flagData['open_description'] ?? null,
'updated_by_closed' => $flagData['updated_by_closed'] ?? null,
'close_description' => $flagData['close_description'] ?? null,
'updated_by_canceled' => $flagData['updated_by_canceled'] ?? null,
'cancel_description' => $flagData['cancel_description'] ?? null,
'semester' => $flagData['semester'] ?? null,
'school_year' => $flagData['school_year'] ?? null,
'created_at' => $flagData['created_at'] ?? date('Y-m-d H:i:s'),
'updated_at' => $flagData['updated_at'] ?? date('Y-m-d H:i:s'),
];
// Insert data into the flag table
$this->flag->insert($dataToInsert);
// Delete the entry from the current_flag table
$this->currentFlag->delete($flagData['id']);
return $this->success(['moved_to_history' => true], 'Flag has been moved to history');
} catch (\Throwable $e) {
log_message('error', 'Move to history error: ' . $e->getMessage());
return $this->respondError('Failed to move flag to history', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function getStudentsByGrade($gradeId = null)
{
try {
$studentIds = $this->studentClass->newQuery()
->where('class_section_id', $gradeId)
->pluck('student_id')
->toArray();
if (empty($studentIds)) {
return $this->success([], 'No students found for this grade');
}
$students = $this->student->newQuery()
->whereIn('id', $studentIds)
->get()
->map(fn($student) => [
'id' => $student->id,
'name' => trim(($student->firstname ?? '') . ' ' . ($student->lastname ?? '')),
])
->values()
->toArray();
return $this->success($students, 'Students retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Get students by grade error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve students', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,56 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use Symfony\Component\HttpFoundation\Response;
class FrontendController extends BaseApiController
{
public function getUser()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
try {
$user = model(User::class);
$userInfo = $user->getUserInfoById($user->id);
if (!$userInfo) {
return $this->respondError('User not found', Response::HTTP_NOT_FOUND);
}
$firstname = (string) ($userInfo['firstname'] ?? '');
$lastname = (string) ($userInfo['lastname'] ?? '');
return $this->success([
'firstname' => $firstname,
'lastname' => $lastname,
'fullname' => trim($firstname . ' ' . $lastname),
'initials' => strtoupper(($firstname[0] ?? '') . ($lastname[0] ?? '')),
], 'User info retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Frontend user error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve user info', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function getPages()
{
try {
return $this->success([
'pages' => [
'facility' => '/facility',
'team' => '/team',
'call_to_action' => '/call_to_action',
'testimonial' => '/testimonial',
],
], 'Frontend pages retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Frontend pages error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve frontend pages', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,482 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\AttendanceRecord;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\FinalExam;
use App\Models\Homework;
use App\Models\MidtermExam;
use App\Models\Project;
use App\Models\Quiz;
use App\Models\ScoreComment;
use App\Models\SemesterScore;
use App\Models\StudentClass;
use App\Models\Student;
use App\Models\TeacherClass;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
class GradingController extends BaseApiController
{
protected Configuration $config;
protected Homework $homework;
protected User $user;
protected StudentClass $studentClass;
protected Student $student;
protected TeacherClass $teacherClass;
protected ClassSection $classSection;
protected string $schoolYear;
protected string $semester;
protected $semesterScoreService;
public function __construct()
{
parent::__construct();
$this->config = model(Configuration::class);
$this->homework = model(Homework::class);
$this->user = model(User::class);
$this->studentClass = model(StudentClass::class);
$this->student = model(Student::class);
$this->teacherClass = model(TeacherClass::class);
$this->classSection = model(ClassSection::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
$this->semesterScoreService = \Config\Services::semesterScoreService();
}
/**
* Show scores for a specific type, class section, and student
*/
public function show($type, $classSectionId = null, $studentId = null)
{
$model = $this->getModelByType($type);
if (!$model) {
return $this->respondError('Invalid type', Response::HTTP_BAD_REQUEST);
}
$student = model(Student::class);
$student = null;
if ($studentId) {
$student = $student->find($studentId);
if (!$student) {
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
}
}
try {
$scores = $model->where([
'student_id' => $studentId,
'semester' => $this->semester,
'school_year' => $this->schoolYear
])->findAll();
return $this->success([
'student' => $student,
'scores' => $scores,
'type' => $type,
'class_section_id' => $classSectionId,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
], 'Scores retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Grading show error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve scores', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Update scores for various types
*/
public function update()
{
$data = $this->payloadData();
$type = $data['type'] ?? null;
$studentId = (int) ($data['student_id'] ?? 0);
$classSectionId = (int) ($data['class_section_id'] ?? 0);
if (!$type) {
return $this->respondError('Type is required', Response::HTTP_BAD_REQUEST);
}
$model = $this->getModelByType($type);
if (!$model) {
return $this->respondError('Invalid type', Response::HTTP_BAD_REQUEST);
}
try {
if (in_array($type, ['homework', 'quiz', 'project'])) {
$scoreIds = $data['score_ids'] ?? [];
$scores = $data['scores'] ?? [];
$comments = $data['comments'] ?? [];
foreach ($scoreIds as $i => $id) {
$updateData = [
'score' => $scores[$i] ?? null,
'updated_at' => utc_now()
];
if (isset($comments[$i])) {
$updateData['comment'] = $comments[$i];
}
$model->update($id, $updateData);
}
} elseif (in_array($type, ['midterm', 'final', 'test'])) {
$score = $data['score'] ?? null;
$updateData = [
'score' => $score,
'updated_at' => utc_now()
];
$existing = $model->where([
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'semester' => $this->semester,
'school_year' => $this->schoolYear
])->first();
if ($existing) {
$model->update($existing['id'], $updateData);
} else {
$insertData = array_merge($updateData, [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'created_at' => utc_now()
]);
$model->insert($insertData);
}
} elseif ($type === 'comments') {
$comment = $data['comment'] ?? null;
// Remove existing comments for this student/semester/year
$model->where([
'student_id' => $studentId,
'semester' => $this->semester,
'school_year' => $this->schoolYear
])->delete();
// Insert new comment
$model->insert([
'student_id' => $studentId,
'score_type' => 'general',
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'comment' => $comment,
'commented_by' => $this->getCurrentUserId(),
'created_at' => utc_now()
]);
}
// Update semester scores for students in the class section
if ($classSectionId > 0) {
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId(
$classSectionId,
$this->semester,
$this->schoolYear
);
try {
foreach ($studentTeacherInfo as $studentInfo) {
$this->semesterScoreService->updateStudentScores([
'student_id' => $studentInfo['student_id'],
'school_id' => $studentInfo['school_id'] ?? null,
'class_section_id' => $studentInfo['class_section_id'],
'updated_by' => $studentInfo['updated_by'] ?? $this->getCurrentUserId(),
], $this->semester, $this->schoolYear);
}
} catch (RuntimeException $e) {
log_message('error', 'Failed to update semester scores: ' . $e->getMessage());
}
}
return $this->success(null, 'Scores updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Grading update error: ' . $e->getMessage());
return $this->respondError('Failed to update scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Get grading main view data - all students grouped by class sections
*/
public function grading()
{
$schoolYear = (string) $this->schoolYear;
$semester = (string) $this->semester;
// Roster + dual-join to semester_scores:
// ss_b: match when semester_scores.class_section_id = BUSINESS ID (student_class.class_section_id)
// ss_p: match when semester_scores.class_section_id = PK (classSection.id)
$rows = DB::table('student_class as sc')
->select([
'cs.id as section_pk',
'cs.class_section_id as section_id',
'cs.class_id',
'cs.class_section_name',
's.id as student_id',
's.school_id',
's.firstname',
's.lastname',
DB::raw('COALESCE(ss_b.ptap_score, ss_p.ptap_score) as ss_ptap_score'),
DB::raw('COALESCE(ss_b.semester_score, ss_p.semester_score) as ss_semester_score'),
'ss_b.class_section_id as matched_biz_csid',
'ss_p.class_section_id as matched_pk_csid'
])
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->join('students as s', 's.id', '=', 'sc.student_id')
->leftJoin('semester_scores as ss_b', function($join) use ($semester, $schoolYear) {
$join->on('ss_b.student_id', '=', 's.id')
->on('ss_b.class_section_id', '=', 'sc.class_section_id')
->whereRaw('LOWER(ss_b.semester) = LOWER(?)', [$semester])
->where('ss_b.school_year', '=', $schoolYear);
})
->leftJoin('semester_scores as ss_p', function($join) use ($semester, $schoolYear) {
$join->on('ss_p.student_id', '=', 's.id')
->on('ss_p.class_section_id', '=', 'cs.id')
->whereRaw('LOWER(ss_p.semester) = LOWER(?)', [$semester])
->where('ss_p.school_year', '=', $schoolYear);
})
->where('sc.school_year', $schoolYear)
->orderBy('cs.class_id', 'ASC')
->orderBy('cs.class_section_name', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->toArray();
// Build structures keyed by BUSINESS section id
$grades = []; // class_id => [ ['class_section_id','class_section_name'], ... ]
$studentsBySection = []; // section_id => [ students... ]
foreach ($rows as $r) {
$sectionId = (int) ($r->section_id ?? 0);
$classId = (int) ($r->class_id ?? 0);
$sectionName = (string) ($r->class_section_name ?? '');
if ($sectionId <= 0 || $classId <= 0) continue;
if (!isset($grades[$classId])) {
$grades[$classId] = [];
}
$exists = false;
foreach ($grades[$classId] as $s) {
if ((int)$s['class_section_id'] === $sectionId) {
$exists = true;
break;
}
}
if (!$exists) {
$grades[$classId][] = [
'class_section_id' => $sectionId,
'class_section_name' => $sectionName,
];
}
$sid = (int) ($r->student_id ?? 0);
if ($sid <= 0) continue;
$ptapScore = $r->ss_ptap_score ?? null;
$semesterScore = $r->ss_semester_score ?? null;
if (!isset($studentsBySection[$sectionId])) {
$studentsBySection[$sectionId] = [];
}
$studentsBySection[$sectionId][] = [
'id' => $sid,
'school_id' => $r->school_id ?? null,
'firstname' => $r->firstname ?? null,
'lastname' => $r->lastname ?? null,
'ptap' => is_null($ptapScore) ? null : (float) $ptapScore,
'semester_score' => is_null($semesterScore) ? null : (float) $semesterScore,
];
}
return $this->success([
'grades' => $grades,
'students_by_section' => $studentsBySection,
'semester' => $semester,
'school_year' => $schoolYear,
], 'Grading data retrieved successfully');
}
/**
* Get all scores and comments for students
*/
public function getScoreComment()
{
// Get all students for the current semester and school year
$studentClassEntries = $this->studentClass
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->findAll();
// Group student IDs
$studentIds = array_map(fn($entry) => $entry['student_id'], $studentClassEntries);
if (empty($studentIds)) {
return $this->success([], 'No students found');
}
// Fetch all scores and comments for the students
$scoresAndComments = $this->getAllScoresAndComments($studentIds, $this->semester, $this->schoolYear);
return $this->success($scoresAndComments, 'Scores and comments retrieved successfully');
}
/**
* Fetch all scores and comments for a list of students based on semester and school year.
*
* @param array $studentIds List of student IDs.
* @param string $semester Current semester (e.g., 'fall', 'spring').
* @param string $schoolYear Current school year (e.g., '2025-2026').
* @return array
*/
private function getAllScoresAndComments($studentIds, $semester, $schoolYear)
{
// Validate input parameters
if (empty($studentIds) || !is_array($studentIds)) {
return [];
}
if (empty($semester) || empty($schoolYear)) {
throw new \InvalidArgumentException('Semester and school year must be provided');
}
// Initialize models
$models = [
'final_exam' => model(FinalExam::class),
'homework' => model(Homework::class),
'midterm' => model(MidtermExam::class),
'project' => model(Project::class),
'quiz' => model(Quiz::class),
'comments' => model(ScoreComment::class),
'semester_scores' => model(SemesterScore::class),
'student' => model(Student::class),
'student_class' => model(StudentClass::class),
'teacher_class' => model(TeacherClass::class),
'config' => model(Configuration::class),
'attendance' => model(AttendanceRecord::class)
];
// Get semester days configuration
$semesterKey = strtolower($semester) === 'fall' ? 'total_semester1_days' : 'total_semester2_days';
$totalSemesterDays = $models['config']->getConfig($semesterKey) ?? 0;
// Common query conditions
$conditions = [
'semester' => $semester,
'school_year' => $schoolYear
];
// Fetch all student data first
$students = $models['student']->whereIn('id', $studentIds)
->where('school_year', $schoolYear)
->findAll();
if (empty($students)) {
return [];
}
// Initialize result array with student data
$allScores = [];
foreach ($students as $student) {
$className = $models['student_class']->getClassSectionsByStudentId($student['id'], $schoolYear);
$updatedBy = $models['teacher_class']->getTeacherIdByClassSection($className, $semester, $schoolYear);
$allScores[$student['id']] = [
'school_id' => $student['school_id'],
'firstname' => $student['firstname'],
'lastname' => $student['lastname'],
'class_name' => $className,
'comments' => []
];
}
// Fetch and process attendance data
foreach ($studentIds as $studentId) {
if (!isset($allScores[$studentId])) continue;
$absences = $models['attendance']->getTotalAbsences($studentId, $semester, $schoolYear);
$attendance = min((($totalSemesterDays - $absences + 1) / $totalSemesterDays) * 100, 100);
$allScores[$studentId]['attendance'] = [
'score' => round($attendance, 2),
'absences' => $absences,
'total_days' => $totalSemesterDays
];
}
// Fetch and process all score types
$scoreTypes = [
'final_exam' => $models['final_exam'],
'homework' => $models['homework'],
'midterm' => $models['midterm'],
'project' => $models['project'],
'quiz' => $models['quiz'],
'semester_score' => $models['semester_scores']
];
foreach ($scoreTypes as $type => $model) {
$scores = $model->whereIn('student_id', $studentIds)
->where($conditions)
->findAll();
foreach ($scores as $score) {
if ($type === 'semester_score') {
$allScores[$score['student_id']][$type] = [
'homework_avg' => $score['homework_avg'],
'quiz_avg' => $score['quiz_avg'],
'project_avg' => $score['project_avg'],
'midterm_exam_score' => $score['midterm_exam_score'],
'final_exam_score' => $score['final_exam_score'],
'attendance_score' => $score['attendance_score'],
'participation_score' => $score['participation_score'],
'ptap_score' => $score['ptap_score'],
'test_avg' => $score['test_avg'],
'semester_score' => $score['semester_score'],
'semester' => $score['semester'],
'school_year' => $score['school_year']
];
} else {
$allScores[$score['student_id']][$type] = $score;
}
}
}
// Fetch and process comments
$comments = $models['comments']->whereIn('student_id', $studentIds)
->where($conditions)
->findAll();
foreach ($comments as $comment) {
$allScores[$comment['student_id']]['comments'][] = $comment;
}
return $allScores;
}
/**
* Get model instance by type
*/
private function getModelByType(?string $type)
{
return match ($type) {
'homework' => model(Homework::class),
'quiz' => model(Quiz::class),
'project' => model(Project::class),
'midterm' => model(MidtermExam::class),
'final' => model(FinalExam::class),
'test' => model(SemesterScore::class),
'comments' => model(ScoreComment::class),
default => null,
};
}
}
@@ -1,94 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class HealthController extends BaseApiController
{
/**
* Check if a path exists and is writable
*/
private function checkPath(string $label, string $path): array
{
return [
'label' => $label,
'path' => $path,
'exists' => is_dir($path),
'writable' => is_writable($path),
];
}
/**
* Health check endpoint - checks filesystem paths and database schema
*/
public function index()
{
try {
// Use storage_path('app') as the base (equivalent to WRITEPATH in CodeIgniter)
$uploadsBase = storage_path('app') . DIRECTORY_SEPARATOR . 'uploads';
$paths = [
'uploads' => $uploadsBase,
'uploads/reimbursements' => $uploadsBase . DIRECTORY_SEPARATOR . 'reimbursements',
'uploads/receipts' => $uploadsBase . DIRECTORY_SEPARATOR . 'receipts',
'uploads/checks' => $uploadsBase . DIRECTORY_SEPARATOR . 'checks',
];
$pathsStatus = [];
foreach ($paths as $label => $p) {
$pathsStatus[] = $this->checkPath($label, $p);
}
// Use Laravel's schema builder to check tables and columns
$schema = DB::getSchemaBuilder();
$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;
// Check if all paths are OK
$okPaths = array_reduce($pathsStatus, function ($carry, $row) {
return $carry && $row['exists'] && $row['writable'];
}, true);
// Check if database schema is OK
$okDb = true;
if ($dbChecks['user_preferences_exists'] && !$dbChecks['user_preferences_has_timezone']) {
$okDb = false;
}
if ($dbChecks['settings_exists'] && !$dbChecks['settings_has_timezone']) {
$okDb = false;
}
$ok = $okPaths && $okDb;
$payload = [
'ok' => $ok,
'paths' => $pathsStatus,
'database' => $dbChecks,
'write_path' => storage_path('app'),
'timestamp' => date('c'),
];
$status = $ok ? Response::HTTP_OK : Response::HTTP_SERVICE_UNAVAILABLE;
// Return JSON response with appropriate status code
return response()->json($payload, $status);
} catch (\Throwable $e) {
log_message('error', 'Health check error: ' . $e->getMessage());
return $this->error('Health check failed', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,516 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Homework;
use App\Models\StudentClass;
use App\Models\Student;
use App\Models\TeacherClass;
use App\Models\User;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
class HomeworkController extends BaseApiController
{
protected Homework $homework;
protected Configuration $config;
protected StudentClass $studentClass;
protected Student $student;
protected TeacherClass $teacherClass;
protected User $user;
protected string $schoolYear;
protected string $semester;
protected $semesterScoreService;
public function __construct()
{
parent::__construct();
$this->homework = model(Homework::class);
$this->config = model(Configuration::class);
$this->studentClass = model(StudentClass::class);
$this->student = model(Student::class);
$this->teacherClass = model(TeacherClass::class);
$this->user = model(User::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
$this->semesterScoreService = \Config\Services::semesterScoreService();
}
/**
* Get paginated homework list
*/
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$classSectionId = $this->request->getGet('class_section_id');
$query = $this->homework->newQuery()
->when($this->schoolYear !== '', fn($q) => $q->where('school_year', $this->schoolYear))
->when($this->semester !== '', fn($q) => $q->where('semester', $this->semester))
->when($classSectionId, fn($q) => $q->where('class_section_id', $classSectionId))
->orderBy('created_at', 'DESC');
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Homework retrieved successfully');
}
/**
* Get homework by student ID
*/
public function getByStudent($id = null)
{
$query = $this->homework->newQuery()
->when($id, fn($q) => $q->where('student_id', $id))
->when($this->schoolYear !== '', fn($q) => $q->where('school_year', $this->schoolYear))
->when($this->semester !== '', fn($q) => $q->where('semester', $this->semester));
$homework = $query->get()->toArray();
return $this->success($homework, 'Homework retrieved successfully');
}
/**
* Create a single homework entry
*/
public function create()
{
$user = $this->getCurrentUser();
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$errors = $this->validateRequest($data, [
'student_id' => 'required|integer',
'class_section_id' => 'required|integer',
'homework_index' => 'required|integer',
]);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$payload = [
'student_id' => $data['student_id'],
'class_section_id' => $data['class_section_id'],
'homework_index' => $data['homework_index'],
'score' => $data['score'] ?? null,
'comment' => $data['comment'] ?? null,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'updated_by' => $user->id ?? null,
];
$homework = $this->homework->create($payload);
return $this->success($homework, 'Homework created successfully', Response::HTTP_CREATED);
}
/**
* Update a single homework entry
*/
public function update($id = null)
{
$homework = $this->homework->find($id);
if (!$homework) {
return $this->respondError('Homework not found', Response::HTTP_NOT_FOUND);
}
$user = $this->getCurrentUser();
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$allowed = ['score', 'comment'];
$update = [];
foreach ($allowed as $field) {
if (array_key_exists($field, $data)) {
$update[$field] = $data[$field];
}
}
if (empty($update)) {
return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST);
}
$update['updated_by'] = $user->id ?? null;
$update['updated_at'] = utc_now();
$this->homework->update($id, $update);
return $this->success($this->homework->find($id), 'Homework updated successfully');
}
/**
* Bulk update homework scores for multiple students
*/
public function updateScores()
{
$data = $this->payloadData();
$scores = $data['scores'] ?? null;
$classSectionId = (int) ($data['class_section_id'] ?? 0);
$updatedBy = $this->getCurrentUserId();
if (!$scores || !is_array($scores)) {
return $this->respondError('Scores data is required', Response::HTTP_BAD_REQUEST);
}
if ($classSectionId <= 0) {
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
}
try {
foreach ($scores as $studentId => $hwData) {
if (!is_numeric($studentId)) continue;
$student = $this->student->find($studentId);
if (!$student) continue;
foreach ($hwData as $index => $score) {
// STEP 1: Always fetch the existing record
$existing = $this->homework->where([
'student_id' => $studentId,
'homework_index' => $index,
'class_section_id' => $classSectionId,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
])->first();
// STEP 2: If blank, delete if exists
if ($score === '' || $score === null) {
if ($existing) {
$this->homework->delete($existing['id']);
log_message('debug', "Deleted blank score for student $studentId index $index");
}
continue;
}
// STEP 3: Save numeric score
if (is_numeric($score)) {
$updateData = [
'student_id' => $studentId,
'school_id' => $student['school_id'] ?? '',
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'homework_index' => $index,
'score' => (float)$score,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'updated_at' => utc_now(),
];
if ($existing) {
$this->homework->update($existing['id'], $updateData);
log_message('debug', "Updated homework ID {$existing['id']} with score $score");
} else {
$updateData['created_at'] = utc_now();
$this->homework->insert($updateData);
log_message('debug', "Inserted score for student $studentId index $index");
}
}
}
}
// Update semester scores for students in the class section
$studentUserInfo = $this->student->getStudentInfoByClassSectionId(
$classSectionId,
$this->semester,
$this->schoolYear
);
try {
foreach ($studentUserInfo as $studentInfo) {
$this->semesterScoreService->updateStudentScores([
'student_id' => $studentInfo['student_id'],
'school_id' => $studentInfo['school_id'] ?? null,
'class_section_id' => $studentInfo['class_section_id'],
'updated_by' => $studentInfo['updated_by'] ?? $updatedBy,
], $this->semester, $this->schoolYear);
}
} catch (RuntimeException $e) {
log_message('error', 'Failed to update semester scores: ' . $e->getMessage());
}
return $this->success(null, 'Homework scores updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Update homework scores error: ' . $e->getMessage());
return $this->respondError('Failed to update homework scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Add a new homework column/index for all students in a class section
*/
public function addNextColumn()
{
$data = $this->payloadData();
$classSectionId = (int) ($data['class_section_id'] ?? 0);
$updatedBy = $this->getCurrentUserId();
if ($classSectionId <= 0) {
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
}
try {
// Step 1: Get the highest existing homework_index
$existingIndexes = $this->homework
->select('homework_index')
->where('class_section_id', $classSectionId)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->groupBy('homework_index')
->orderBy('homework_index', 'DESC')
->findAll();
$maxIndex = 0;
foreach ($existingIndexes as $row) {
if (isset($row['homework_index']) && is_numeric($row['homework_index'])) {
$maxIndex = max($maxIndex, (int)$row['homework_index']);
}
}
$nextIndex = $maxIndex + 1;
// Step 2: Get all students in the class
$students = $this->studentClass
->where('class_section_id', $classSectionId)
->where('school_year', $this->schoolYear)
->findAll();
// Step 3: Insert a new homework row for each student if not already exists
$firstInsertedId = null;
$insertedCount = 0;
foreach ($students as $i => $student) {
$studentId = $student['student_id'];
// Check if record already exists
$existing = $this->homework
->where('student_id', $studentId)
->where('homework_index', $nextIndex)
->where('class_section_id', $classSectionId)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->first();
if ($existing) continue;
$studentRecord = $this->student->find($studentId);
$insertData = [
'student_id' => $studentId,
'school_id' => $studentRecord['school_id'] ?? '',
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'homework_index' => $nextIndex,
'score' => null,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'created_at' => utc_now(),
'updated_at' => utc_now()
];
$id = $this->homework->insert($insertData);
if ($i === 0) {
$firstInsertedId = $id;
}
$insertedCount++;
}
return $this->success([
'homework_index' => $nextIndex,
'new_homework_id' => $firstInsertedId,
'students_processed' => $insertedCount
], 'Homework column added successfully');
} catch (\Throwable $e) {
log_message('error', 'Add homework column error: ' . $e->getMessage());
return $this->respondError('Failed to add homework column: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Get homework data for a class section (for management view)
*/
public function getByClassSection()
{
$classSectionId = (int) ($this->request->getGet('class_section_id') ?? 0);
if ($classSectionId <= 0) {
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
}
try {
$homeworkScores = $this->getHomeworkScoresByStudent($classSectionId, $this->semester, $this->schoolYear);
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $this->schoolYear);
$homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $this->semester, $this->schoolYear);
return $this->success([
'homework_scores' => $homeworkScores,
'students' => $students,
'homework_headers' => $homeworkHeaders,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'class_section_id' => $classSectionId,
'has_homework_cols' => !empty($homeworkHeaders),
], 'Homework data retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Get homework by class section error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve homework data: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Get students with their homework scores
*/
public function getStudentsWithScores()
{
$classSectionId = (int) ($this->request->getGet('class_section_id') ?? 0);
if ($classSectionId <= 0) {
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
}
try {
$homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $this->semester, $this->schoolYear);
$students = $this->getStudentsWithHomeworkScores($classSectionId, $homeworkHeaders);
return $this->success([
'students' => $students,
'homework_headers' => $homeworkHeaders,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'class_section_id' => $classSectionId,
], 'Students with homework scores retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Get students with scores error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve students with scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
// ──────────────── Helper Methods ────────────────
/**
* Get students by class section and year
*/
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
{
// Step 1: Get student IDs from student_class table
$studentClassRows = $this->studentClass
->select('student_id')
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->findAll();
$studentIds = array_column($studentClassRows, 'student_id');
if (empty($studentIds)) {
return [];
}
// Step 2: Get student data from students table
$students = $this->student
->whereIn('id', $studentIds)
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->findAll();
return $students;
}
/**
* Get homework scores grouped by student
*/
private function getHomeworkScoresByStudent($classSectionId, $semester, $schoolYear)
{
$rows = $this->homework
->select('student_id, homework_index, score')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
$studentScores = [];
foreach ($rows as $row) {
$studentId = $row['student_id'];
$index = $row['homework_index'];
$score = $row['score'];
if (!isset($studentScores[$studentId])) {
$studentScores[$studentId] = ['scores' => []];
}
$studentScores[$studentId]['scores'][$index] = $score;
}
return $studentScores;
}
/**
* Get students with their homework scores
*/
private function getStudentsWithHomeworkScores($classSectionId, $homeworkHeaders)
{
$studentClasses = $this->studentClass
->where('class_section_id', $classSectionId)
->where('school_year', $this->schoolYear)
->findAll();
$students = [];
foreach ($studentClasses as $sc) {
$student = $this->student->find($sc['student_id']);
if (!$student) continue;
$scores = [];
foreach ($homeworkHeaders as $index) {
$entry = $this->homework
->where('homework_index', $index)
->where('student_id', $sc['student_id'])
->where('class_section_id', $classSectionId)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->first();
$scores[$index] = $entry['score'] ?? '';
}
$students[] = [
'student_id' => $sc['student_id'],
'firstname' => $student['firstname'],
'lastname' => $student['lastname'],
'scores' => $scores
];
}
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
return $students;
}
/**
* Get homework headers (unique homework_index values)
*/
private function getHomeworkHeaders($classSectionId, $semester, $schoolYear)
{
$rows = $this->homework
->select('homework_index')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->orderBy('homework_index', 'ASC')
->findAll();
$headers = [];
foreach ($rows as $row) {
$index = $row['homework_index'];
if (!is_array($index)) {
$headers[$index] = true;
}
}
ksort($headers);
return array_keys($headers);
}
}
@@ -1,275 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Calendar;
use App\Models\Configuration;
use App\Models\Homework;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class HomeworkTrackingController extends BaseApiController
{
protected Calendar $calendar;
protected Homework $homework;
protected Configuration $config;
protected string $schoolYear;
protected string $semester;
public function __construct()
{
parent::__construct();
$this->calendar = model(Calendar::class);
$this->homework = model(Homework::class);
$this->config = model(Configuration::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
}
/**
* Get homework tracking data with Sunday-based schedule
*/
public function index()
{
try {
// Get date range from request or use defaults
$startDate = $this->request->getGet('start_date') ?? '2025-09-21';
$endDate = $this->request->getGet('end_date') ?? '2026-01-18';
// Parse dates
try {
$start = new \DateTime($startDate);
} catch (\Throwable $e) {
$start = new \DateTime('today');
}
try {
$end = new \DateTime($endDate);
} catch (\Throwable $e) {
$end = new \DateTime('today');
}
// Move start to the first Sunday on/after start
if ((int)$start->format('w') !== 0) {
$start = (clone $start)->modify('next sunday');
}
// Collect all Sundays within range
$sundays = [];
$d = clone $start;
while ($d <= $end) {
$sundays[] = $d->format('Y-m-d');
$d->modify('+7 days');
}
// Map "no school" events by date (Y-m-d) using explicit DB flag
$events = $this->calendar->getEventsBySchoolYear($this->schoolYear) ?? [];
$eventDays = [];
foreach ($events as $ev) {
$ymd = substr((string)($ev['date'] ?? $ev['start_date'] ?? ''), 0, 10);
$no = (int)($ev['no_school'] ?? 0);
if ($ymd && $no === 1) {
$eventDays[$ymd] = true; // yellow highlight for no-school day
}
}
// Map Sundays to ordinal homework_index, skipping event Sundays
$dateToIndex = [];
$idx = 0;
foreach ($sundays as $ymd) {
if (!isset($eventDays[$ymd])) {
$idx++;
$dateToIndex[$ymd] = $idx;
} else {
$dateToIndex[$ymd] = null; // event day (no homework expected)
}
}
// Aggregate homework presence + first entered date per class_section_id + homework_index
$rows = DB::table('homework')
->select('class_section_id', 'homework_index', DB::raw('MIN(created_at) AS first_created'), DB::raw('COUNT(*) AS cnt'))
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->groupBy('class_section_id', 'homework_index')
->get()
->toArray();
$hasHomework = [];
$hwEnteredAt = [];
foreach ($rows as $r) {
$csid = (int)($r->class_section_id ?? 0);
$hi = (int)($r->homework_index ?? 0);
$cnt = (int)($r->cnt ?? 0);
if ($csid > 0 && $hi > 0 && $cnt > 0) {
$hasHomework[$csid][$hi] = true;
$dateStr = substr((string)($r->first_created ?? ''), 0, 10);
$hwEnteredAt[$csid][$hi] = $dateStr ?: null;
}
}
// Build date-based presence mapped to the nearest prior non-NoSchool Sunday
$rowsByDate = DB::table('homework')
->select(DB::raw('class_section_id, DATE(created_at) AS hw_date, MIN(created_at) AS first_created, COUNT(*) AS cnt'))
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->groupBy(DB::raw('class_section_id, DATE(created_at)'))
->orderBy('hw_date', 'ASC')
->get()
->toArray();
$hasHomeworkByDate = [];
$hwEnteredAtByDate = [];
foreach ($rowsByDate as $r) {
$csid = (int)($r->class_section_id ?? 0);
$d = substr((string)($r->hw_date ?? ''), 0, 10);
$cnt = (int)($r->cnt ?? 0);
$firstCreated = substr((string)($r->first_created ?? ''), 0, 10);
if ($csid <= 0 || !$d || $cnt <= 0) {
continue;
}
// Find the index of the last Sunday on or before $d
$baseIndex = -1;
for ($i = count($sundays) - 1; $i >= 0; $i--) {
if ($sundays[$i] <= $d) {
$baseIndex = $i;
break;
}
}
if ($baseIndex < 0) {
continue;
}
// Map this homework day to only the nearest prior nonNo School Sunday
$j = $baseIndex;
while ($j >= 0 && !empty($eventDays[$sundays[$j]])) {
$j--;
}
if ($j < 0) {
continue;
}
$sd = $sundays[$j];
// Mark homework on that Sunday for this class_section (single mapping)
$hasHomeworkByDate[$csid][$sd] = true;
if (empty($hwEnteredAtByDate[$csid][$sd])) {
$hwEnteredAtByDate[$csid][$sd] = $firstCreated ?: $d;
} else {
// Keep the earliest date for display
$existing = (string)$hwEnteredAtByDate[$csid][$sd];
$candidate = $firstCreated ?: $d;
if ($candidate && (!$existing || $candidate < $existing)) {
$hwEnteredAtByDate[$csid][$sd] = $candidate;
}
}
}
// Fetch teachers and TAs per section for this term
$teacherRows = DB::table('teacher_class as tc')
->select('tc.class_section_id', 'tc.position', 'cs.class_section_name', 'cs.class_id', 'u.firstname', 'u.lastname')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
->leftJoin('users as u', 'u.id', '=', 'tc.teacher_id')
->where('tc.school_year', $this->schoolYear)
->where('tc.semester', $this->semester)
->whereNotNull('tc.class_section_id')
->whereIn('tc.position', ['main', 'ta'])
->orderBy('cs.class_id', 'ASC')
->orderBy('cs.class_section_name', 'ASC')
->orderByRaw("FIELD(tc.position, 'main','ta')")
->orderBy('u.firstname', 'ASC')
->get()
->toArray();
$bySection = [];
foreach ($teacherRows as $r) {
$sid = (int)($r->class_section_id ?? 0);
if ($sid <= 0) continue;
if (!isset($bySection[$sid])) {
$bySection[$sid] = [
'class_section_id' => $sid,
'class_id' => (int)($r->class_id ?? 0),
'class_section_name' => (string)($r->class_section_name ?? ''),
'teachers' => [],
'tas' => [],
];
}
$name = trim(($r->firstname ?? '') . ' ' . ($r->lastname ?? ''));
$pos = strtolower((string)($r->position ?? ''));
if ($name !== '') {
if ($pos === 'main') {
$bySection[$sid]['teachers'][] = $name;
} elseif ($pos === 'ta') {
$bySection[$sid]['tas'][] = $name;
}
}
}
$teachers = array_values($bySection);
// Sort by grade order: KG(13) → Grade 1..11 → Youth(12) → others
usort($teachers, function ($a, $b) {
$ai = (int)($a['class_id'] ?? 0);
$bi = (int)($b['class_id'] ?? 0);
$ord = function ($cid) {
if ($cid === 13) return 0; // KG first
if ($cid >= 1 && $cid <= 11) return $cid; // Grades 1..11
if ($cid === 12) return 99; // Youth last of known
return 200 + $cid; // any others after
};
$cmp = $ord($ai) <=> $ord($bi);
if ($cmp !== 0) return $cmp;
return strnatcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? '');
});
// Simple server-side pagination for rows (8 lines per page)
$perPage = 8;
$totalRows = count($teachers);
$totalPages = max(1, (int)ceil($totalRows / $perPage));
$page = (int) ($this->request->getGet('page') ?? 1);
if ($page < 1) $page = 1;
if ($page > $totalPages) $page = $totalPages;
$offset = ($page - 1) * $perPage;
$teachersPage = array_slice($teachers, $offset, $perPage);
return $this->success([
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'sundays' => $sundays,
'event_days' => $eventDays,
'date_to_index' => $dateToIndex,
'teachers' => $teachersPage,
'has_homework' => $hasHomework,
'hw_entered_at' => $hwEnteredAt,
'has_homework_by_date' => $hasHomeworkByDate,
'hw_entered_at_by_date' => $hwEnteredAtByDate,
'pagination' => [
'page' => $page,
'total_pages' => $totalPages,
'per_page' => $perPage,
'total_rows' => $totalRows,
],
], 'Homework tracking retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Homework tracking error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve homework tracking: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,62 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use Symfony\Component\HttpFoundation\Response;
class InfoIconController extends BaseApiController
{
/**
* Get user profile icon information (name and initials)
*/
public function profileIcon()
{
$userId = $this->getCurrentUserId();
if (!$userId) {
return $this->respondError('User not logged in', Response::HTTP_UNAUTHORIZED);
}
try {
$user = model(User::class);
$user = $user->getUserInfoById($userId);
if (!$user) {
return $this->success([
'user_name' => 'User not found',
'user_initials' => '??'
], 'User info retrieved');
}
$firstname = (string) ($user['firstname'] ?? '');
$lastname = (string) ($user['lastname'] ?? '');
$userName = trim($firstname . ' ' . $lastname);
if ($userName === '') {
$userName = $user['email'] ?? 'User #' . $userId;
}
$userInitials = '??';
if (!empty($firstname) && !empty($lastname)) {
$userInitials = strtoupper(($firstname[0] ?? '') . ($lastname[0] ?? ''));
} elseif (!empty($firstname)) {
$userInitials = strtoupper(substr($firstname, 0, 2));
} elseif (!empty($lastname)) {
$userInitials = strtoupper(substr($lastname, 0, 2));
} elseif (!empty($user['email'])) {
$email = $user['email'];
$userInitials = strtoupper(substr($email, 0, 2));
}
return $this->success([
'user_name' => $userName,
'user_initials' => $userInitials
], 'Profile icon info retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Profile icon error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve profile icon info', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,205 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\IpAttempt;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\HttpFoundation\Response;
class IpBanController extends BaseApiController
{
protected IpAttempt $ipAttempt;
public function __construct()
{
parent::__construct();
$this->ipAttempt = model(IpAttempt::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$status = strtolower(trim((string) ($this->request->getGet('status') ?? 'all')));
$now = Carbon::now('UTC')->toDateTimeString();
$query = $this->ipAttempt->newQuery()->orderBy('updated_at', 'DESC');
if ($status === 'active') {
$query->where('blocked_until', '>', $now);
} elseif ($status === 'expired') {
$query->where('blocked_until', '<=', $now)
->whereNotNull('blocked_until');
}
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'IP bans retrieved successfully');
}
public function show($id = null)
{
$ban = $this->ipAttempt->find($id);
if (!$ban) {
return $this->error('IP ban not found', Response::HTTP_NOT_FOUND);
}
return $this->success($ban, 'IP ban retrieved successfully');
}
public function unban()
{
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$id = (int) ($payload['id'] ?? 0);
$ip = trim((string) ($payload['ip'] ?? ''));
$all = (int) ($payload['all'] ?? 0) === 1;
try {
if ($all) {
$now = Carbon::now('UTC')->toDateTimeString();
$builder = $this->ipAttempt->newQuery();
$count = $builder->where('blocked_until', '>', $now)
->update([
'blocked_until' => null,
'attempts' => 0,
]);
return $this->success(['count' => $count], $count . ' IP(s) unbanned.');
}
$row = null;
if ($id > 0) {
$row = $this->ipAttempt->find($id);
} elseif ($ip !== '') {
$row = $this->ipAttempt->newQuery()
->where('ip_address', $ip)
->first();
}
if (!$row) {
return $this->error('IP record not found', Response::HTTP_NOT_FOUND);
}
$ipAddress = $row['ip_address'] ?? $row->ip_address ?? '';
$this->ipAttempt->update($row->id ?? $row['id'], [
'blocked_until' => null,
'attempts' => 0,
]);
return $this->success(['ip' => $ipAddress], 'IP ' . $ipAddress . ' unbanned.');
} catch (\Throwable $e) {
log_message('error', 'IP unban error: ' . $e->getMessage());
return $this->respondError('Failed to unban IP', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function ban()
{
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'ip' => 'required|ip',
'blocked_until' => 'required|date',
];
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
try {
$ip = trim($payload['ip']);
$blockedUntil = Carbon::parse($payload['blocked_until'])
->timezone('UTC')
->format('Y-m-d H:i:s');
$reason = $payload['reason'] ?? null;
$attempts = (int) ($payload['attempts'] ?? 5);
$existing = $this->ipAttempt->newQuery()
->where('ip_address', $ip)
->first();
if ($existing) {
$this->ipAttempt->update($existing->id ?? $existing['id'], [
'blocked_until' => $blockedUntil,
'attempts' => $attempts,
'reason' => $reason,
]);
$ban = $this->ipAttempt->find($existing->id ?? $existing['id']);
} else {
$ban = $this->ipAttempt->create([
'ip_address' => $ip,
'blocked_until' => $blockedUntil,
'attempts' => $attempts,
'reason' => $reason,
]);
}
$responseData = $ban instanceof Model ? $ban->toArray() : $ban;
return $this->success($responseData, 'IP banned successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'IP ban error: ' . $e->getMessage());
return $this->respondError('Failed to ban IP', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/ip-bans/ban-now
* Ban an IP immediately for a specified number of hours
*/
public function banNow()
{
$payload = $this->payloadData();
// Accept both POST and GET params
$id = (int) ($payload['id'] ?? $this->request->getGet('id') ?? 0);
$ip = trim((string) ($payload['ip'] ?? $this->request->getGet('ip') ?? ''));
$hours = (int) ($payload['hours'] ?? $this->request->getGet('hours') ?? 24);
if ($hours <= 0) {
$hours = 24;
}
try {
$row = null;
if ($id > 0) {
$row = $this->ipAttempt->find($id);
} elseif ($ip !== '') {
$row = $this->ipAttempt->newQuery()
->where('ip_address', $ip)
->first();
}
if (!$row) {
return $this->error('IP record not found', Response::HTTP_NOT_FOUND);
}
$blockedUntil = Carbon::now('UTC')->addHours($hours)->format('Y-m-d H:i:s');
$currentAttempts = (int) ($row['attempts'] ?? $row->attempts ?? 0);
$this->ipAttempt->update($row['id'] ?? $row->id, [
'blocked_until' => $blockedUntil,
// Optionally set attempts to threshold for clarity
'attempts' => max($currentAttempts, 10),
]);
$ipAddress = $row['ip_address'] ?? $row->ip_address;
return $this->success([
'ip' => $ipAddress,
'blocked_until' => $blockedUntil,
'hours' => $hours,
], "IP {$ipAddress} banned for {$hours}h.");
} catch (\Throwable $e) {
log_message('error', 'IP ban now error: ' . $e->getMessage());
return $this->respondError('Unable to ban: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,378 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Invoice;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use App\Models\UserRole;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class LandingPageController extends BaseApiController
{
protected Configuration $config;
protected StudentClass $studentClass;
protected Invoice $invoice;
protected UserRole $userRole;
protected TeacherClass $teacherClass;
protected string $schoolYear;
protected string $semester;
protected ?string $lastDayOfRegistration;
protected ?string $refundDeadline;
protected ?int $classSectionId;
public function __construct()
{
parent::__construct();
$this->config = model(Configuration::class);
$this->studentClass = model(StudentClass::class);
$this->invoice = model(Invoice::class);
$this->userRole = model(UserRole::class);
$this->teacherClass = model(TeacherClass::class);
// Fetch Enrollment and Refund Deadlines from Configuration
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
$this->lastDayOfRegistration = $this->config->getConfig('enrollment_deadline') ?? 'Not set';
$this->refundDeadline = $this->config->getConfig('refund_deadline') ?? 'Not set';
// Get class_id and store it in session
$this->classSectionId = $this->classSectionId();
if ($this->classSectionId) {
session()->put('class_section_id', $this->classSectionId);
}
}
/**
* GET /api/v1/landing
* Get landing page data based on user role
*/
public function index()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$userRole = $this->getUserRole();
$role = strtolower($userRole);
switch ($role) {
case 'administrator':
return $this->administrator();
case 'admin':
return $this->admin();
case 'teacher':
return $this->teacher();
case 'student':
return $this->student();
case 'parent':
case 'authorized_user':
return $this->parentDashboard();
default:
return $this->guest();
}
}
/**
* GET /api/v1/landing/data
* Get basic landing page data (backward compatibility)
*/
public function getData()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
try {
$payload = [
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'enrollment_deadline' => $this->lastDayOfRegistration,
'refund_deadline' => $this->refundDeadline,
'class_section_id' => $this->classSectionId,
'user_role' => session()->get('active_role') ?? $this->getUserRole(),
];
return $this->success($payload, 'Landing page data retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Landing page data error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve landing page data', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
protected function classSectionId(): ?int
{
$userId = $this->getCurrentUserId();
if (!$userId) {
return null;
}
$teacherClass = $this->teacherClass->getClassSectionIdByUserId($userId);
if (!$teacherClass) {
return null;
}
return (int) ($teacherClass['class_section_id'] ?? null);
}
protected function administrator()
{
return $this->success([
'role' => 'administrator',
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'enrollment_deadline' => $this->lastDayOfRegistration,
'refund_deadline' => $this->refundDeadline,
], 'Administrator dashboard data');
}
protected function admin()
{
return $this->success([
'role' => 'admin',
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'enrollment_deadline' => $this->lastDayOfRegistration,
'refund_deadline' => $this->refundDeadline,
], 'Admin dashboard data');
}
protected function teacher()
{
return $this->success([
'role' => 'teacher',
'class_section_id' => $this->classSectionId,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'enrollment_deadline' => $this->lastDayOfRegistration,
'refund_deadline' => $this->refundDeadline,
], 'Teacher dashboard data');
}
protected function student()
{
return $this->success([
'role' => 'student',
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'enrollment_deadline' => $this->lastDayOfRegistration,
'refund_deadline' => $this->refundDeadline,
], 'Student dashboard data');
}
protected function parentDashboard()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$parentId = $user->id;
$userType = session()->get('user_type');
// Map user type to roles
if ($userType === 'primary') {
$parentId = $parentId;
} elseif ($userType === 'secondary') {
// If user type is 'Secondary', find the parent_id from the parents table
$parentData = DB::table('parents')
->select('parent_id')
->where('secondparent_user_id', $parentId)
->first();
if ($parentData) {
$parentId = $parentData->parent_id;
}
} elseif ($userType === 'tertiary') {
// If user type is 'Tertiary', find the parent_id from the authorized_users table
$authUserData = DB::table('authorized_users')
->select('user_id as parent_id')
->where('authorized_user_id', $parentId)
->first();
if ($authUserData) {
$parentId = $authUserData->parent_id;
}
}
// If no firstparent ID is found, show an error
if (!$parentId) {
return $this->respondError('Unable to retrieve student data. Please contact support.', Response::HTTP_NOT_FOUND);
}
try {
// Fetch Notifications (only active, non-expired, non-deleted)
$notifications = 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 ($query) use ($parentId) {
$query->where('notifications.target_group', 'parent')
->orWhere('user_notifications.user_id', $parentId);
})
->whereNull('notifications.deleted_at')
->where(function ($query) {
$query->whereNull('notifications.expires_at')
->orWhere('notifications.expires_at', '>', DB::raw('NOW()'));
})
->groupBy('notifications.id', 'notifications.title', 'notifications.message',
'notifications.target_group', 'notifications.created_at',
'notifications.expires_at', 'user_notifications.user_id')
->orderBy('notifications.created_at', 'DESC')
->get()
->map(function ($item) {
return (array) $item;
})
->toArray();
// Fetch Student Information (no filtering needed by school year or semester)
$students = DB::table('students')
->where('parent_id', $parentId)
->get()
->map(function ($student) {
$studentArray = (array) $student;
// Get class_section_name and treat it as grade
$classSection = $this->studentClass->getClassSectionsByStudentId($student->id, $this->schoolYear);
$studentArray['class_section'] = $classSection;
$studentArray['grade'] = $classSection; // grade same as section
return $studentArray;
})
->toArray();
// Fetch Attendance Records (filtered by most recent school year and semester)
$attendanceData = 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', $this->schoolYear)
->where('attendance_data.semester', $this->semester)
->orderBy('attendance_data.date', 'DESC')
->get()
->map(function ($item) {
return (array) $item;
})
->toArray();
// Fetch Grades (filtered by most recent school year and semester)
$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', $this->schoolYear)
->where('final_score.semester', $this->semester)
->orderBy('final_score.created_at', 'DESC')
->get()
->map(function ($item) {
return (array) $item;
})
->toArray();
// Fetch Enrollments (filtered by most recent school year and semester)
$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', $this->schoolYear)
->where('enrollments.semester', $this->semester)
->orderBy('enrollments.enrollment_date', 'DESC')
->get()
->map(function ($item) {
return (array) $item;
})
->toArray();
// Fetch latest invoice balance
$paymentBalance = $this->invoice->getLatestInvoiceTotalAmount($parentId);
return $this->success([
'role' => 'parent',
'notifications' => $notifications,
'students' => $students,
'attendance' => $attendanceData,
'grades' => $grades,
'enrollments' => $enrollments,
'lastDayOfRegistration' => $this->lastDayOfRegistration,
'withdrawalDeadline' => $this->refundDeadline,
'paymentBalance' => $paymentBalance,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
], 'Parent dashboard data retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Parent dashboard error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve parent dashboard data: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
protected function guest()
{
return $this->success([
'role' => 'guest',
'school_year' => $this->schoolYear,
'semester' => $this->semester,
], 'Guest dashboard data');
}
protected function getUserRole(): string
{
// Try to get from session first
$role = session()->get('user_role');
if ($role) {
return $role;
}
// Try active_role
$role = session()->get('active_role');
if ($role) {
return $role;
}
// Get from current user's roles
$user = $this->getCurrentUser();
if ($user && !empty($user->roles)) {
return $user->roles[0] ?? 'guest';
}
// Fallback to database lookup
$userId = $this->getCurrentUserId();
if ($userId) {
return $this->getUserRoleFromDatabase($userId);
}
return 'guest';
}
protected function getUserRoleFromDatabase($userId): string
{
try {
$roles = $this->userRole->getRolesByUserId($userId);
if (!empty($roles) && isset($roles[0]['role_name'])) {
return $roles[0]['role_name'];
}
} catch (\Throwable $e) {
log_message('error', 'Failed to get user role from database: ' . $e->getMessage());
}
return 'guest';
}
}
@@ -1,107 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\LateSlipLog;
use Symfony\Component\HttpFoundation\Response;
class LateSlipLogsController extends BaseApiController
{
protected LateSlipLog $log;
protected Configuration $config;
public function __construct()
{
parent::__construct();
$this->log = model(LateSlipLog::class);
$this->config = model(Configuration::class);
}
/**
* GET /api/v1/late-slip-logs
* Get paginated late slip logs with optional filters
*/
public function index()
{
$defaultYear = (string) ($this->config->getConfig('school_year') ?? '');
$defaultSem = (string) ($this->config->getConfig('semester') ?? '');
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $defaultYear));
$semester = trim((string) ($this->request->getGet('semester') ?? $defaultSem));
$q = trim((string) ($this->request->getGet('q') ?? ''));
$dateFrom = trim((string) ($this->request->getGet('date_from') ?? ''));
$dateTo = trim((string) ($this->request->getGet('date_to') ?? ''));
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(200, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
// Normalize dates to Y-m-d for filtering
$toDbDate = static function (?string $s): ?string {
$s = trim((string) $s);
if ($s === '') {
return null;
}
$ts = strtotime($s);
return $ts ? date('Y-m-d', $ts) : null;
};
$df = $toDbDate($dateFrom);
$dt = $toDbDate($dateTo);
try {
$query = $this->log->newQuery();
if ($schoolYear !== '') {
$query->where('school_year', $schoolYear);
}
if ($semester !== '') {
$query->where('semester', $semester);
}
if ($q !== '') {
$query->like('student_name', $q);
}
if ($df) {
$query->where('slip_date >=', $df);
}
if ($dt) {
$query->where('slip_date <=', $dt);
}
$result = $this->paginate($query->orderBy('id', 'DESC'), $page, $perPage);
return $this->success([
'logs' => $result['data'],
'pagination' => $result['pagination'],
'filters' => [
'school_year' => $schoolYear,
'semester' => $semester,
'q' => $q,
'date_from' => $dateFrom,
'date_to' => $dateTo,
],
], 'Late slip logs retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Late slip logs index error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve late slip logs', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/late-slip-logs/{id}
* Get a single late slip log by ID
*/
public function show($id = null)
{
$log = $this->log->find($id);
if (!$log) {
return $this->error('Late slip log not found', Response::HTTP_NOT_FOUND);
}
return $this->success($log, 'Late slip log retrieved successfully');
}
}
@@ -1,142 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Message;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\HttpFoundation\Response;
class MessageController extends BaseApiController
{
protected Message $message;
protected Configuration $config;
public function __construct()
{
parent::__construct();
$this->message = model(Message::class);
$this->config = model(Configuration::class);
}
public function index()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$type = $this->request->getGet('type');
$read = $this->request->getGet('read');
$query = $this->message->newQuery();
if ($type === 'sent') {
$query->where('sender_id', $user->id);
} else {
$query->where('recipient_id', $user->id);
}
if ($read !== null) {
$isRead = filter_var($read, FILTER_VALIDATE_BOOLEAN);
$query->where('read_status', $isRead ? 1 : 0);
}
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Messages retrieved successfully');
}
public function show($id = null)
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$message = $this->message->newQuery()
->where('id', $id)
->where(function ($query) use ($user) {
$query->where('sender_id', $user->id)
->orWhere('recipient_id', $user->id);
})
->first();
if (!$message) {
return $this->error('Message not found', Response::HTTP_NOT_FOUND);
}
if (($message->recipient_id ?? $message['recipient_id'] ?? null) === $user->id) {
$readStatus = $message->read_status ?? $message['read_status'] ?? null;
if ($readStatus === 0 || $readStatus === '0') {
$this->message->update($message->id ?? $message['id'], [
'read_status' => 1,
'read_datetime'=> Carbon::now('UTC')->format('Y-m-d H:i:s'),
]);
$message->read_status = 1;
}
}
$payload = $message instanceof Model ? $message->toArray() : (array) $message;
return $this->success($payload, 'Message retrieved successfully');
}
public function send()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'to' => 'required|integer',
'subject' => 'required|max_length[255]',
'body' => 'required',
];
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$schoolYear = $this->config->getConfig('school_year');
$semester = $this->config->getConfig('semester');
$maxMessageNumber = $this->message->newQuery()
->selectRaw('COALESCE(MAX(message_number), 0) AS message_number')
->first();
$messageNumber = (int) ($maxMessageNumber->message_number ?? $maxMessageNumber['message_number'] ?? 0) + 1;
$messageData = [
'sender_id' => $user->id,
'recipient_id' => (int) $payload['to'],
'subject' => $payload['subject'],
'message' => $payload['body'],
'sent_datetime' => Carbon::now('UTC')->format('Y-m-d H:i:s'),
'read_status' => 0,
'message_number'=> $messageNumber,
'priority' => $payload['priority'] ?? 'normal',
'status' => 'sent',
'semester' => $semester,
'school_year' => $schoolYear,
];
try {
$message = $this->message->create($messageData);
return $this->success($message->toArray(), 'Message sent successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'Message send error: ' . $e->getMessage());
return $this->respondError('Failed to send message', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,307 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Message;
use App\Models\StudentClass;
use App\Models\Student;
use App\Models\TeacherClass;
use App\Models\User;
use App\Models\UserRole;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
class MessagesController extends BaseApiController
{
protected Message $message;
protected UserRole $userRole;
protected TeacherClass $teacherClass;
protected StudentClass $studentClass;
protected Student $student;
protected User $user;
public function __construct()
{
parent::__construct();
$this->message = model(Message::class);
$this->userRole = model(UserRole::class);
$this->teacherClass = model(TeacherClass::class);
$this->studentClass = model(StudentClass::class);
$this->student = model(Student::class);
$this->user = model(User::class);
}
public function inbox()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$messages = $this->message->getInboxMessages((int) $user->id);
return $this->success($messages, 'Inbox messages retrieved');
}
public function sent()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$messages = $this->message->getSentMessages((int) $user->id);
return $this->success($messages, 'Sent messages retrieved');
}
public function drafts()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$messages = $this->message->getDraftMessages((int) $user->id);
return $this->success($messages, 'Draft messages retrieved');
}
public function trash()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$messages = $this->message->getTrashedMessages((int) $user->id);
return $this->success($messages, 'Trashed messages retrieved');
}
public function index()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$role = $this->resolveRoleName((int) $user->id);
return $this->success([
'role' => $role,
'receivedMessages' => $this->getReceivedMessages((int) $user->id),
], 'Message overview retrieved');
}
public function send()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
$recipientId = $payload['recipient_id'] ?? null;
if (!$recipientId && !empty($payload['recipient_role'])) {
$recipientId = $this->getRecipientId(['name' => $payload['recipient_role']]);
}
if (!$recipientId) {
return $this->respondError('Recipient not provided', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$attachmentPath = $this->storeAttachment();
$data = [
'sender_id' => (int) $user->id,
'recipient_id' => (int) $recipientId,
'subject' => $payload['subject'] ?? null,
'message' => $payload['message'] ?? null,
'sent_datetime' => Carbon::now('UTC')->format('Y-m-d H:i:s'),
'message_number'=> $this->generateMessageNumber(),
'priority' => $payload['priority'] ?? 'normal',
'attachment' => $attachmentPath,
'status' => $payload['status'] ?? 'sent',
'semester' => $payload['semester'] ?? null,
'school_year' => $payload['school_year'] ?? null,
];
$message = $this->message->create($data);
return $this->success($message->toArray(), 'Message sent successfully');
}
public function receive()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$messages = $this->getReceivedMessages((int) $user->id);
foreach ($messages as $message) {
$this->markAsRead((int) ($message['id'] ?? 0));
}
return $this->success($messages, 'Received messages retrieved');
}
/**
* GET /api/v1/messages/recipients/{type}
* Get recipients list (teachers or parents)
*/
public function getRecipients($type)
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$type = strtolower(trim((string) ($type ?? '')));
if (!in_array($type, ['teacher', 'parent'], true)) {
return $this->respondError('Unsupported recipient type. Must be "teacher" or "parent"', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$recipients = [];
$teacherClasses = $this->teacherClass->findAll();
if ($type === 'teacher') {
foreach ($teacherClasses as $teacher) {
$userRow = $this->user->find($teacher['teacher_id'] ?? null);
if (!$userRow) {
continue;
}
$recipients[(int) $teacher['teacher_id']] = [
'id' => (int) $teacher['teacher_id'],
'name' => trim(($userRow['firstname'] ?? '') . ' ' . ($userRow['lastname'] ?? '')),
];
}
} elseif ($type === 'parent') {
foreach ($teacherClasses as $class) {
$students = $this->studentClass->where('class_section_id', $class['class_section_id'] ?? null)->findAll();
foreach ($students as $student) {
$studentData = $this->student->find($student['student_id'] ?? null);
if (!$studentData) {
continue;
}
// Get first parent (primary parent)
if (!empty($studentData['parent_id'])) {
$firstParent = $this->user->find($studentData['parent_id']);
if ($firstParent) {
$parentName = trim(($firstParent['firstname'] ?? '') . ' ' . ($firstParent['lastname'] ?? ''));
if ($parentName) {
$recipients[(int) $studentData['parent_id']] = [
'id' => (int) $studentData['parent_id'],
'name' => $parentName,
];
}
}
}
// Get second parent if exists
// Check if secondparent_user_id exists in students table
if (!empty($studentData['secondparent_user_id'])) {
$secondParent = $this->user->find($studentData['secondparent_user_id']);
if ($secondParent) {
$parentName = trim(($secondParent['firstname'] ?? '') . ' ' . ($secondParent['lastname'] ?? ''));
if ($parentName) {
$recipients[(int) $studentData['secondparent_user_id']] = [
'id' => (int) $studentData['secondparent_user_id'],
'name' => $parentName,
];
}
}
} else {
// Fallback: check parents table for second parent
$parentRow = DB::table('parents')
->where('firstparent_id', $studentData['parent_id'] ?? 0)
->first();
if ($parentRow && !empty($parentRow->secondparent_user_id)) {
$secondParent = $this->user->find($parentRow->secondparent_user_id);
if ($secondParent) {
$parentName = trim(($secondParent['firstname'] ?? '') . ' ' . ($secondParent['lastname'] ?? ''));
if ($parentName) {
$recipients[(int) $parentRow->secondparent_user_id] = [
'id' => (int) $parentRow->secondparent_user_id,
'name' => $parentName,
];
}
}
}
}
}
}
}
return $this->success(array_values($recipients), 'Recipients retrieved successfully');
}
protected function resolveRoleName(int $userId): ?string
{
$role = $this->userRole->newQuery()
->select('roles.name')
->join('roles', 'roles.id', '=', 'user_roles.role_id')
->where('user_roles.user_id', $userId)
->first();
return $role['name'] ?? null;
}
protected function getReceivedMessages(int $userId): array
{
$rows = DB::table('messages')
->select('messages.*', 'users.firstname AS sender_name')
->join('users', 'messages.sender_id', '=', 'users.id')
->where('messages.recipient_id', $userId)
->orderBy('sent_datetime', 'DESC')
->get()
->toArray();
return array_map(fn($row) => (array) $row, $rows);
}
protected function markAsRead(int $messageId): void
{
if ($messageId <= 0) {
return;
}
$this->message->markAsRead($messageId);
}
protected function generateMessageNumber(): string
{
return 'MSG-' . Carbon::now('UTC')->format('YmdHis');
}
protected function getRecipientId(array $role): ?int
{
$roleName = strtolower($role['name'] ?? '');
return match ($roleName) {
'teacher' => 1,
'parent' => 2,
'admin' => 3,
'student' => 4,
'guest' => 5,
'administrator' => 6,
default => null,
};
}
protected function storeAttachment(): ?string
{
/** @var UploadedFile|null $file */
$file = $this->laravelRequest->file('attachment');
if (!$file || !$file->isValid()) {
return null;
}
return Storage::putFile('messages', $file);
}
}
@@ -1,413 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Message;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class MessagingController extends BaseApiController
{
protected Message $message;
protected User $user;
protected Configuration $config;
public function __construct()
{
parent::__construct();
$this->message = model(Message::class);
$this->user = model(User::class);
$this->config = model(Configuration::class);
}
public function conversations()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$userId = (int) $user->id;
$sql = <<<'SQL'
SELECT
CASE WHEN sender_id = ? THEN recipient_id ELSE sender_id END AS other_user_id,
MAX(sent_datetime) AS last_message_time,
SUM(CASE WHEN recipient_id = ? AND read_status = 0 THEN 1 ELSE 0 END) AS unread_count,
(
SELECT m2.message
FROM messages m2
WHERE (m2.sender_id = ? AND m2.recipient_id = CASE WHEN m1.sender_id = ? THEN m1.recipient_id ELSE m1.sender_id END)
OR (m2.recipient_id = ? AND m2.sender_id = CASE WHEN m1.sender_id = ? THEN m1.recipient_id ELSE m1.sender_id END)
ORDER BY m2.sent_datetime DESC
LIMIT 1
) AS last_message
FROM messages m1
WHERE (sender_id = ? OR recipient_id = ?)
AND (subject = '' OR subject IS NULL OR subject = 'chat')
GROUP BY other_user_id
ORDER BY last_message_time DESC
SQL;
$rows = DB::select($sql, array_fill(0, 8, $userId));
$conversations = [];
foreach ($rows as $row) {
$otherUser = $this->user->find($row->other_user_id);
if (!$otherUser) {
continue;
}
$conversations[] = [
'user_id' => $row->other_user_id,
'user_name' => trim(($otherUser['firstname'] ?? '') . ' ' . ($otherUser['lastname'] ?? '')),
'user_initials' => strtoupper(($otherUser['firstname'][0] ?? '') . ($otherUser['lastname'][0] ?? '')),
'last_message' => $row->last_message ?? '',
'last_message_time'=> $row->last_message_time ?? null,
'unread_count' => (int) ($row->unread_count ?? 0),
];
}
return $this->success($conversations, 'Conversations retrieved successfully');
}
public function conversation($userId = null)
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
if (!$userId) {
return $this->error('User ID is required', Response::HTTP_BAD_REQUEST);
}
$currentUserId = (int) $user->id;
$otherUserId = (int) $userId;
$otherUser = $this->user->find($otherUserId);
if (!$otherUser) {
return $this->error('User not found', Response::HTTP_NOT_FOUND);
}
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 50)));
$offset = ($page - 1) * $perPage;
$baseQuery = DB::table('messages')
->where(function ($query) use ($currentUserId, $otherUserId) {
$query->where('sender_id', $currentUserId)
->where('recipient_id', $otherUserId);
})
->orWhere(function ($query) use ($currentUserId, $otherUserId) {
$query->where('sender_id', $otherUserId)
->where('recipient_id', $currentUserId);
})
->whereRaw("(subject = '' OR subject IS NULL OR subject = 'chat')")
->orderBy('sent_datetime', 'ASC');
$total = (clone $baseQuery)->count();
$messages = (clone $baseQuery)->offset($offset)->limit($perPage)->get();
foreach ($messages as $message) {
if ($message->recipient_id === $currentUserId && ((int) $message->read_status) === 0) {
$this->message->update($message->id, [
'read_status' => 1,
'read_datetime'=> Carbon::now('UTC')->format('Y-m-d H:i:s'),
]);
}
}
$enriched = [];
foreach ($messages as $msg) {
$enriched[] = [
'id' => $msg->id,
'sender_id' => $msg->sender_id,
'recipient_id' => $msg->recipient_id,
'message' => $msg->message,
'sent_datetime' => $msg->sent_datetime,
'read_status' => $msg->read_status,
'read_datetime' => $msg->read_datetime,
'attachment' => $msg->attachment ?? null,
'is_sender' => $msg->sender_id === $currentUserId,
];
}
$payload = [
'messages' => $enriched,
'other_user' => [
'id' => $otherUser['id'],
'name' => trim(($otherUser['firstname'] ?? '') . ' ' . ($otherUser['lastname'] ?? '')),
'initials' => strtoupper(($otherUser['firstname'][0] ?? '') . ($otherUser['lastname'][0] ?? '')),
],
'pagination' => [
'current_page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => (int) ceil($total / $perPage),
],
];
return $this->success($payload, 'Messages retrieved successfully');
}
public function send()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'recipient_id' => 'required|integer',
'message' => 'required|max_length[5000]',
];
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$recipientId = (int) $payload['recipient_id'];
if ($recipientId === (int) $user->id) {
return $this->error('Cannot send message to yourself', Response::HTTP_BAD_REQUEST);
}
$recipient = $this->user->find($recipientId);
if (!$recipient) {
return $this->error('Recipient not found', Response::HTTP_NOT_FOUND);
}
$schoolYear = $this->config->getConfig('school_year') ?? date('Y');
$semester = $this->config->getConfig('semester') ?? 'Fall';
$maxMessageNumber = $this->message->newQuery()
->selectRaw('COALESCE(MAX(message_number), 0) AS message_number')
->first();
$messageNumber = (int) ($maxMessageNumber->message_number ?? $maxMessageNumber['message_number'] ?? 0) + 1;
$messageData = [
'sender_id' => (int) $user->id,
'recipient_id' => $recipientId,
'subject' => 'chat',
'message' => $payload['message'],
'sent_datetime' => Carbon::now('UTC')->format('Y-m-d H:i:s'),
'read_status' => 0,
'message_number'=> $messageNumber,
'priority' => 'normal',
'status' => 'sent',
'semester' => $semester,
'school_year' => $schoolYear,
'attachment' => $payload['attachment'] ?? null,
];
try {
$message = $this->message->create($messageData);
return $this->success([
'id' => $message->id,
'sender_id' => $message->sender_id,
'recipient_id' => $message->recipient_id,
'message' => $message->message,
'sent_datetime' => $message->sent_datetime,
'read_status' => $message->read_status,
'attachment' => $message->attachment,
'is_sender' => true,
], 'Message sent successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'Message send error: ' . $e->getMessage());
return $this->respondError('Failed to send message', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function markRead()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$currentUserId = (int) $user->id;
try {
if (!empty($payload['message_ids']) && is_array($payload['message_ids'])) {
$messageIds = array_map('intval', $payload['message_ids']);
$updated = $this->message->newQuery()
->whereIn('id', $messageIds)
->where('recipient_id', $currentUserId)
->where('read_status', 0)
->update([
'read_status' => 1,
'read_datetime'=> Carbon::now('UTC')->format('Y-m-d H:i:s'),
]);
return $this->success([
'updated_count' => $updated,
], 'Messages marked as read');
}
if (isset($payload['user_id'])) {
$senderId = (int) $payload['user_id'];
$updated = $this->message->newQuery()
->where('sender_id', $senderId)
->where('recipient_id', $currentUserId)
->where('read_status', 0)
->update([
'read_status' => 1,
'read_datetime'=> Carbon::now('UTC')->format('Y-m-d H:i:s'),
]);
return $this->success([
'updated_count' => $updated,
], 'Messages marked as read');
}
return $this->error('Either message_ids or user_id is required', Response::HTTP_BAD_REQUEST);
} catch (\Throwable $e) {
log_message('error', 'Mark read error: ' . $e->getMessage());
return $this->respondError('Failed to mark messages as read', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function unreadCount()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
try {
$count = $this->message->newQuery()
->where('recipient_id', (int) $user->id)
->where('read_status', 0)
->whereRaw("(subject = '' OR subject IS NULL OR subject = 'chat')")
->count();
return $this->success([
'unread_count' => $count,
], 'Unread count retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Unread count error: ' . $e->getMessage());
return $this->respondError('Failed to get unread count', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function searchUsers()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$query = trim((string) ($this->request->getGet('q') ?? ''));
if (strlen($query) < 2) {
return $this->error('Search query must be at least 2 characters', Response::HTTP_BAD_REQUEST);
}
$limit = min(20, max(1, (int) ($this->request->getGet('limit') ?? 10)));
try {
$users = $this->user->newQuery()
->where(function ($q) use ($query) {
$q->where('firstname', 'LIKE', "%{$query}%")
->orWhere('lastname', 'LIKE', "%{$query}%")
->orWhere('email', 'LIKE', "%{$query}%");
})
->where('id', '!=', (int) $user->id)
->where('status', 'active')
->limit($limit)
->get();
$results = [];
foreach ($users as $u) {
$results[] = [
'id' => $u->id,
'name' => trim(($u->firstname ?? '') . ' ' . ($u->lastname ?? '')),
'initials' => strtoupper(($u->firstname[0] ?? '') . ($u->lastname[0] ?? '')),
'email' => $u->email ?? '',
];
}
return $this->success($results, 'Users retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Search users error: ' . $e->getMessage());
return $this->respondError('Failed to search users', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function deleteMessage($id = null)
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
if (!$id) {
return $this->error('Message ID is required', Response::HTTP_BAD_REQUEST);
}
$message = $this->message->find($id);
if (!$message) {
return $this->error('Message not found', Response::HTTP_NOT_FOUND);
}
if ($message->sender_id !== (int) $user->id && $message->recipient_id !== (int) $user->id) {
return $this->error('Unauthorized to delete this message', Response::HTTP_FORBIDDEN);
}
$this->message->update($id, [
'status' => 'deleted',
]);
return $this->success(null, 'Message deleted successfully');
}
public function deleteConversation($userId = null)
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
if (!$userId) {
return $this->error('User ID is required', Response::HTTP_BAD_REQUEST);
}
$currentUserId = (int) $user->id;
$otherUserId = (int) $userId;
try {
$updated = $this->message->newQuery()
->where(function ($query) use ($currentUserId, $otherUserId) {
$query->where('sender_id', $currentUserId)
->where('recipient_id', $otherUserId);
})
->orWhere(function ($query) use ($currentUserId, $otherUserId) {
$query->where('sender_id', $otherUserId)
->where('recipient_id', $currentUserId);
})
->whereRaw("(subject = '' OR subject IS NULL OR subject = 'chat')")
->update(['status' => 'deleted']);
return $this->success([
'deleted_count' => $updated,
], 'Conversation deleted successfully');
} catch (\Throwable $e) {
log_message('error', 'Delete conversation error: ' . $e->getMessage());
return $this->respondError('Failed to delete conversation', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,318 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\MidtermExam;
use App\Models\StudentClass;
use App\Models\Student;
use App\Models\TeacherClass;
use App\Services\SemesterScoreService;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
class MidtermController extends BaseApiController
{
protected Configuration $config;
protected Student $student;
protected TeacherClass $teacherClass;
protected MidtermExam $midtermExam;
protected SemesterScoreService $semesterScoreService;
protected string $schoolYear;
protected string $semester;
public function __construct()
{
parent::__construct();
$this->config = model(Configuration::class);
$this->student = model(Student::class);
$this->teacherClass = model(TeacherClass::class);
$this->midtermExam = model(MidtermExam::class);
$this->semesterScoreService = app(SemesterScoreService::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
}
/**
* GET /api/v1/midterm
* Get midterm exam data for a class section
*/
public function index()
{
// Get class_section_id from POST -> GET -> session
$incoming = $this->request->getPost('class_section_id')
?? $this->request->getGet('class_section_id')
?? session()->get('class_section_id');
$classSectionId = (int) ($incoming ?: 0);
if ($classSectionId <= 0) {
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
}
// Persist for downstream use
session()->put('class_section_id', $classSectionId);
try {
$result = $this->getSavedScores($classSectionId, $this->semester, $this->schoolYear);
return $this->success([
'students' => $result['students'],
'semester' => $result['semester'],
'school_year' => $result['schoolYear'],
'class_section_id' => $classSectionId,
], 'Midterm exam data retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Midterm index error: ' . $e->getMessage());
return $this->respondError($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/midterm
* Update midterm exam scores (teacher endpoint)
*/
public function update()
{
$updatedBy = $this->getCurrentUserId();
if (!$updatedBy) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
$scores = $payload['final_score'] ?? null;
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
if (!is_array($scores)) {
return $this->respondError('No scores submitted.', Response::HTTP_BAD_REQUEST);
}
if ($classSectionId <= 0) {
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
}
try {
$this->saveExamScores('midterm_exam', $scores, $updatedBy, $classSectionId, $this->semester, $this->schoolYear);
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId, $this->semester, $this->schoolYear);
// Add semester and school_year to each student info for the service
foreach ($studentTeacherInfo as &$student) {
$student['semester'] = $this->semester;
$student['school_year'] = $this->schoolYear;
}
unset($student);
// Call the updateScoresForStudents method
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
} catch (RuntimeException $e) {
log_message('error', 'SemesterScoreService error: ' . $e->getMessage());
}
return $this->success([
'class_section_id' => $classSectionId,
'updated_count' => count($scores),
], 'Midterm exam scores updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Midterm update error: ' . $e->getMessage());
return $this->respondError('Failed to update midterm exam scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/midterm/management
* Update midterm exam scores (management endpoint)
*/
public function updateManagement()
{
$payload = $this->payloadData();
$scores = $payload['final_score'] ?? null;
$updatedBy = (int) ($payload['teacher_id'] ?? $this->getCurrentUserId() ?? 0);
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
if (!is_array($scores)) {
return $this->respondError('No scores submitted.', Response::HTTP_BAD_REQUEST);
}
if ($classSectionId <= 0) {
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
}
if ($updatedBy <= 0) {
return $this->respondError('Missing teacher ID.', Response::HTTP_BAD_REQUEST);
}
try {
$this->saveExamScores('midterm_exam', $scores, $updatedBy, $classSectionId, $this->semester, $this->schoolYear);
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId, $this->semester, $this->schoolYear);
// Add semester and school_year to each student info for the service
foreach ($studentTeacherInfo as &$student) {
$student['semester'] = $this->semester;
$student['school_year'] = $this->schoolYear;
}
unset($student);
// Call the updateScoresForStudents method
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
} catch (RuntimeException $e) {
log_message('error', 'SemesterScoreService error: ' . $e->getMessage());
}
// Return the same data structure as index for consistency
$result = $this->getSavedScores($classSectionId, $this->semester, $this->schoolYear);
return $this->success([
'students' => $result['students'],
'semester' => $result['semester'],
'school_year' => $result['schoolYear'],
'class_section_id' => $classSectionId,
'updated_count' => count($scores),
], 'Midterm exam scores updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Midterm updateManagement error: ' . $e->getMessage());
return $this->respondError('Failed to update midterm exam scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/midterm/management
* Get midterm exam data for management view
*/
public function showManagement()
{
// Get class_section_id from POST -> GET -> session
$incoming = $this->request->getPost('class_section_id')
?? $this->request->getGet('class_section_id')
?? session()->get('class_section_id');
$classSectionId = (int) ($incoming ?: 0);
if ($classSectionId <= 0) {
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
}
try {
// Load roster + saved scores for this class/term
$result = $this->getSavedScores($classSectionId, $this->semester, $this->schoolYear);
return $this->success([
'students' => $result['students'],
'semester' => $result['semester'],
'school_year' => $result['schoolYear'],
'class_section_id' => $classSectionId,
], 'Midterm exam management data retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Midterm showManagement error: ' . $e->getMessage());
return $this->respondError($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Save exam scores to the database
*/
private function saveExamScores(string $table, array $scores, int $updatedBy, int $classSectionId, string $semester, string $schoolYear): void
{
$now = utc_now();
foreach ($scores as $studentId => $data) {
if (!is_numeric($studentId) || !isset($data['score'])) {
continue;
}
$score = $data['score'];
$existing = DB::table($table)
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$dataToSave = [
'student_id' => (int) $studentId,
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'score' => is_numeric($score) ? (float) $score : null,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => $now,
];
if ($existing) {
DB::table($table)
->where('id', $existing->id)
->update($dataToSave);
} else {
$dataToSave['created_at'] = $now;
DB::table($table)->insert($dataToSave);
}
}
}
/**
* Get roster + saved midterm scores for a specific classSection/term.
*/
private function getSavedScores(int $classSectionId, string $semester, string $schoolYear): array
{
if ($classSectionId <= 0) {
throw new \InvalidArgumentException('Invalid class section id.');
}
session()->put('class_section_id', $classSectionId);
// Subquery: pick ONE midterm row per student (latest by id).
$latestMidtermSub = DB::table('midterm_exam')
->select('student_id', DB::raw('MAX(id) AS max_id'))
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->groupBy('student_id');
// Main query: roster for this class/term + LEFT JOIN the single midterm row
$rows = DB::table('students as s')
->select([
's.id AS student_id',
's.school_id',
's.firstname',
's.lastname',
'me.score',
])
->join('student_class as sc', function ($join) use ($classSectionId, $semester, $schoolYear) {
$join->on('sc.student_id', '=', 's.id')
->where('sc.class_section_id', '=', $classSectionId)
->where('sc.semester', '=', $semester)
->where('sc.school_year', '=', $schoolYear);
})
->leftJoinSub($latestMidtermSub, 'ml', function ($join) {
$join->on('ml.student_id', '=', 's.id');
})
->leftJoin('midterm_exam as me', 'me.id', '=', 'ml.max_id')
->distinct()
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->toArray();
// Final safety net: ensure one row per student_id
$unique = [];
foreach ($rows as $r) {
$rArray = (array) $r;
$unique[(int) $rArray['student_id']] = $rArray;
}
$students = array_values($unique);
return [
'students' => $students,
'semester' => $semester,
'schoolYear' => $schoolYear,
];
}
}
@@ -1,377 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\NavItem;
use App\Models\RoleNavItem;
use App\Models\Role;
use App\Services\NavbarService;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class NavBuilderController extends BaseApiController
{
protected NavItem $items;
protected RoleNavItem $maps;
protected NavbarService $service;
protected Role $role;
public function __construct()
{
parent::__construct();
$this->items = model(NavItem::class);
$this->maps = model(RoleNavItem::class);
$this->service = app(NavbarService::class);
$this->role = model(Role::class);
}
/**
* Check if user has admin access to nav-builder
*/
protected function ensureAdmin(): void
{
$user = $this->getCurrentUser();
if (!$user) {
throw new \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException('', 'Authentication required');
}
$roleNames = $user->roles ?? [];
if (empty($roleNames)) {
throw new \Symfony\Component\HttpKernel\Exception\ForbiddenHttpException('Access denied');
}
// Map role names -> ids
$roleIdRows = DB::table('roles')
->select('id')
->whereIn('name', $roleNames)
->get()
->toArray();
$roleIds = array_map('intval', array_column($roleIdRows, 'id'));
if (empty($roleIds)) {
throw new \Symfony\Component\HttpKernel\Exception\ForbiddenHttpException('Access denied');
}
// Is this route allowed for any of the user's roles?
$allowed = DB::table('role_nav_items AS rni')
->select('1')
->join('nav_items AS ni', 'ni.id', '=', 'rni.nav_item_id')
->where('ni.url', 'nav-builder')
->whereIn('rni.role_id', $roleIds)
->first();
if (!$allowed) {
throw new \Symfony\Component\HttpKernel\Exception\ForbiddenHttpException('Access denied');
}
}
/**
* GET /api/v1/nav-builder/data
* Get navigation builder data (items, roles, parent options)
*/
public function data()
{
try {
$this->ensureAdmin();
$payload = $this->buildNavPayload();
return $this->success($payload, 'Navigation data retrieved successfully');
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
return $this->respondError($e->getMessage(), $e->getStatusCode());
} catch (\Throwable $e) {
log_message('error', 'Nav builder data error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve navigation data', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/nav-builder
* Save or update a navigation item
*/
public function save()
{
try {
$this->ensureAdmin();
$payload = $this->payloadData();
$id = (int) ($payload['id'] ?? 0);
// Accept either "menu_parent_id" or "parent_id" from the form
$menuParentRaw = $payload['menu_parent_id'] ?? $payload['parent_id'] ?? null;
$menuParentId = ($menuParentRaw === '' || $menuParentRaw === null) ? null : (int) $menuParentRaw;
if ($id && $menuParentId === $id) {
// item cannot be its own parent
$menuParentId = null;
}
// Validate parent exists (optional but helpful)
if ($menuParentId !== null) {
$parent = $this->items->find($menuParentId);
if (!$parent) {
return $this->respondError('Selected parent does not exist.', Response::HTTP_BAD_REQUEST);
}
}
$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' => (int) (($payload['is_enabled'] ?? false) ? 1 : 0),
];
// Save (force insert to return ID)
if ($id) {
$this->items->update($id, $data);
} else {
$id = (int) $this->items->insert($data, true); // ensure insertID is returned
}
// Roles
$roleIds = array_values(array_unique(array_filter(
array_map('intval', (array) ($payload['roles'] ?? [])),
fn ($v) => $v > 0
)));
$this->maps->where('nav_item_id', $id)->delete();
foreach ($roleIds as $rid) {
$this->maps->insert(['role_id' => $rid, 'nav_item_id' => $id]);
}
$this->service->clearCache();
return $this->success([
'id' => $id,
'message' => 'Menu saved successfully',
], 'Menu saved successfully');
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
return $this->respondError($e->getMessage(), $e->getStatusCode());
} catch (\Throwable $e) {
log_message('error', 'Nav builder save error: ' . $e->getMessage());
return $this->respondError('Failed to save menu item: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* DELETE /api/v1/nav-builder/{id}
* Delete a navigation item
*/
public function delete($id)
{
try {
$this->ensureAdmin();
$id = (int) $id;
$this->items->delete($id); // children handled by FK (SET NULL on parent)
$this->service->clearCache();
return $this->success([
'id' => $id,
'message' => 'Menu item deleted successfully',
], 'Menu item deleted successfully');
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
return $this->respondError($e->getMessage(), $e->getStatusCode());
} catch (\Throwable $e) {
log_message('error', 'Nav builder delete error: ' . $e->getMessage());
return $this->respondError('Failed to delete menu item: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/nav-builder/reorder
* Reorder navigation items
*/
public function reorder()
{
try {
$this->ensureAdmin();
$payload = $this->payloadData();
$orders = $payload['orders'] ?? [];
foreach ($orders as $id => $order) {
$this->items->update((int) $id, ['sort_order' => (int) $order]);
}
$this->service->clearCache();
return $this->success(['ok' => true], 'Navigation items reordered successfully');
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
return $this->respondError($e->getMessage(), $e->getStatusCode());
} catch (\Throwable $e) {
log_message('error', 'Nav builder reorder error: ' . $e->getMessage());
return $this->respondError('Failed to reorder navigation items: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Get distinct roles
*/
protected function distinctRoles(): array
{
return DB::table('roles')
->select('id', 'name')
->orderBy('name')
->get()
->toArray();
}
/**
* Build navigation payload
*/
private function buildNavPayload(): array
{
$all = $this->items
->orderBy('menu_parent_id', 'ASC')
->orderBy('label', 'ASC')
->orderBy('id', 'ASC')
->findAll();
$byId = [];
foreach ($all as $a) {
$a['children'] = [];
$byId[$a['id']] = $a;
}
$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 = [];
$roleRows = $this->maps
->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', 'left')
->findAll();
foreach ($roleRows 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;
}
}
$flattened = $this->flattenTreeForResponse($tree, $roleAssignments);
$parentOptions = array_map(static function ($row) {
return [
'id' => (int) ($row['id'] ?? 0),
'label' => (string) ($row['label'] ?? ''),
];
}, $flatAlpha);
$roles = array_map(static function ($role) {
return [
'id' => (int) ($role['id'] ?? 0),
'name' => (string) ($role['name'] ?? ''),
];
}, $this->distinctRoles());
return [
'items' => $flattened,
'roles' => $roles,
'parentOptions' => $parentOptions,
];
}
/**
* Case-insensitive, natural sort; ignores leading punctuation & articles ("a/an/the")
*/
private function labelKey(string $s): string
{
$s = trim(preg_replace('/\s+/', ' ', $s));
$s = preg_replace('/^[^[:alnum:]]+/u', '', $s); // strip leading punctuation
$s = preg_replace('/^(?i)(the|an|a)\s+/', '', $s); // drop leading articles
return mb_strtolower($s, 'UTF-8');
}
/**
* Sort tree alphabetically
*/
private function sortTreeAlpha(array &$nodes): void
{
usort($nodes, fn($a, $b) => strnatcasecmp(
$this->labelKey($a['label'] ?? ''),
$this->labelKey($b['label'] ?? '')
));
foreach ($nodes as &$n) {
if (!empty($n['children'])) {
$this->sortTreeAlpha($n['children']);
}
}
unset($n);
}
/**
* Flatten tree for response
*/
private function flattenTreeForResponse(array $nodes, array $roleAssignments, ?string $parentLabel = null, int $depth = 0, array &$rows = []): array
{
foreach ($nodes as $node) {
$raw = $node;
unset($raw['children']);
$navId = (int) ($raw['id'] ?? 0);
$roles = $roleAssignments[$navId] ?? ['ids' => [], 'names' => []];
$rows[] = [
'id' => $navId,
'label' => (string) ($raw['label'] ?? ''),
'url' => $raw['url'] ?? null,
'parent_label' => $parentLabel ?? '—',
'parent_id' => isset($raw['menu_parent_id']) && (int) $raw['menu_parent_id'] !== 0
? (int) $raw['menu_parent_id']
: null,
'order' => (int) ($raw['sort_order'] ?? 0),
'enabled' => (int) ($raw['is_enabled'] ?? 0) === 1,
'target' => $raw['target'] ?? null,
'depth' => $depth,
'roles' => [
'ids' => array_values(array_unique($roles['ids'] ?? [])),
'names' => array_values(array_unique($roles['names'] ?? [])),
],
'raw' => $raw,
];
if (!empty($node['children'])) {
$this->flattenTreeForResponse(
$node['children'],
$roleAssignments,
(string) ($raw['label'] ?? ''),
$depth + 1,
$rows
);
}
}
return $rows;
}
}
@@ -1,104 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Notification;
use App\Models\UserNotification;
use Carbon\Carbon;
use Symfony\Component\HttpFoundation\Response;
class NotificationController extends BaseApiController
{
protected Notification $notification;
protected UserNotification $userNotification;
public function __construct()
{
parent::__construct();
$this->notification = model(Notification::class);
$this->userNotification = model(UserNotification::class);
}
public function index()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$read = $this->request->getGet('read');
$query = $this->userNotification->newQuery()
->select([
'notifications.*',
'user_notifications.is_read AS is_read',
'user_notifications.read_at',
])
->join('notifications', 'notifications.id', '=', 'user_notifications.notification_id')
->where('user_notifications.user_id', (int) $user->id);
if ($read !== null) {
$isRead = filter_var($read, FILTER_VALIDATE_BOOLEAN);
$query->where('user_notifications.is_read', $isRead ? 1 : 0);
}
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Notifications retrieved successfully');
}
public function show($id = null)
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$notification = $this->userNotification->newQuery()
->select([
'notifications.*',
'user_notifications.is_read AS is_read',
'user_notifications.read_at',
])
->join('notifications', 'notifications.id', '=', 'user_notifications.notification_id')
->where('user_notifications.user_id', (int) $user->id)
->where('user_notifications.notification_id', $id)
->first();
if (!$notification) {
return $this->error('Notification not found', Response::HTTP_NOT_FOUND);
}
return $this->success($notification, 'Notification retrieved successfully');
}
public function markRead($id = null)
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$userNotification = $this->userNotification->newQuery()
->where('user_id', (int) $user->id)
->where('notification_id', $id)
->first();
if (!$userNotification) {
return $this->error('Notification not found', Response::HTTP_NOT_FOUND);
}
try {
$this->userNotification->update($userNotification->id, [
'is_read' => 1,
'read_at' => Carbon::now('UTC')->format('Y-m-d H:i:s'),
]);
return $this->success(null, 'Notification marked as read');
} catch (\Throwable $e) {
log_message('error', 'Notification mark read error: ' . (string) $e->getMessage());
return $this->respondError('Failed to mark notification as read', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,278 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Notification;
use App\Models\UserNotification;
use App\Models\User;
use App\Services\EmailService;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class NotificationManagementController extends BaseApiController
{
protected Notification $notification;
protected UserNotification $userNotification;
protected User $user;
protected EmailService $emailService;
public function __construct()
{
parent::__construct();
$this->notification = model(Notification::class);
$this->userNotification = model(UserNotification::class);
$this->user = model(User::class);
$this->emailService = app(EmailService::class);
}
/**
* GET /api/v1/notifications/active
* Get active (non-deleted, non-expired) notifications
*/
public function activeNotificationsData()
{
try {
$targetGroup = $this->request->getGet('target_group');
$payload = $this->buildActiveNotificationsPayload($targetGroup);
return $this->success($payload, 'Active notifications retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Active notifications data error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve active notifications', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/notifications/deleted
* Get soft-deleted notifications
*/
public function deletedNotificationsData()
{
try {
$payload = $this->buildDeletedNotificationsPayload();
return $this->success($payload, 'Deleted notifications retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Deleted notifications data error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve deleted notifications', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/notifications/send
* Create and send notifications to a target group
*/
public function send()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
$title = trim((string) ($payload['title'] ?? ''));
$message = trim((string) ($payload['message'] ?? ''));
$group = trim((string) ($payload['target_group'] ?? ''));
$channels = (array) ($payload['channels'] ?? []);
if (empty($title) || empty($message) || empty($group) || empty($channels)) {
return $this->respondError('Title, message, target_group, and channels are required', Response::HTTP_BAD_REQUEST);
}
try {
// Create notification
$notifId = $this->notification->insert([
'title' => $title,
'message' => $message,
'target_group' => $group,
'delivery_channels' => implode(',', $channels),
'created_at' => utc_now(),
], true);
if (!$notifId) {
return $this->respondError('Failed to create notification', Response::HTTP_INTERNAL_SERVER_ERROR);
}
// Get users by role (target_group)
$users = $this->user->getUsersByRole($group);
$sentCount = 0;
$emailCount = 0;
$smsCount = 0;
foreach ($users as $user) {
// Create user notification record
$this->userNotification->insert([
'notification_id' => $notifId,
'user_id' => (int) $user['id'],
'is_read' => 0,
]);
// Send via email if requested
if (in_array('email', $channels) && !empty($user['email'])) {
try {
$this->sendEmail($user['email'], $title, $message);
$emailCount++;
} catch (\Throwable $e) {
log_message('error', 'Failed to send email to ' . $user['email'] . ': ' . $e->getMessage());
}
}
// Send via SMS if requested
if (in_array('sms', $channels) && !empty($user['cellphone'])) {
try {
$this->sendSMS($user['cellphone'], $message);
$smsCount++;
} catch (\Throwable $e) {
log_message('error', 'Failed to send SMS to ' . $user['cellphone'] . ': ' . $e->getMessage());
}
}
$sentCount++;
}
return $this->success([
'notification_id' => $notifId,
'users_notified' => $sentCount,
'emails_sent' => $emailCount,
'sms_sent' => $smsCount,
], 'Notification sent successfully');
} catch (\Throwable $e) {
log_message('error', 'Notification send error: ' . $e->getMessage());
return $this->respondError('Failed to send notification: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/notifications/{id}/restore
* Restore a soft-deleted notification
*/
public function restore($id)
{
try {
$id = (int) $id;
// Check if notification exists and is deleted
$notification = $this->notification->withDeleted()->find($id);
if (!$notification) {
return $this->respondError('Notification not found', Response::HTTP_NOT_FOUND);
}
$restored = $this->notification->restoreNotification($id);
// Check if update was successful (update returns affected rows or false)
if ($restored !== false) {
return $this->success([
'id' => $id,
], 'Notification restored successfully');
}
return $this->respondError('Failed to restore notification', Response::HTTP_BAD_REQUEST);
} catch (\Throwable $e) {
log_message('error', 'Notification restore error: ' . $e->getMessage());
return $this->respondError('Failed to restore notification: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Build active notifications payload
*/
private function buildActiveNotificationsPayload(?string $targetGroup): array
{
$targetGroup = $targetGroup !== null ? trim($targetGroup) : null;
if ($targetGroup === '') {
$targetGroup = null;
}
$rows = $this->notification->getActiveNotifications($targetGroup);
if (!is_array($rows)) {
$rows = [];
}
$now = time();
$notifications = array_map(static function ($row) use ($now) {
if (!is_array($row)) {
return [];
}
$expiresAt = $row['expires_at'] ?? null;
$expiryTs = $expiresAt ? strtotime((string) $expiresAt) : false;
$isExpired = $expiryTs !== false && $expiryTs <= $now;
return [
'id' => $row['id'] ?? null,
'title' => $row['title'] ?? null,
'message' => $row['message'] ?? null,
'priority' => $row['priority'] ?? null,
'scheduled_at' => $row['scheduled_at'] ?? null,
'expires_at' => $expiresAt,
'created_at' => $row['created_at'] ?? null,
'target_group' => $row['target_group'] ?? null,
'isExpired' => $isExpired,
];
}, $rows);
return [
'notifications' => $notifications,
];
}
/**
* Build deleted notifications payload
*/
private function buildDeletedNotificationsPayload(): array
{
$rows = $this->notification->getDeletedNotifications();
if (!is_array($rows)) {
$rows = [];
}
$notifications = array_map(static function ($row) {
if (!is_array($row)) {
return [];
}
return [
'id' => $row['id'] ?? null,
'title' => $row['title'] ?? null,
'message' => $row['message'] ?? null,
'priority' => $row['priority'] ?? null,
'scheduled_at' => $row['scheduled_at'] ?? null,
'expires_at' => $row['expires_at'] ?? null,
'deleted_at' => $row['deleted_at'] ?? null,
];
}, $rows);
return [
'notifications' => $notifications,
];
}
/**
* Send email notification
*/
protected function sendEmail(string $to, string $subject, string $message): void
{
try {
$this->emailService->setTo($to);
$this->emailService->setSubject($subject);
$this->emailService->setMessage($message);
$this->emailService->send();
} catch (\Throwable $e) {
log_message('error', 'Email send error: ' . $e->getMessage());
throw $e;
}
}
/**
* Send SMS notification (placeholder - logs for now)
*/
protected function sendSMS(string $phone, string $message): void
{
log_message('info', "SMS to {$phone}: {$message}");
// TODO: Implement actual SMS sending via SendSMSController or SMS service
}
}
@@ -1,8 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
class NotificationsController extends NotificationController
{
// Alias controller for route naming consistency
}
-156
View File
@@ -1,156 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\ContactUs;
use App\Services\EmailService;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class PageController extends BaseApiController
{
protected ContactUs $contactUs;
protected EmailService $emailService;
public function __construct()
{
parent::__construct();
$this->contactUs = model(ContactUs::class);
$this->emailService = app(EmailService::class);
}
/**
* POST /api/v1/pages/contact
* Submit contact form
*/
public function submitContact()
{
$payload = $this->payloadData();
if (empty($payload)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'email' => 'required|email',
'message' => 'required|min_length[10]',
];
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$email = strtolower(trim((string) ($payload['email'] ?? '')));
$message = trim((string) ($payload['message'] ?? ''));
try {
// Save the message to the database using ContactUs
// Note: ContactUs expects different fields, so we'll use DB directly
// or adapt to the model structure if needed
$contactData = [
'email' => $email,
'message' => $message,
'created_at' => utc_now(),
'updated_at' => utc_now(),
];
// Try using model first, fallback to DB if model structure differs
$saved = false;
try {
// Check if ContactUs supports email/message fields directly
// ContactUs has different fields (sender_id, receiver_id, etc.), so use DB directly
$saved = DB::table('contactus')->insert($contactData);
} catch (\Throwable $e) {
log_message('error', 'Failed to save contact data: ' . $e->getMessage());
return $this->respondError('Failed to save message. Please try again.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
if (!$saved) {
log_message('error', 'Failed to save contact data');
return $this->respondError('Failed to save message. Please try again.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
// Prepare the email content
$recipient = 'alrahma.isgl@gmail.com';
$subject = 'Contact Us Form Submission';
$emailMessage = "You have received a new message from {$email}:\n\n{$message}";
// Send email using EmailService
$emailSent = false;
try {
$this->emailService->setTo($recipient);
$this->emailService->setFrom(
config('mail.from.address', 'no-reply@alrahmaisgl.org'),
config('mail.from.name', 'Alrahma Team')
);
$this->emailService->setSubject($subject);
$this->emailService->setMessage($emailMessage);
$emailSent = $this->emailService->send();
} catch (\Throwable $e) {
log_message('error', 'Failed to send email: ' . $e->getMessage());
}
if (!$emailSent) {
log_message('error', 'Failed to send email.');
// Don't fail the request if email fails, just log it
}
return $this->success([
'email' => $email,
'email_sent' => $emailSent,
], 'Message sent successfully');
} catch (\Throwable $e) {
log_message('error', 'Contact form error: ' . $e->getMessage());
return $this->respondError('An unexpected error occurred. Please try again.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/pages/privacy
* Get privacy policy HTML content
*/
public function privacyPolicy()
{
return $this->renderStaticFile('privacy_policy.html', 'privacy_policy');
}
/**
* GET /api/v1/pages/terms
* Get terms of service HTML content
*/
public function termsOfService()
{
return $this->renderStaticFile('terms_of_service.html', 'terms_of_service');
}
/**
* GET /api/v1/pages/help
* Get help center HTML content
*/
public function helpCenter()
{
return $this->renderStaticFile('help_center.html', 'help_center');
}
protected function renderStaticFile(string $filename, string $type)
{
$path = base_path('public/html/' . $filename);
if (!is_readable($path)) {
return $this->respondError('Failed to retrieve ' . str_replace('_', ' ', $type), Response::HTTP_INTERNAL_SERVER_ERROR);
}
try {
$content = file_get_contents($path);
} catch (\Throwable $e) {
log_message('error', ucfirst($type) . ' load error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve ' . str_replace('_', ' ', $type), Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success([
'content' => $content,
'type' => $type,
], ucfirst(str_replace('_', ' ', $type)) . ' retrieved successfully');
}
}
@@ -1,145 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\ParentAttendanceReport;
use App\Models\Student;
use Carbon\Carbon;
use Symfony\Component\HttpFoundation\Response;
class ParentAttendanceReportController extends BaseApiController
{
protected ParentAttendanceReport $report;
protected Student $student;
protected Configuration $config;
protected string $schoolYear;
public function __construct()
{
parent::__construct();
$this->report = model(ParentAttendanceReport::class);
$this->student = model(Student::class);
$this->config = model(Configuration::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
}
public function submit()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'student_ids' => 'required|is_array',
'date' => 'required|date_format:Y-m-d',
'type' => 'required|in:absent,late,early_dismissal',
];
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$studentIds = (array) $payload['student_ids'];
$date = $payload['date'];
$type = $payload['type'];
$arrivalTime = $payload['arrival_time'] ?? null;
$dismissTime = $payload['dismiss_time'] ?? null;
$reason = $payload['reason'] ?? null;
$reports = [];
try {
foreach ($studentIds as $studentId) {
$reportData = [
'parent_id' => (int) $user->id,
'student_id' => (int) $studentId,
'report_date' => $date,
'type' => $type,
'arrival_time'=> $arrivalTime,
'dismiss_time'=> $dismissTime,
'reason' => $reason,
'school_year' => $this->schoolYear,
];
$report = $this->report->create($reportData);
$reports[] = $report->toArray();
}
return $this->success($reports, 'Attendance reports submitted successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'Attendance report submission error: ' . $e->getMessage());
return $this->respondError('Failed to submit attendance report', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function index()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$query = $this->report->newQuery()
->where('parent_id', (int) $user->id)
->where('school_year', $schoolYear)
->orderBy('report_date', 'DESC');
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Attendance reports retrieved successfully');
}
public function update($id = null)
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$report = $this->report->find($id);
if (!$report) {
return $this->error('Attendance report not found', Response::HTTP_NOT_FOUND);
}
if ((int) ($report['parent_id'] ?? 0) !== (int) $user->id) {
return $this->error('Access denied', Response::HTTP_FORBIDDEN);
}
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$allowedFields = ['type', 'arrival_time', 'dismiss_time', 'reason'];
$updateData = [];
foreach ($allowedFields as $field) {
if (array_key_exists($field, $payload)) {
$updateData[$field] = $payload[$field];
}
}
if (empty($updateData)) {
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
}
try {
$this->report->update($id, $updateData);
$updatedReport = $this->report->find($id);
return $this->success($updatedReport, 'Attendance report updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Attendance report update error: ' . $e->getMessage());
return $this->respondError('Failed to update attendance report', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,767 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\AuthorizedUser;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\EmergencyContact;
use App\Models\Enrollment;
use App\Models\EventCharges;
use App\Models\Event;
use App\Models\Invoice;
use App\Models\ParentAttendanceReport;
use App\Models\PromotionQueue;
use App\Models\Refund;
use App\Models\StudentAllergy;
use App\Models\StudentClass;
use App\Models\StudentMedicalCondition;
use App\Models\Student;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
class ParentController extends BaseApiController
{
protected User $user;
protected Student $student;
protected StudentClass $studentClass;
protected Enrollment $enrollment;
protected Configuration $config;
protected EventCharges $charges;
protected Event $event;
protected StudentMedicalCondition $medicalCondition;
protected StudentAllergy $allergy;
protected Refund $refund;
protected Invoice $invoice;
protected ClassSection $classSection;
protected PromotionQueue $promotion;
protected EmergencyContact $emergencyContact;
protected AuthorizedUser $authorizedUser;
public function __construct()
{
parent::__construct();
$this->user = model(User::class);
$this->student = model(Student::class);
$this->studentClass = model(StudentClass::class);
$this->enrollment = model(Enrollment::class);
$this->config = model(Configuration::class);
$this->charges = model(EventCharges::class);
$this->event = model(Event::class);
$this->medicalCondition = model(StudentMedicalCondition::class);
$this->allergy = model(StudentAllergy::class);
$this->refund = model(Refund::class);
$this->invoice = model(Invoice::class);
$this->classSection = model(ClassSection::class);
$this->promotion = model(PromotionQueue::class);
$this->emergencyContact = model(EmergencyContact::class);
$this->authorizedUser = model(AuthorizedUser::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$query = $this->user->newQuery()
->select('users.*')
->join('user_roles', 'user_roles.user_id', '=', 'users.id')
->join('roles', 'roles.id', '=', 'user_roles.role_id')
->where('roles.name', 'parent')
->groupBy('users.id');
$result = $this->paginate($query, $page, $perPage);
foreach ($result['data'] as &$parent) {
unset($parent['password'], $parent['token']);
}
return $this->success($result, 'Parents retrieved successfully');
}
public function enrollClassesData($id)
{
try {
$parentId = (int) $id;
$parent = $this->user->find($parentId);
if (!$parent) {
return $this->error('Parent not found', Response::HTTP_NOT_FOUND);
}
$schoolYear = (string) ($this->config->getConfig('school_year'));
$semester = (string) ($this->config->getConfig('semester'));
$lastDayOfRegistration = (string) ($this->config->getConfig('enrollment_deadline'));
$schoolStartDate = (string) ($this->config->getConfig('school_start_date'));
$withdrawalDeadline = (string) ($this->config->getConfig('refund_deadline'));
$selectedYear = (string) ($this->request->getGet('school_year') ?? $schoolYear);
$isEditable = ($selectedYear === $schoolYear);
$students = DB::table('students')
->where('parent_id', $parentId)
->get()
->map(fn($row) => (array) $row)
->toArray();
$statusMap = [
'admission under review' => 'admission under review',
'payment pending' => 'payment pending',
'enrolled' => 'enrolled',
'withdraw under review' => 'withdraw under review',
'refund pending' => 'refund pending',
'withdrawn' => 'withdrawn',
'denied' => 'denied',
'waitlist' => 'waitlist',
];
foreach ($students as &$student) {
$studentId = (int) ($student['id'] ?? 0);
$classRow = DB::table('student_class')
->select('class_section_id')
->where('student_id', $studentId)
->where('school_year', $selectedYear)
->first();
$classSectionId = $classRow->class_section_id ?? null;
$student['class_section'] = $classSectionId
? ($this->classSection->getClassSectionNameBySectionId($classSectionId) ?? 'Class not Assigned')
: 'Class not Assigned';
$enr = DB::table('enrollments')
->select('enrollment_status', 'admission_status', 'is_withdrawn')
->where('student_id', $studentId)
->where('school_year', $selectedYear)
->orderByDesc('updated_at')
->first();
if ($enr && isset($enr->admission_status)) {
$student['admission_status'] = $enr->admission_status;
if ($enr->admission_status === 'denied') {
$student['enrollment_status'] = 'denied';
} else {
$est = $enr->enrollment_status ?? '';
$student['enrollment_status'] = $statusMap[$est] ?? 'not enrolled';
}
} else {
$student['admission_status'] = null;
$student['enrollment_status'] = 'not enrolled';
}
$student['disable_enroll'] = in_array(
$student['enrollment_status'],
['admission under review', 'payment pending', 'enrolled', 'withdraw under review', 'denied'],
true
);
}
unset($student);
$schoolYears = DB::table('enrollments')
->select('school_year')
->distinct()
->orderByDesc('school_year')
->get()
->map(fn($row) => (array) $row)
->toArray();
if (empty($schoolYears)) {
$schoolYears[] = ['school_year' => $schoolYear];
}
return $this->success([
'students' => $students,
'schoolYears' => $schoolYears,
'selectedYear' => $selectedYear,
'isEditable' => $isEditable,
'withdrawalDeadline' => $withdrawalDeadline,
'lastDayOfRegistration'=> $lastDayOfRegistration,
'schoolStartDate' => $schoolStartDate,
], 'Enroll classes data');
} catch (\Throwable $e) {
log_message('error', 'enrollClassesData error: ' . $e->getMessage());
return $this->error('Server error', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function updateEnrollments($id)
{
$parentId = (int) $id;
$parent = $this->user->find($parentId);
if (!$parent) {
return $this->error('Parent not found', Response::HTTP_NOT_FOUND);
}
$payload = $this->payloadData();
$enroll = array_map('intval', (array) ($payload['enroll'] ?? []));
$withdraw = array_map('intval', (array) ($payload['withdraw'] ?? []));
if (empty($enroll) && empty($withdraw)) {
return $this->error('No students selected for enrollment or withdrawal.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$result = $this->processEnrollmentUpdate($parentId, $enroll, $withdraw);
return $this->success($result, 'Enrollment updates processed');
}
public function emergencyContacts($id)
{
$parentId = (int) $id;
$rows = $this->emergencyContact->getEmergencyContactsByParentId($parentId);
return $this->success($rows, 'Emergency contacts retrieved');
}
public function saveEmergencyContact($id)
{
$parentId = (int) $id;
$data = $this->payloadData();
$payload = [
'parent_id' => $parentId,
'emergency_contact_name' => trim((string) ($data['emergency_contact_name'] ?? '')),
'cellphone' => trim((string) ($data['cellphone'] ?? '')),
'email' => trim((string) ($data['email'] ?? '')) ?: null,
'relation' => trim((string) ($data['relation'] ?? '')) ?: null,
'semester' => (string) $this->config->getConfig('semester'),
'school_year' => (string) $this->config->getConfig('school_year'),
];
if ($payload['emergency_contact_name'] === '' || $payload['cellphone'] === '') {
return $this->error('Name and cellphone are required', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$idToUpdate = isset($data['id']) ? (int) $data['id'] : 0;
if ($idToUpdate > 0) {
$row = $this->emergencyContact->find($idToUpdate);
if (!$row || (int) $row['parent_id'] !== $parentId) {
return $this->error('Contact not found', Response::HTTP_NOT_FOUND);
}
$this->emergencyContact->update($idToUpdate, $payload);
return $this->success(['id' => $idToUpdate], 'Emergency contact updated');
}
$newId = $this->emergencyContact->insert($payload);
if (!$newId) {
return $this->error('Failed to save emergency contact', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success(['id' => $newId], 'Emergency contact created');
}
public function authorizedUsers($id)
{
$parentId = (int) $id;
$rows = $this->authorizedUser->newQuery()
->where('user_id', $parentId)
->orderByDesc('created_at')
->get()
->toArray();
return $this->success($rows, 'Authorized users retrieved');
}
public function addAuthorizedUser($id)
{
$parentId = (int) $id;
$data = $this->payloadData();
$payload = [
'user_id' => $parentId,
'firstname' => trim((string) ($data['firstname'] ?? '')),
'lastname' => trim((string) ($data['lastname'] ?? '')),
'email' => trim((string) ($data['email'] ?? '')),
'phone_number' => trim((string) ($data['phone_number'] ?? '')),
'relation_to_user'=> trim((string) ($data['relation_to_user'] ?? '')),
'status' => 'Pending',
];
if ($payload['firstname'] === '' || $payload['lastname'] === '' || $payload['email'] === '' || $payload['phone_number'] === '') {
return $this->error('Missing required fields', Response::HTTP_UNPROCESSABLE_ENTITY);
}
if (!$this->authorizedUser->insert($payload)) {
return $this->error('Failed to create authorized user', Response::HTTP_INTERNAL_SERVER_ERROR, $this->authorizedUser->errors());
}
return $this->success([], 'Authorized user request created');
}
public function createStudent($id)
{
$parentId = (int) $id;
$data = $this->payloadData();
$allowed = [
'firstname', 'lastname', 'dob', 'age', 'gender', 'registration_grade', 'photo_consent', 'rfid_tag',
];
$payload = array_intersect_key($data, array_flip($allowed));
$payload['parent_id'] = $parentId;
$payload['is_new'] = isset($data['is_new']) ? (int) $data['is_new'] : 1;
$payload['school_year'] = (string) $this->config->getConfig('school_year');
$payload['semester'] = (string) $this->config->getConfig('semester');
$payload['registration_date'] = Carbon::now('UTC')->format('Y-m-d');
if (empty($payload['firstname']) || empty($payload['lastname']) || empty($payload['gender'])) {
return $this->error('firstname, lastname, and gender are required', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$student = $this->student->create($payload);
if (!$student) {
return $this->error('Failed to create student', Response::HTTP_INTERNAL_SERVER_ERROR, $this->student->errors());
}
return $this->success(['id' => $student->id], 'Student created');
}
public function updateStudent($id, $studentId)
{
$parentId = (int) $id;
$studentId = (int) $studentId;
$row = $this->student->find($studentId);
if (!$row || (int) $row['parent_id'] !== $parentId) {
return $this->error('Student not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
$allowed = ['firstname', 'lastname', 'dob', 'age', 'gender', 'registration_grade', 'photo_consent', 'rfid_tag'];
$payload = array_intersect_key($data, array_flip($allowed));
if (empty($payload)) {
return $this->error('No valid fields to update', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$ok = $this->student->update($studentId, $payload);
if (!$ok) {
return $this->error('Failed to update student', Response::HTTP_INTERNAL_SERVER_ERROR, $this->student->errors());
}
return $this->success([], 'Student updated');
}
public function deleteStudent($id, $studentId)
{
$parentId = (int) $id;
$studentId = (int) $studentId;
$row = $this->student->find($studentId);
if (!$row || (int) $row['parent_id'] !== $parentId) {
return $this->error('Student not found', Response::HTTP_NOT_FOUND);
}
$ok = $this->student->delete($studentId);
if (!$ok) {
return $this->error('Failed to delete student', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success([], 'Student deleted');
}
private function processEnrollmentUpdate(int $parentId, array $enroll, array $withdraw): array
{
$schoolYear = (string) $this->config->getConfig('school_year');
$semester = (string) $this->config->getConfig('semester');
$lastDayOfRegistration = (string) $this->config->getConfig('enrollment_deadline');
$today = Carbon::now('UTC')->format('Y-m-d');
if ($lastDayOfRegistration && $today > date('Y-m-d', strtotime($lastDayOfRegistration))) {
if (!empty($enroll)) {
return [
'enrolled' => [],
'withdrawn' => [],
'messages' => ['Enrollment deadline has passed. New enrollments are not allowed.'],
];
}
}
$enrolled = [];
$withdrawn = [];
$messages = [];
foreach ($enroll as $studentId) {
$studentId = (int) $studentId;
if ($studentId <= 0) {
continue;
}
$existing = $this->enrollment->newQuery()
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->first();
if ($existing) {
if ((int) ($existing['is_withdrawn'] ?? 0) === 1) {
$ok = $this->enrollment->update((int) $existing['id'], [
'is_withdrawn' => 0,
'withdrawal_date' => null,
'enrollment_status' => 'payment pending',
'updated_at' => Carbon::now('UTC')->toDateTimeString(),
]);
if ($ok) {
$enrolled[] = $studentId;
$messages[] = "Re-enrolled student {$studentId}.";
}
} else {
$messages[] = "Student {$studentId} already enrolled.";
}
} else {
$ok = $this->enrollment->insert([
'student_id' => $studentId,
'parent_id' => $parentId,
'school_year' => $schoolYear,
'semester' => $semester,
'enrollment_date' => $today,
'is_withdrawn' => 0,
'enrollment_status' => 'admission under review',
'admission_status' => 'pending',
'created_at' => Carbon::now('UTC')->toDateTimeString(),
]);
if ($ok) {
$enrolled[] = $studentId;
$messages[] = "Enrolled student {$studentId}.";
}
}
$this->applyPromotionAssignment($studentId, $schoolYear);
}
foreach ($withdraw as $studentId) {
$studentId = (int) $studentId;
if ($studentId <= 0) {
continue;
}
$enrollment = $this->enrollment->newQuery()
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->where('is_withdrawn', 0)
->first();
if ($enrollment) {
$ok = $this->enrollment->update((int) $enrollment['id'], [
'withdrawal_date' => $today,
'enrollment_status' => 'withdraw under review',
'updated_at' => Carbon::now('UTC')->toDateTimeString(),
]);
if ($ok) {
$withdrawn[] = $studentId;
$messages[] = "Withdrawn student {$studentId}.";
}
$invoice = $this->invoice->newQuery()
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderByDesc('created_at')
->first();
if ($invoice) {
$existingRefund = $this->refund->newQuery()
->where('parent_id', $parentId)
->where('invoice_id', $invoice['id'])
->where('school_year', $schoolYear)
->first();
if ($existingRefund) {
$this->refund->update((int) $existingRefund['id'], [
'reason' => 'Withdrawal under review for student ID ' . $studentId,
'note' => null,
'updated_by' => $this->getCurrentUserId(),
]);
} else {
$this->refund->insert([
'parent_id' => $parentId,
'invoice_id' => $invoice['id'],
'requested_at' => Carbon::now('UTC')->toDateTimeString(),
'school_year' => $schoolYear,
'status' => 'Pending review',
'reason' => 'Withdrawal under review for student ID ' . $studentId,
'request' => 'new',
'semester' => $semester,
'refund_paid_amount' => 0.0,
]);
}
}
}
}
return [
'enrolled' => $enrolled,
'withdrawn' => $withdrawn,
'messages' => $messages,
];
}
private function applyPromotionAssignment(int $studentId, string $year): void
{
try {
$row = $this->promotion->newQuery()
->where('student_id', $studentId)
->where('school_year_to', $year)
->first();
if (!$row) {
return;
}
$targetSectionId = (int) ($row['to_class_section_id'] ?? 0);
if ($targetSectionId <= 0) {
$base = $this->classSection->getBaseSectionByClassId((int) $row['to_class_id']);
if (!$base) {
return;
}
$targetSectionId = (int) ($base['class_section_id'] ?? 0);
}
if ($targetSectionId <= 0) {
return;
}
$semester = (string) $this->config->getConfig('semester');
$exists = $this->studentClass->newQuery()
->where('student_id', $studentId)
->where('school_year', $year)
->where('semester', $semester)
->first();
$payload = [
'student_id' => $studentId,
'class_section_id' => $targetSectionId,
'school_year' => $year,
'semester' => $semester,
'updated_by' => $this->getCurrentUserId(),
'updated_at' => Carbon::now('UTC')->toDateTimeString(),
];
if ($exists) {
$this->studentClass->update((int) $exists['id'], $payload);
} else {
$payload['created_at'] = Carbon::now('UTC')->toDateTimeString();
$this->studentClass->insert($payload);
}
$this->promotion->update((int) $row['id'], [
'status' => 'applied',
'updated_at' => Carbon::now('UTC')->toDateTimeString(),
'updated_by' => $this->getCurrentUserId(),
]);
} catch (\Throwable $e) {
log_message('error', 'applyPromotionAssignment (API) failed: ' . $e->getMessage());
}
}
public function show($id = null)
{
$parent = $this->user->newQuery()
->select('users.*')
->join('user_roles', 'user_roles.user_id', '=', 'users.id')
->join('roles', 'roles.id', '=', 'user_roles.role_id')
->where('roles.name', 'parent')
->where('users.id', $id)
->first();
if (!$parent) {
return $this->error('Parent not found', Response::HTTP_NOT_FOUND);
}
unset($parent['password'], $parent['token']);
return $this->success($parent, 'Parent retrieved successfully');
}
public function getStudents($id = null)
{
$parent = $this->user->find($id);
if (!$parent) {
return $this->error('Parent not found', Response::HTTP_NOT_FOUND);
}
$students = $this->student->newQuery()
->where('parent_id', $id)
->get()
->map(fn($row) => (array) $row)
->toArray();
foreach ($students as &$student) {
$classInfo = DB::table('student_class')
->select('class_section.*', 'classes.name as class_name')
->join('class_section', 'class_section.id', '=', 'student_class.class_section_id', 'left')
->join('classes', 'classes.id', '=', 'class_section.class_id', 'left')
->where('student_class.student_id', $student['id'])
->orderByDesc('student_class.school_year')
->first();
$student['current_class'] = $classInfo ? (array) $classInfo : null;
$student['medical_conditions'] = $this->medicalCondition
->where('student_id', $student['id'])
->findColumn('condition_name') ?? [];
$student['allergies'] = $this->allergy
->where('student_id', $student['id'])
->findColumn('allergy') ?? [];
}
return $this->success($students, 'Students retrieved successfully');
}
public function attendance($id)
{
try {
$year = $this->request->getGet('school_year') ?? $this->config->getConfig('school_year');
$records = DB::table('attendance_data')
->select('students.firstname', 'students.lastname', 'attendance_data.date', 'attendance_data.status', 'attendance_data.reason')
->join('students', 'students.id', '=', 'attendance_data.student_id')
->where('students.parent_id', $id)
->where('attendance_data.school_year', $year)
->orderByDesc('attendance_data.date')
->get()
->map(fn($row) => (array) $row)
->toArray();
return $this->success($records, 'Attendance data retrieved successfully');
} catch (Throwable $e) {
return $this->error('Error fetching attendance: ' . $e->getMessage());
}
}
public function payments($id)
{
$invoices = DB::table('invoices')
->where('parent_id', $id)
->orderByDesc('created_at')
->get()
->map(fn($row) => (array) $row)
->toArray();
return $this->success($invoices, 'Payments retrieved successfully');
}
public function enrollments($id)
{
$schoolYear = $this->request->getGet('school_year') ?? $this->config->getConfig('school_year');
$students = $this->student->where('parent_id', $id)->findAll();
$results = [];
foreach ($students as $student) {
$enrollment = $this->enrollment->newQuery()
->where('student_id', $student['id'])
->where('school_year', $schoolYear)
->orderByDesc('created_at')
->first();
$student['enrollment'] = $enrollment ?? [
'enrollment_status' => 'not enrolled',
'admission_status' => null,
];
$results[] = $student;
}
return $this->success($results, 'Enrollment data retrieved successfully');
}
public function enroll($id)
{
try {
$data = $this->payloadData();
if (empty($data['student_ids']) || !is_array($data['student_ids'])) {
return $this->error('Invalid student_ids payload', Response::HTTP_BAD_REQUEST);
}
$parentId = (int) $id;
$result = $this->processEnrollmentUpdate($parentId, array_map('intval', $data['student_ids']), []);
return $this->success($result, 'Enrollment submitted successfully');
} catch (Throwable $e) {
return $this->error('Failed to enroll: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function events($id)
{
$schoolYear = $this->config->getConfig('school_year');
$semester = $this->config->getConfig('semester');
$events = $this->event->getActiveEvents($schoolYear, $semester);
$charges = $this->charges->getChargesWithEventInfo($id, $schoolYear);
$chargeMap = [];
foreach ($charges as $charge) {
$chargeMap[$charge['student_id'] . ':' . $charge['event_id']] = $charge;
}
return $this->success([
'events' => $events,
'charges' => $chargeMap,
], 'Event participation data retrieved');
}
public function updateParticipation($id)
{
try {
$payload = $this->payloadData();
$participations = $payload['participation'] ?? [];
$schoolYear = $this->config->getConfig('school_year');
$semester = $this->config->getConfig('semester');
foreach ($participations as $key => $value) {
[$studentId, $eventId] = explode(':', $key);
$existing = $this->charges->where([
'parent_id' => $id,
'student_id' => $studentId,
'event_id' => $eventId,
])->first();
if ($value === 'no') {
if ($existing) {
$this->charges->delete($existing['id']);
}
continue;
}
$event = $this->event->getEvent($eventId, $schoolYear);
if ($existing) {
$this->charges->update($existing['id'], ['participation' => $value]);
} else {
$this->charges->insert([
'parent_id' => $id,
'student_id' => $studentId,
'event_id' => $eventId,
'participation' => $value,
'charged' => $event['amount'],
'school_year' => $schoolYear,
'semester' => $semester,
]);
}
}
return $this->success([], 'Participation updated successfully');
} catch (Throwable $e) {
return $this->error('Failed to update participation: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function profile($id)
{
$user = $this->user->find($id);
if (!$user) {
return $this->error('Parent not found', Response::HTTP_NOT_FOUND);
}
unset($user['password'], $user['token']);
return $this->success($user, 'Profile retrieved successfully');
}
public function updateProfile($id)
{
try {
$data = $this->payloadData();
if (!$data) {
return $this->error('Invalid JSON payload', Response::HTTP_BAD_REQUEST);
}
$allowed = ['firstname', 'lastname', 'cellphone', 'address_street', 'city', 'state', 'zip'];
$update = array_intersect_key($data, array_flip($allowed));
if (empty($update)) {
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
}
$this->user->update($id, $update);
return $this->success([], 'Profile updated successfully');
} catch (Throwable $e) {
return $this->error('Failed to update profile: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,327 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Participation;
use App\Models\StudentClass;
use App\Models\Student;
use App\Models\TeacherClass;
use App\Services\SemesterScoreService;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
class ParticipationController extends BaseApiController
{
protected Configuration $config;
protected Student $student;
protected TeacherClass $teacherClass;
protected Participation $participation;
protected SemesterScoreService $semesterScoreService;
protected string $schoolYear;
protected string $semester;
public function __construct()
{
parent::__construct();
$this->config = model(Configuration::class);
$this->student = model(Student::class);
$this->teacherClass = model(TeacherClass::class);
$this->participation = model(Participation::class);
$this->semesterScoreService = app(SemesterScoreService::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
}
/**
* GET /api/v1/participation
* Get participation scores for a class section
*/
public function index()
{
// Get class_section_id from POST -> GET -> session
$incoming = $this->request->getPost('class_section_id')
?? $this->request->getGet('class_section_id')
?? session()->get('class_section_id');
$classSectionId = (int) ($incoming ?: 0);
if ($classSectionId <= 0) {
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
}
// Persist for downstream use
session()->put('class_section_id', $classSectionId);
try {
$result = $this->getSavedScores($classSectionId, $this->semester, $this->schoolYear);
return $this->success([
'students' => $result['students'],
'semester' => $result['semester'],
'school_year' => $result['schoolYear'],
'class_section_id' => $classSectionId,
], 'Participation scores retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Participation index error: ' . $e->getMessage());
return $this->respondError($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/participation
* Update participation scores (teacher endpoint)
*/
public function update()
{
$updatedBy = $this->getCurrentUserId();
if (!$updatedBy) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
$scores = $payload['final_score'] ?? null;
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
if (!is_array($scores)) {
return $this->respondError('No scores submitted.', Response::HTTP_BAD_REQUEST);
}
if ($classSectionId <= 0) {
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
}
try {
$this->saveParticipationScores('participation', $scores, $updatedBy, $classSectionId, $this->semester, $this->schoolYear);
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId, $this->semester, $this->schoolYear);
// Add semester and school_year to each student info for the service
foreach ($studentTeacherInfo as &$student) {
$student['semester'] = $this->semester;
$student['school_year'] = $this->schoolYear;
}
unset($student);
// Call the updateScoresForStudents method
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
} catch (RuntimeException $e) {
log_message('error', 'SemesterScoreService error: ' . $e->getMessage());
}
return $this->success([
'class_section_id' => $classSectionId,
'updated_count' => count($scores),
], 'Participation scores updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Participation update error: ' . $e->getMessage());
return $this->respondError('Failed to update participation scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/participation/management
* Update participation scores (management endpoint)
*/
public function updateManagement()
{
$payload = $this->payloadData();
$scores = $payload['final_score'] ?? null;
$updatedBy = (int) ($payload['teacher_id'] ?? $this->getCurrentUserId() ?? 0);
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
if (!is_array($scores)) {
return $this->respondError('No scores submitted.', Response::HTTP_BAD_REQUEST);
}
if ($classSectionId <= 0) {
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
}
if ($updatedBy <= 0) {
return $this->respondError('Missing teacher ID.', Response::HTTP_BAD_REQUEST);
}
try {
$this->saveParticipationScores('participation', $scores, $updatedBy, $classSectionId, $this->semester, $this->schoolYear);
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId, $this->semester, $this->schoolYear);
// Add semester and school_year to each student info for the service
foreach ($studentTeacherInfo as &$student) {
$student['semester'] = $this->semester;
$student['school_year'] = $this->schoolYear;
}
unset($student);
// Call the updateScoresForStudents method
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
} catch (RuntimeException $e) {
log_message('error', 'SemesterScoreService error: ' . $e->getMessage());
}
// Return the same data structure as index for consistency
$result = $this->getSavedScores($classSectionId, $this->semester, $this->schoolYear);
return $this->success([
'students' => $result['students'],
'semester' => $result['semester'],
'school_year' => $result['schoolYear'],
'class_section_id' => $classSectionId,
'updated_count' => count($scores),
], 'Participation scores updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Participation updateManagement error: ' . $e->getMessage());
return $this->respondError('Failed to update participation scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/participation/management
* Get participation scores for management view
*/
public function showManagement()
{
// Get class_section_id from POST -> GET -> session
$incoming = $this->request->getPost('class_section_id')
?? $this->request->getGet('class_section_id')
?? session()->get('class_section_id');
$classSectionId = (int) ($incoming ?: 0);
if ($classSectionId <= 0) {
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
}
try {
// Load roster + saved scores for this class/term
$result = $this->getSavedScores($classSectionId, $this->semester, $this->schoolYear);
return $this->success([
'students' => $result['students'],
'semester' => $result['semester'],
'school_year' => $result['schoolYear'],
'class_section_id' => $classSectionId,
], 'Participation management data retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Participation showManagement error: ' . $e->getMessage());
return $this->respondError($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/participation/student/{id}
* Get participation score for a specific student
*/
public function getByStudent($id = null)
{
$participation = $this->participation->newQuery()
->where('student_id', $id)
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->first();
return $this->success($participation ?: null, 'Participation retrieved successfully');
}
/**
* Save participation scores to the database
*/
private function saveParticipationScores(string $table, array $scores, int $updatedBy, int $classSectionId, string $semester, string $schoolYear): void
{
$now = utc_now();
foreach ($scores as $studentId => $data) {
if (!is_numeric($studentId) || !isset($data['score'])) {
continue;
}
$score = $data['score'];
$existing = DB::table($table)
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$dataToSave = [
'student_id' => (int) $studentId,
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'score' => is_numeric($score) ? (float) $score : null,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => $now,
];
if ($existing) {
DB::table($table)
->where('id', $existing->id)
->update($dataToSave);
} else {
$dataToSave['created_at'] = $now;
DB::table($table)->insert($dataToSave);
}
}
}
/**
* Get roster + saved participation scores for a specific classSection/term.
*/
private function getSavedScores(int $classSectionId, string $semester, string $schoolYear): array
{
if ($classSectionId <= 0) {
throw new \InvalidArgumentException('Invalid class section id.');
}
session()->put('class_section_id', $classSectionId);
// Main query: Students + LEFT JOIN participation for same section/term
$students = DB::table('students as s')
->select([
's.id AS student_id',
's.school_id',
's.firstname',
's.lastname',
'p.score',
])
->join('student_class as sc', function ($join) use ($classSectionId, $schoolYear) {
$join->on('sc.student_id', '=', 's.id')
->where('sc.class_section_id', '=', $classSectionId)
->where('sc.school_year', '=', $schoolYear);
})
->leftJoin('participation as p', function ($join) use ($classSectionId, $semester, $schoolYear) {
$join->on('p.student_id', '=', 's.id')
->where('p.class_section_id', '=', $classSectionId)
->where('p.semester', '=', $semester)
->where('p.school_year', '=', $schoolYear);
})
->distinct()
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->map(function ($row) {
return (array) $row;
})
->toArray();
// Final safety net: ensure one row per student_id
$unique = [];
foreach ($students as $r) {
$unique[(int) $r['student_id']] = $r;
}
$students = array_values($unique);
return [
'students' => $students,
'semester' => $semester,
'schoolYear' => $schoolYear,
];
}
}
File diff suppressed because it is too large Load Diff
@@ -1,493 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\FamilyGuardian;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\PaymentNotificationLog;
use App\Models\User;
use App\Services\EmailService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class PaymentNotificationController extends BaseApiController
{
protected PaymentNotificationLog $log;
protected User $user;
protected Configuration $config;
protected Invoice $invoice;
protected Payment $payment;
protected FamilyGuardian $familyGuardian;
protected EmailService $emailService;
public function __construct()
{
parent::__construct();
$this->log = model(PaymentNotificationLog::class);
$this->user = model(User::class);
$this->config = model(Configuration::class);
$this->invoice = model(Invoice::class);
$this->payment = model(Payment::class);
$this->familyGuardian = model(FamilyGuardian::class);
$this->emailService = app(EmailService::class);
}
/**
* GET /api/v1/payments/notifications
* List payment notification logs with optional filters
*/
public function index(): JsonResponse
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$from = $this->request->getGet('from');
$to = $this->request->getGet('to');
$type = $this->request->getGet('type');
try {
$rows = $this->log->listLogs($from, $to, $type);
// Preload parent names
$parents = [];
foreach ($rows as $r) {
$pid = (int) ($r['parent_id'] ?? 0);
if ($pid && !isset($parents[$pid])) {
$u = $this->user->select('firstname,lastname')->find($pid);
$parents[$pid] = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? ''));
}
}
// Add parent names to rows
foreach ($rows as &$row) {
$pid = (int) ($row['parent_id'] ?? 0);
$row['parent_name'] = $parents[$pid] ?? '';
}
unset($row);
$offset = ($page - 1) * $perPage;
$total = count($rows);
$paginatedRows = array_slice($rows, $offset, $perPage);
$result = [
'data' => $paginatedRows,
'pagination' => [
'current_page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => (int) ceil($total / $perPage),
],
];
return $this->success($result, 'Payment notification logs retrieved successfully');
} catch (\Throwable $e) {
Log::error('Payment notification logs error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve payment notification logs', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/payments/notifications/send
* Send payment reminder notifications
*
* Body: parent_id? (int), type? ('no_payment'|'installment'), school_year?, force? (0|1)
* - When parent_id provided: send for that parent only (if balance > 0 or force=1)
* - Otherwise: send to all parents with positive balance for selected school year
* respecting idempotency (1 email per month/type) unless force=1.
*/
public function send(): JsonResponse
{
$payload = $this->payloadData();
$parentId = (int) ($payload['parent_id'] ?? 0);
$typeInput = (string) ($payload['type'] ?? '');
$force = (int) ($payload['force'] ?? 0) === 1;
$schoolYear = (string) ($payload['school_year'] ?? ($this->config->getConfig('school_year') ?? date('Y')));
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$tz = new \DateTimeZone($tzName);
$now = new \DateTime('now', $tz);
$year = (int) $now->format('Y');
$month = (int) $now->format('n');
// Helper: compute current total balance across invoices for this parent/year
$computeBalance = function (int $pid) use ($schoolYear): array {
$rows = DB::table('invoices')
->select('id', 'total_amount')
->where('parent_id', $pid)
->where('school_year', $schoolYear)
->get()
->toArray();
$paymentsTbl = 'payments';
$hasStatus = DB::getSchemaBuilder()->hasColumn($paymentsTbl, 'status');
$hasVoid = DB::getSchemaBuilder()->hasColumn($paymentsTbl, 'is_void');
$sumBalance = 0.0;
$latestId = null;
foreach ($rows as $ir) {
$ir = (array) $ir; // Convert to array for consistent access
$iid = (int) ($ir['id'] ?? 0);
if ($latestId === null) {
$latestId = $iid;
}
$total = (float) ($ir['total_amount'] ?? 0);
$qb = DB::table('payments')->select(DB::raw('COALESCE(SUM(paid_amount),0) AS tot'))->where('invoice_id', $iid);
if ($hasStatus) {
$qb->where(function ($query) {
$query->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
->orWhereNull('status');
});
}
if ($hasVoid) {
$qb->where(function ($query) {
$query->where('is_void', 0)
->orWhereNull('is_void');
});
}
$paidRow = $qb->first();
$paid = (float) (($paidRow && isset($paidRow->tot)) ? $paidRow->tot : 0);
$discRow = DB::table('discount_usages')
->select(DB::raw('COALESCE(SUM(discount_amount),0) AS tot'))
->where('invoice_id', $iid)
->first();
$disc = (float) (($discRow && isset($discRow->tot)) ? $discRow->tot : 0);
$rfndRow = DB::table('refunds')
->select(DB::raw('COALESCE(SUM(refund_paid_amount),0) AS tot'))
->where('invoice_id', $iid)
->whereIn('status', ['Partial', 'Paid'])
->first();
$rfnd = (float) (($rfndRow && isset($rfndRow->tot)) ? $rfndRow->tot : 0);
$sumBalance += max(0.0, round($total - $disc - $paid - $rfnd, 2));
}
return [$sumBalance, $latestId];
};
// Helper: get secondary guardian email
$getSecondary = function (int $primaryUserId): ?string {
$row = $this->familyGuardian->where('user_id', $primaryUserId)->first();
if (!$row || empty($row['family_id'])) {
return null;
}
$others = $this->familyGuardian
->where('family_id', (int) $row['family_id'])
->where('user_id', '!=', $primaryUserId)
->where('receive_emails', 1)
->findAll();
foreach ($others as $g) {
$email = $this->user->select('email')->find((int) $g['user_id'])['email'] ?? null;
if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $email;
}
}
return null;
};
// Compose subject/body using same content as CLI command
$compose = function (int $pid, float $balance) use ($schoolYear, $now, $tzName): array {
$u = $this->user->find($pid) ?: [];
$parentName = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? ''));
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
$monthYear = $now->format('F Y');
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
$balanceFmt = '$' . number_format($balance, 2);
$intro = 'This is your monthly installment reminder for ' . $schoolYear . '.';
// Compute remaining/max installments similar to Manual Payment UI
$installmentEndRaw = (string) $this->config->getConfig('installment_date');
$tz = new \DateTimeZone($tzName);
$today = new \DateTimeImmutable('today', $tz);
$end = null;
try {
if ($installmentEndRaw) {
$end = new \DateTimeImmutable($installmentEndRaw, $tz);
}
} catch (\Throwable $e) {
$end = null;
}
$remMonths = 0;
if ($end) {
$y = (int) $end->format('Y') - (int) $today->format('Y');
$m = (int) $end->format('n') - (int) $today->format('n');
$remMonths = $y * 12 + $m;
if ((int) $end->format('j') > (int) $today->format('j')) {
$remMonths += 1;
}
if ($remMonths < 0) {
$remMonths = 0;
}
}
$remainingInst = max(1, $remMonths);
$maxInst = max(($remMonths ?: 0), ($balance > 0 ? 2 : 0));
$instDueFmt = '$' . number_format($remainingInst > 0 ? ($balance / $remainingInst) : 0, 2);
$bodyHtml = <<<HTML
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder</h2>
<p>{$greeting}</p>
<p>{$intro}</p>
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
<ul>
<li><strong>Remaining installments:</strong> {$remainingInst}</li>
<li><strong>Suggested installment this month:</strong> {$instDueFmt}</li>
<li><strong>Maximum installments available:</strong> {$maxInst}</li>
</ul>
<p>As a friendly reminder, our tuition installments are due at the beginning of each month.</p>
<p>You can review your invoice and payment options by logging in to your parent portal.</p>
<p>Thank you for your prompt attention.</p>
<p><em>Reminder Period:</em> {$monthYear}</p>
</div>
HTML;
// Try to wrap in email layout view, fallback to plain HTML
try {
$body = view('emails._wrap_layout', [
'title' => 'Monthly Tuition Reminder',
'body_html' => $bodyHtml,
], ['saveData' => false]);
} catch (\Throwable $e) {
// Fallback if view doesn't exist
$body = $bodyHtml;
}
return [$subject, $body];
};
$results = [
'sent' => 0,
'skipped' => 0,
'failed' => 0,
'details' => [],
];
if ($parentId > 0) {
// Single parent mode
[$balance, $latestInv] = $computeBalance($parentId);
// Detect type if not provided
if ($typeInput === 'no_payment' || $typeInput === 'installment') {
$type = $typeInput;
} else {
$hasPayments = $this->payment
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->countAllResults() > 0;
$type = $hasPayments ? 'installment' : 'no_payment';
}
// Idempotency: skip if already sent this month for this type
if (!$force && $this->log->existsForPeriod($parentId, $year, $month, $type)) {
$results['skipped']++;
$results['details'][] = ['parent_id' => $parentId, 'result' => 'already_sent'];
} elseif ($balance <= 0 && !$force) {
$results['skipped']++;
$results['details'][] = ['parent_id' => $parentId, 'result' => 'no_balance'];
} else {
// Compute current-month installment suggestion
$installmentEndRaw = (string) $this->config->getConfig('installment_date');
$tz = new \DateTimeZone($tzName);
$nowD = new \DateTimeImmutable('today', $tz);
$endD = null;
try {
if ($installmentEndRaw) {
$endD = new \DateTimeImmutable($installmentEndRaw, $tz);
}
} catch (\Throwable $e) {
$endD = null;
}
$remMonths = 0;
if ($endD) {
$y = (int) $endD->format('Y') - (int) $nowD->format('Y');
$m = (int) $endD->format('n') - (int) $nowD->format('n');
$remMonths = $y * 12 + $m;
if ((int) $endD->format('j') > (int) $nowD->format('j')) {
$remMonths += 1;
}
if ($remMonths < 0) {
$remMonths = 0;
}
}
$remInst = max(1, $remMonths);
$instDue = round($balance / $remInst, 2);
$toEmail = $this->user->select('email')->find($parentId)['email'] ?? null;
$toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null;
$ccEmail = $getSecondary($parentId);
[$subject, $body] = $compose($parentId, $balance);
$ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false;
if ($ok && $ccEmail && strcasecmp($ccEmail, (string) $toEmail) !== 0) {
$this->emailService->send($ccEmail, $subject, $body, 'finance');
}
$this->log->insert([
'parent_id' => $parentId,
'invoice_id' => $latestInv,
'school_year' => $schoolYear,
'period_year' => $year,
'period_month' => $month,
'type' => $type,
'to_email' => $toEmail,
'cc_email' => $ccEmail,
'head_fa_notified' => 0,
'subject' => $subject,
'body' => $body,
'status' => $ok ? 'sent' : 'failed',
'error_message' => $ok ? null : 'Missing/invalid email or send failed',
'balance_snapshot' => $balance,
'sent_at' => $now->format('Y-m-d H:i:s'),
]);
if ($ok) {
$results['sent']++;
$results['details'][] = [
'parent_id' => $parentId,
'result' => 'sent',
'balance' => $balance,
'installment_due' => $instDue,
'remaining' => $remInst,
];
} else {
$results['failed']++;
$results['details'][] = ['parent_id' => $parentId, 'result' => 'failed'];
}
}
} else {
// Bulk mode: parents with positive balance in selected year
$rows = DB::table('invoices')
->select(DB::raw('DISTINCT parent_id'))
->where('school_year', $schoolYear)
->get();
$pids = array_values(array_unique($rows->pluck('parent_id')->map(fn($id) => (int) $id)->toArray()));
foreach ($pids as $pid) {
[$balance, $latestInv] = $computeBalance($pid);
if ($balance <= 0 && !$force) {
$results['skipped']++;
continue;
}
$hasPayments = $this->payment
->where('parent_id', $pid)
->where('school_year', $schoolYear)
->countAllResults() > 0;
$type = $hasPayments ? 'installment' : 'no_payment';
if (!$force && $this->log->existsForPeriod($pid, $year, $month, $type)) {
$results['skipped']++;
continue;
}
$toEmail = $this->user->select('email')->find($pid)['email'] ?? null;
$toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null;
$ccEmail = $getSecondary($pid);
[$subject, $body] = $compose($pid, $balance);
$ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false;
if ($ok && $ccEmail && strcasecmp($ccEmail, (string) $toEmail) !== 0) {
$this->emailService->send($ccEmail, $subject, $body, 'finance');
}
// Optional: notify head of finance in-app
$this->notifyHeadOfFinance($pid, $type, $balance);
$this->log->insert([
'parent_id' => $pid,
'invoice_id' => $latestInv,
'school_year' => $schoolYear,
'period_year' => $year,
'period_month' => $month,
'type' => $type,
'to_email' => $toEmail,
'cc_email' => $ccEmail,
'head_fa_notified' => 1,
'subject' => $subject,
'body' => $body,
'status' => $ok ? 'sent' : 'failed',
'error_message' => $ok ? null : 'Missing/invalid email or send failed',
'balance_snapshot' => $balance,
'sent_at' => $now->format('Y-m-d H:i:s'),
]);
if ($ok) {
$results['sent']++;
} else {
$results['failed']++;
}
}
}
return $this->success([
'ok' => true,
] + $results + [
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
], 'Payment notifications processed');
}
/**
* Notify head of finance users about payment reminder sent
*/
private function notifyHeadOfFinance(int $parentId, string $type, float $balance): void
{
try {
$heads = $this->getHeadOfFinanceUsers();
foreach ($heads as $u) {
// Create in-app notification if notification system exists
// This is optional functionality - can be implemented later
Log::info(sprintf(
'Payment reminder sent: %s reminder to parent #%d (balance: $%0.2f)',
$type,
$parentId,
$balance
));
}
} catch (\Throwable $e) {
// Silently fail - this is optional
Log::debug('Failed to notify head of finance: ' . $e->getMessage());
}
}
/**
* Get head of finance users
*/
private function getHeadOfFinanceUsers(): array
{
$heads = $this->user->getUsersByRole('head of department (finance)');
if (!empty($heads)) {
return $heads;
}
$acct = $this->user->getUsersByRole('accountant');
return $acct ?: [];
}
}
@@ -1,197 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\PaymentTransaction;
use Symfony\Component\HttpFoundation\Response;
class PaymentTransactionController extends BaseApiController
{
protected PaymentTransaction $transaction;
protected Configuration $config;
public function __construct()
{
parent::__construct();
$this->transaction = model(PaymentTransaction::class);
$this->config = model(Configuration::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$paymentId = $this->request->getGet('payment_id');
$status = $this->request->getGet('status');
$query = $this->transaction->newQuery();
if ($paymentId) {
$query->where('payment_id', $paymentId);
}
if ($status) {
$query->where('payment_status', $status);
}
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Payment transactions retrieved successfully');
}
public function show($id = null)
{
$transaction = $this->transaction->find($id);
if (!$transaction) {
return $this->error('Payment transaction not found', Response::HTTP_NOT_FOUND);
}
return $this->success($transaction, 'Payment transaction retrieved successfully');
}
/**
* GET /api/v1/payment-transactions/payment/{paymentId}
* Get all transactions for a specific payment
*/
public function getByPayment($paymentId = null)
{
if (!$paymentId) {
return $this->error('Payment ID is required', Response::HTTP_BAD_REQUEST);
}
try {
$transactions = $this->transaction->getTransactionsByPaymentId($paymentId);
if (empty($transactions)) {
return $this->error('Transactions not found for the payment', Response::HTTP_NOT_FOUND);
}
return $this->success($transactions, 'Payment transactions retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Get transactions by payment error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve payment transactions', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/payment-transactions
* Create a new payment transaction (installment)
*/
public function store()
{
$data = $this->payloadData();
if (empty($data)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'payment_id' => 'required|integer',
'transaction_id' => 'required|unique:payment_transactions,transaction_id',
'amount' => 'required|numeric|min:0',
'payment_method' => 'required|string|max:50',
'transaction_date' => 'permit_empty|valid_date[Y-m-d H:i:s]',
'transaction_fee' => 'permit_empty|numeric|min:0',
'payment_reference' => 'permit_empty|string|max:255',
'is_full_payment' => 'permit_empty|in:0,1',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$schoolYear = $this->config->getConfig('school_year');
$semester = $this->config->getConfig('semester');
$transactionData = [
'transaction_id' => $data['transaction_id'],
'payment_id' => (int) $data['payment_id'],
'transaction_date' => $data['transaction_date'] ?? date('Y-m-d H:i:s'),
'amount' => (float) $data['amount'],
'payment_method' => $data['payment_method'],
'payment_status' => 'Pending', // Default status as per CodeIgniter version
'transaction_fee' => isset($data['transaction_fee']) ? (float) $data['transaction_fee'] : 0,
'payment_reference' => $data['payment_reference'] ?? null,
'is_full_payment' => isset($data['is_full_payment']) ? ((int) $data['is_full_payment'] ? 1 : 0) : 0,
'school_year' => $schoolYear,
'semester' => $semester,
];
try {
$transaction = $this->transaction->create($transactionData);
return $this->respondCreated($transaction->toArray(), 'Payment transaction created successfully');
} catch (\Throwable $e) {
log_message('error', 'Payment transaction creation error: ' . $e->getMessage());
return $this->respondError('Failed to create payment transaction', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* PATCH /api/v1/payment-transactions/{transactionId}/status
* Update payment status by transaction ID
*/
public function updateStatus($transactionId = null)
{
if (!$transactionId) {
return $this->error('Transaction ID is required', Response::HTTP_BAD_REQUEST);
}
$data = $this->payloadData();
if (empty($data) || !isset($data['status'])) {
return $this->error('Status is required', Response::HTTP_BAD_REQUEST);
}
$status = (string) $data['status'];
$rules = [
'status' => 'required|string|max:50',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
try {
// First check if transaction exists by transaction_id (string) or id (integer)
$transaction = null;
if (is_numeric($transactionId)) {
$transaction = $this->transaction->find((int) $transactionId);
}
if (!$transaction) {
$transaction = $this->transaction->getTransactionById($transactionId);
}
if (!$transaction) {
return $this->error('Transaction not found', Response::HTTP_NOT_FOUND);
}
// Update by transaction_id if it's a string, otherwise by id
$updated = false;
if (is_numeric($transactionId)) {
$updated = $this->transaction->update((int) $transactionId, ['payment_status' => $status]);
} else {
$updated = $this->transaction->updateTransactionStatus($transactionId, $status);
}
if ($updated) {
// Fetch updated transaction
$updatedTransaction = is_numeric($transactionId)
? $this->transaction->find((int) $transactionId)
: $this->transaction->getTransactionById($transactionId);
return $this->success([
'status' => 'success',
'transaction' => $updatedTransaction,
], 'Transaction status updated successfully');
} else {
return $this->respondError('Failed to update transaction status', Response::HTTP_INTERNAL_SERVER_ERROR);
}
} catch (\Throwable $e) {
log_message('error', 'Update transaction status error: ' . $e->getMessage());
return $this->respondError('Failed to update transaction status', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,140 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\PayPalPayment;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Response as ResponseFacade;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PaypalTransactionsController extends BaseApiController
{
protected PayPalPayment $paypalPayment;
public function __construct()
{
parent::__construct();
$this->paypalPayment = model(PayPalPayment::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$keyword = $this->request->getGet('q');
$query = $this->paypalPayment->newQuery()->orderBy('created_at', 'DESC');
if ($keyword) {
$query->where(function ($q) use ($keyword) {
$q->where('transaction_id', 'LIKE', "%{$keyword}%")
->orWhere('payer_email', 'LIKE', "%{$keyword}%")
->orWhere('event_type', 'LIKE', "%{$keyword}%")
->orWhere('order_id', 'LIKE', "%{$keyword}%")
->orWhere('parent_school_id', 'LIKE', "%{$keyword}%");
});
}
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'PayPal transactions retrieved successfully');
}
public function show($id = null)
{
$transaction = $this->paypalPayment->find($id);
if (!$transaction) {
return $this->error('PayPal transaction not found', Response::HTTP_NOT_FOUND);
}
return $this->success($transaction, 'PayPal transaction retrieved successfully');
}
/**
* GET /api/v1/paypal-transactions/transaction/{transactionId}
* Get transaction by PayPal transaction ID
*/
public function getByTransactionId($transactionId = null)
{
$transaction = $this->paypalPayment->newQuery()
->where('transaction_id', $transactionId)
->first();
if (!$transaction) {
return $this->error('PayPal transaction not found', Response::HTTP_NOT_FOUND);
}
return $this->success($transaction, 'PayPal transaction retrieved successfully');
}
/**
* GET /api/v1/paypal-transactions/export-csv
* Export PayPal transactions to CSV file
*/
public function exportCsv(): StreamedResponse
{
$keyword = $this->request->getGet('q');
$query = $this->paypalPayment->newQuery()->orderBy('created_at', 'DESC');
if ($keyword) {
$query->where(function ($q) use ($keyword) {
$q->where('transaction_id', 'LIKE', "%{$keyword}%")
->orWhere('payer_email', 'LIKE', "%{$keyword}%")
->orWhere('event_type', 'LIKE', "%{$keyword}%")
->orWhere('order_id', 'LIKE', "%{$keyword}%")
->orWhere('parent_school_id', 'LIKE', "%{$keyword}%");
});
}
$transactions = $query->get()->toArray();
$filename = 'paypal_transactions_' . date('Ymd_His') . '.csv';
$headers = [
'Content-Type' => 'text/csv',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
'Pragma' => 'no-cache',
'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0',
'Expires' => '0',
];
return ResponseFacade::streamDownload(function () use ($transactions) {
$output = fopen('php://output', 'w');
// CSV headers
fputcsv($output, [
'ID',
'Transaction ID',
'Order ID',
'Parent School ID',
'Email',
'Amount',
'Net Amount',
'Currency',
'Status',
'Event Type',
'Created At',
]);
foreach ($transactions as $t) {
$t = (array) $t; // Convert to array for consistent access
fputcsv($output, [
$t['id'] ?? '',
$t['transaction_id'] ?? '',
$t['order_id'] ?? '',
$t['parent_school_id'] ?? '',
$t['payer_email'] ?? '',
$t['amount'] ?? '',
$t['net_amount'] ?? '',
$t['currency'] ?? '',
$t['status'] ?? '',
$t['event_type'] ?? '',
$t['created_at'] ?? '',
]);
}
fclose($output);
}, $filename, $headers);
}
}
-162
View File
@@ -1,162 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\PayPalPayment;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
class PaypalWebhook extends BaseApiController
{
protected Configuration $config;
protected PayPalPayment $payment;
public function __construct()
{
parent::__construct();
$this->config = model(Configuration::class);
$this->payment = model(PayPalPayment::class);
}
public function handle()
{
$config = $this->loadConfig();
if (!$config) {
return $this->respondError('PayPal configuration incomplete', Response::HTTP_INTERNAL_SERVER_ERROR);
}
$payload = $this->payloadData();
if (empty($payload)) {
return $this->respondError('Invalid JSON payload', Response::HTTP_BAD_REQUEST);
}
$requiredHeaders = [
'paypal-transmission-id',
'paypal-transmission-time',
'paypal-cert-url',
'paypal-auth-algo',
'paypal-transmission-sig',
];
foreach ($requiredHeaders as $header) {
if (!$this->laravelRequest->headers->has($header)) {
return $this->respondError("Missing required header: {$header}", Response::HTTP_BAD_REQUEST);
}
}
$accessToken = $this->getAccessToken($config['client_id'], $config['secret'], $config['token_url']);
if (!$accessToken) {
return $this->respondError('Failed to get access token', Response::HTTP_INTERNAL_SERVER_ERROR);
}
$verificationData = [
'auth_algo' => $this->laravelRequest->headers->get('paypal-auth-algo'),
'cert_url' => $this->laravelRequest->headers->get('paypal-cert-url'),
'transmission_id' => $this->laravelRequest->headers->get('paypal-transmission-id'),
'transmission_sig' => $this->laravelRequest->headers->get('paypal-transmission-sig'),
'transmission_time' => $this->laravelRequest->headers->get('paypal-transmission-time'),
'webhook_id' => $config['webhook_id'],
'webhook_event' => $payload,
];
if (!$this->verifyWebhookSignature($config['verify_url'], $accessToken, $verificationData)) {
return $this->respondError('Webhook verification failed', Response::HTTP_UNAUTHORIZED);
}
try {
$this->processPayment($payload);
return $this->success(['status' => 'success'], 'Webhook processed successfully');
} catch (\Throwable $e) {
log_message('error', 'Payment processing error: ' . $e->getMessage());
return $this->respondError('Payment processing error', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
protected function loadConfig(): ?array
{
$mode = (string) ($this->config->getConfig('paypal_mode') ?? '');
$configs = [
'sandbox' => [
'webhook_id' => $this->config->getConfig('sandbox_webhookid_paypal'),
'client_id' => $this->config->getConfig('sandbox_clientid_paypal'),
'secret' => $this->config->getConfig('sandbox_secret_paypal'),
'verify_url' => $this->config->getConfig('sandbox_verifylink_paypal'),
'token_url' => $this->config->getConfig('sandbox_tokenurl_paypal'),
],
'production' => [
'webhook_id' => $this->config->getConfig('production_webhookid_paypal'),
'client_id' => $this->config->getConfig('production_clientid_paypal'),
'secret' => $this->config->getConfig('production_secret_paypal'),
'verify_url' => $this->config->getConfig('production_verifylink_paypal'),
'token_url' => $this->config->getConfig('production_tokenurl_paypal'),
],
];
if (!isset($configs[$mode])) {
return null;
}
$cfg = $configs[$mode];
if (in_array(null, $cfg, true) || in_array('', $cfg, true)) {
return null;
}
return $cfg + ['mode' => $mode];
}
protected function getAccessToken(string $clientId, string $secret, string $urlToken): ?string
{
try {
$response = Http::asForm()
->withBasicAuth($clientId, $secret)
->acceptJson()
->post($urlToken, ['grant_type' => 'client_credentials']);
if (!$response->successful()) {
log_message('error', 'Access token fetch failed: HTTP ' . $response->status());
return null;
}
return $response->json('access_token');
} catch (\Throwable $e) {
log_message('error', 'Access token fetch failed: ' . $e->getMessage());
return null;
}
}
protected function verifyWebhookSignature(string $verifyUrl, string $accessToken, array $verificationData): bool
{
try {
$response = Http::withToken($accessToken)
->acceptJson()
->post($verifyUrl, $verificationData);
if (!$response->successful()) {
log_message('error', 'Webhook verification failed: HTTP ' . $response->status());
return false;
}
return ($response->json('verification_status') ?? '') === 'SUCCESS';
} catch (\Throwable $e) {
log_message('error', 'Webhook verification failed: ' . $e->getMessage());
return false;
}
}
protected function processPayment(array $eventData): void
{
$resource = $eventData['resource'] ?? [];
$this->payment->create([
'webhook_id' => $eventData['id'] ?? null,
'transaction_id' => $resource['id'] ?? null,
'status' => $resource['status'] ?? null,
'amount' => $resource['amount']['value'] ?? null,
'currency' => $resource['amount']['currency_code'] ?? null,
'payer_email' => $resource['payer']['email_address'] ?? null,
'raw_payload' => json_encode($eventData),
]);
}
}
@@ -1,87 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use Symfony\Component\HttpFoundation\Response;
class PolicyController extends BaseApiController
{
public function index()
{
return $this->success([], 'Policy index');
}
/**
* GET /api/v1/policy/picture
* Get school picture policy content
*/
public function schoolPicturePolicy()
{
$partialPath = base_path('resources/views/policy/picture_policy_partial.php');
$viewPath = base_path('resources/views/policy/picture_policy.blade.php');
if (!is_readable($partialPath)) {
return $this->respondError('Picture policy partial not found', Response::HTTP_NOT_FOUND);
}
try {
// Include the PHP partial file (may return data array or output HTML)
$content = include $partialPath;
// If partial returns an array, try to render the view with it
// Otherwise, use the content directly
if (is_array($content)) {
if (is_readable($viewPath)) {
$html = view('policy.picture_policy', $content)->render();
} else {
// Fallback: if view doesn't exist, return the content as-is
$html = is_string($content) ? $content : json_encode($content);
}
} else {
// Content is already HTML string
$html = (string) $content;
}
} catch (\Throwable $e) {
log_message('error', 'Picture policy load error: ' . $e->getMessage());
return $this->respondError('Failed to load picture policy', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success(['html' => $html], 'Picture policy content retrieved');
}
/**
* GET /api/v1/policy/school
* Get school policy content
*/
public function schoolPolicy()
{
$metaPath = base_path('resources/views/policy/school_policy_partial.php');
$viewPath = base_path('resources/views/policy/school_policy.blade.php');
if (!is_readable($metaPath)) {
return $this->respondError('School policy partial not found', Response::HTTP_NOT_FOUND);
}
try {
// Include the PHP partial file (returns data array)
$content = include $metaPath;
$meta = is_array($content) ? $content : [];
// Render the Blade view with the meta data
if (is_readable($viewPath)) {
$html = view('policy.school_policy', $meta)->render();
} else {
// Fallback: if view doesn't exist, return meta as JSON
$html = json_encode($meta, JSON_PRETTY_PRINT);
}
} catch (\Throwable $e) {
log_message('error', 'School policy load error: ' . $e->getMessage());
return $this->respondError('Failed to load school policy', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success([
'meta' => $meta,
'html' => $html,
], 'School policy content retrieved');
}
}
@@ -1,114 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Preferences;
use Symfony\Component\HttpFoundation\Response;
class PreferencesController extends BaseApiController
{
protected Preferences $preferences;
public function __construct()
{
parent::__construct();
$this->preferences = model(Preferences::class);
}
public function index()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
try {
$preferences = $this->preferences->newQuery()
->where('user_id', $user->id)
->first();
if (!$preferences) {
$preferences = [
'notification_email' => 1,
'notification_sms' => 1,
'theme' => 'light',
'language' => 'en',
];
}
$styleConfig = config('Style');
$preferences['style_color'] = session()->get('style_color') ?? ($styleConfig->defaultStyle ?? 'blue');
$preferences['menu_color'] = session()->get('menu_color') ?? ($styleConfig->defaultMenu ?? 'white');
return $this->success($preferences, 'Preferences retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Preferences retrieval error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve preferences', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update($id = null)
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$styleConfig = config('Style');
$styleOptions = implode(',', array_keys($styleConfig->stylePalettes ?? []));
$menuOptions = implode(',', array_keys($styleConfig->menuPalettes ?? []));
$rules = [
'notification_email' => 'permit_empty|in_list[0,1]',
'notification_sms' => 'permit_empty|in_list[0,1]',
'theme' => 'permit_empty|in_list[light,dark]',
'language' => 'permit_empty|in_list[en,fr,es]',
'style_color' => $styleOptions ? 'permit_empty|in_list[' . $styleOptions . ']' : 'permit_empty',
'menu_color' => $menuOptions ? 'permit_empty|in_list[' . $menuOptions . ']' : 'permit_empty',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
try {
$existing = $this->preferences->newQuery()
->where('user_id', $user->id)
->first();
$prefData = [
'user_id' => $user->id,
'notification_email' => $data['notification_email'] ?? 1,
'notification_sms' => $data['notification_sms'] ?? 1,
'theme' => $data['theme'] ?? 'light',
'language' => $data['language'] ?? 'en',
];
if ($existing) {
$this->preferences->update($existing['id'], $prefData);
$preferences = $this->preferences->find($existing['id']);
} else {
$pref = $this->preferences->create($prefData);
$preferences = $pref instanceof \Illuminate\Database\Eloquent\Model ? $pref->toArray() : $pref;
}
if (isset($data['style_color'])) {
session()->put('style_color', $data['style_color']);
}
if (isset($data['menu_color'])) {
session()->put('menu_color', $data['menu_color']);
}
return $this->success($preferences, 'Preferences updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Preferences update error: ' . $e->getMessage());
return $this->respondError('Failed to update preferences', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,624 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\BadgePrintLog;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\SemesterScore;
use App\Models\StudentClass;
use App\Models\Student;
use App\Models\TeacherClass;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PrintablesReportsController extends BaseApiController
{
protected Student $student;
protected ClassSection $classSection;
protected Configuration $config;
protected BadgePrintLog $badgePrintLog;
protected SemesterScore $semesterScore;
protected StudentClass $studentClass;
protected TeacherClass $teacherClass;
protected User $user;
protected string $schoolYear;
protected string $semester;
public function __construct()
{
parent::__construct();
$this->student = model(Student::class);
$this->classSection = model(ClassSection::class);
$this->config = model(Configuration::class);
$this->badgePrintLog = model(BadgePrintLog::class);
$this->semesterScore = model(SemesterScore::class);
$this->studentClass = model(StudentClass::class);
$this->teacherClass = model(TeacherClass::class);
$this->user = model(User::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
}
/**
* GET /api/v1/printables/report-card-meta
* Get report card metadata for UI hydration
*/
public function reportCardMeta(): JsonResponse
{
try {
$year = trim((string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? ''));
$sem = trim((string) ($this->request->getGet('semester') ?? $this->semester ?? ''));
if ($year === '') {
// Fallback to latest present in classSection or semester_score
$yr = DB::table('classSection')
->select('school_year')
->orderBy('school_year', 'DESC')
->first();
$year = (string) ($yr->school_year ?? '');
}
// Build schoolYears from classSection and semester_scores
$schoolYears = [];
try {
$q1 = DB::table('classSection')
->select(DB::raw('DISTINCT school_year'))
->whereNotNull('school_year')
->orderBy('school_year', 'DESC')
->pluck('school_year')
->map(fn($r) => (string) $r)
->filter()
->values()
->toArray();
$schoolYears = $q1;
} catch (\Throwable $e) {
// ignore
}
try {
$q2 = DB::table('semester_scores')
->select(DB::raw('DISTINCT school_year'))
->whereNotNull('school_year')
->orderBy('school_year', 'DESC')
->pluck('school_year')
->map(fn($r) => (string) $r)
->filter()
->toArray();
foreach ($q2 as $val) {
if ($val !== '' && !in_array($val, $schoolYears, true)) {
$schoolYears[] = $val;
}
}
} catch (\Throwable $e) {
// ignore
}
rsort($schoolYears);
// Semesters available for the selected year
$semesters = [];
try {
$rs = DB::table('semester_scores')
->select(DB::raw('DISTINCT semester'))
->where('school_year', $year)
->whereNotNull('semester')
->orderBy('semester', 'ASC')
->pluck('semester')
->map(fn($r) => (string) $r)
->filter()
->values()
->toArray();
$semesters = $rs;
} catch (\Throwable $e) {
// ignore
}
if (empty($semesters)) {
// Provide a sensible default list
$defaults = array_filter([(string) ($this->semester ?? ''), 'Fall', 'Spring'], fn($v) => $v !== '');
$semesters = array_values(array_unique($defaults));
}
if ($sem === '' && !empty($semesters)) {
$sem = $semesters[0];
}
// Students: prefer those with scores in selected year (+semester if provided); fallback to all
$students = [];
try {
$stuRows = DB::table('semester_scores ss')
->select(DB::raw('DISTINCT s.id, s.firstname, s.lastname'))
->join('students s', 's.id', '=', 'ss.student_id')
->where('ss.school_year', $year)
->where(function ($query) use ($sem) {
if ($sem !== '') {
$query->where('ss.semester', $sem);
}
})
->orderBy('s.lastname', 'ASC')
->get()
->toArray();
if (!empty($stuRows)) {
$students = array_map(fn($r) => [
'id' => (int) $r->id,
'firstname' => (string) ($r->firstname ?? ''),
'lastname' => (string) ($r->lastname ?? ''),
], $stuRows);
}
} catch (\Throwable $e) {
// ignore
}
if (empty($students)) {
$allStudents = $this->student
->select('id', 'firstname', 'lastname')
->orderBy('lastname', 'ASC')
->findAll();
$students = array_map(fn($s) => [
'id' => (int) ($s['id'] ?? 0),
'firstname' => (string) ($s['firstname'] ?? ''),
'lastname' => (string) ($s['lastname'] ?? ''),
], $allStudents);
}
// Class sections for selected year (fallback to all)
$classSections = $this->classSection
->select('id', 'class_section_id', 'class_section_name', 'school_year')
->where('school_year', $year)
->orderBy('class_section_name', 'ASC')
->findAll();
if (empty($classSections)) {
$classSections = $this->classSection
->select('id', 'class_section_id', 'class_section_name', 'school_year')
->orderBy('class_section_name', 'ASC')
->findAll();
}
$classSectionsFormatted = array_map(static fn($r) => [
'id' => (int) ($r['id'] ?? 0),
'class_section_id' => (int) ($r['class_section_id'] ?? ($r['id'] ?? 0)),
'class_section_name' => (string) ($r['class_section_name'] ?? 'Section'),
'school_year' => (string) ($r['school_year'] ?? ''),
], $classSections);
return $this->success([
'ok' => true,
'schoolYears' => $schoolYears,
'selectedYear' => $year,
'semesters' => $semesters,
'selectedSemester' => $sem,
'students' => $students,
'classSections' => $classSectionsFormatted,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
], 'Report card metadata retrieved successfully');
} catch (\Throwable $e) {
Log::error('Report card meta error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve report card metadata', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/printables/badge-status
* Get badge print status for given users
*/
public function badgePrintStatus(): JsonResponse
{
$ids = $this->request->getGet('user_ids');
if (is_string($ids)) {
$ids = array_filter(array_map('trim', explode(',', $ids)), 'strlen');
}
if (!is_array($ids)) {
$ids = $ids ? [$ids] : [];
}
$ids = array_values(array_unique(array_map(static fn($v) => (int) $v, $ids)));
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
try {
$status = $this->badgePrintLog->getStatus($ids, is_string($schoolYear) ? $schoolYear : null);
} catch (\Throwable $e) {
Log::error('Badge print status error: ' . $e->getMessage());
return $this->error('Failed to query status', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success([
'ok' => true,
'data' => $status,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
], 'Badge print status retrieved successfully');
}
/**
* POST /api/v1/printables/badge-status
* Log a set of badge prints explicitly
*/
public function logBadgePrint(): JsonResponse
{
$data = $this->payloadData();
if (empty($data)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$ids = $data['user_ids'] ?? [];
if (!is_array($ids)) {
$ids = $ids ? [$ids] : [];
}
$ids = array_values(array_unique(array_map(static fn($v) => (int) $v, $ids)));
$roles = $data['roles'] ?? [];
$class = $data['classes'] ?? [];
if (!is_array($roles)) {
$roles = [];
}
if (!is_array($class)) {
$class = [];
}
$schoolYear = $data['school_year'] ?? $this->schoolYear;
$actorId = $this->getCurrentUserId();
try {
$cnt = $this->badgePrintLog->logPrints(
$ids,
$actorId,
is_string($schoolYear) ? $schoolYear : null,
$roles,
$class,
1
);
return $this->success([
'ok' => true,
'inserted' => $cnt,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
], 'Badge prints logged successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
Log::error('Log badge print error: ' . $e->getMessage());
return $this->error('Failed to log badge prints: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/printables/students-by-class/{classSectionId}
* Get students by class section
*/
public function getByClass($classSectionId): JsonResponse
{
try {
$students = DB::table('student_class sc')
->select('s.id', 's.firstname', 's.lastname', 's.registration_grade', 's.gender')
->join('students s', 's.id', '=', 'sc.student_id')
->where('sc.class_section_id', (int) $classSectionId)
->where('sc.school_year', $this->schoolYear)
->orderBy('s.lastname', 'asc')
->get()
->toArray();
return $this->success($students, 'Students retrieved successfully');
} catch (\Throwable $e) {
Log::error('Get students by class error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve students', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/printables/students-by-class/{classSectionId}
* Alternative endpoint name for students by class
*/
public function studentsByClass($classSectionId): JsonResponse
{
try {
$rows = $this->student
->select('students.id', 'students.firstname', 'students.lastname', 'students.gender', 'cs.class_section_name AS registration_grade')
->join('student_class sc', 'sc.student_id', '=', 'students.id', 'inner')
->join('classSection cs', 'cs.class_section_id', '=', 'sc.class_section_id', 'inner')
->where('sc.school_year', $this->schoolYear)
->where('sc.class_section_id', (int) $classSectionId)
->groupBy('students.id', 'students.firstname', 'students.lastname', 'students.gender', 'cs.class_section_name')
->orderBy('students.firstname', 'ASC')
->orderBy('students.lastname', 'ASC')
->findAll(1000);
return $this->success($rows, 'Students retrieved successfully');
} catch (\Throwable $e) {
Log::error('Students by class error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve students', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/printables/preview-sticker-counts
* Preview sticker counts per student
*/
public function previewStickerCounts(): JsonResponse
{
try {
$classId = (int) ($this->request->getGet('class_id') ?? 0);
if ($classId > 0) {
$result = $this->getStickerCountsPerStudentForClass($this->schoolYear, $classId, $this->semester);
} else {
$result = $this->getStickerCountsPerStudent($this->schoolYear, $this->semester);
}
return $this->success([
'status' => 'ok',
'data' => $result,
], 'Sticker counts retrieved successfully');
} catch (\Throwable $e) {
Log::error('Preview sticker counts error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve sticker counts', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Compute sticker counts per student for entire school year
*/
protected function getStickerCountsPerStudent(string $schoolYear, ?string $semester = null): array
{
$specialMap = [
'KG' => 1,
'1-A' => 3,
'1-B' => 4,
'2-A' => 5,
'2-B' => 5,
'9' => 2,
];
$isUpperBlock = static function (string $g): bool {
return (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
};
$isGrade5 = static function (string $g): bool {
return (bool) preg_match('/^5(\-|$)/', trim($g));
};
$builder = DB::table('student_class sc')
->select([
'sc.student_id',
's.firstname',
's.lastname',
's.is_new',
'cs.class_section_name AS grade_label',
])
->join('students s', 's.id', '=', 'sc.student_id')
->join('classSection cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->join('classes c', 'c.id', '=', 'cs.class_id')
->where('sc.school_year', $schoolYear);
if ($semester !== null && $semester !== '') {
$builder->where('sc.semester', $semester);
}
$rows = $builder->get()->toArray();
// Filter out Youth and empty labels
$rows = array_values(array_filter($rows, static function ($r) {
$g = trim((string) ($r->grade_label ?? ''));
return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0;
}));
$out = [];
$totalPrimary = 0;
$totalSecondary = 0;
foreach ($rows as $r) {
$first = trim((string) ($r->firstname ?? ''));
$last = trim((string) ($r->lastname ?? ''));
$grade = trim((string) ($r->grade_label ?? ''));
$isNew = ((int) ($r->is_new ?? 0)) === 1;
$primary = 0;
$secondary = 0;
if (array_key_exists($grade, $specialMap)) {
$primary = (int) $specialMap[$grade];
$secondary = 0;
} elseif ($isUpperBlock($grade)) {
if ($isNew) {
$primary = 4;
$secondary = 0;
} else {
$primary = $isGrade5($grade) ? 3 : 2;
$secondary = max(0, 4 - $primary);
}
} else {
$primary = 4;
$secondary = 0;
}
$totalPrimary += $primary;
$totalSecondary += $secondary;
$out[] = [
'student_id' => (int) $r->student_id,
'firstname' => $first,
'lastname' => $last,
'grade_label' => $grade,
'primary_count' => $primary,
'secondary_count' => $secondary,
];
}
return [
'students' => $out,
'totals' => [
'primary' => $totalPrimary,
'secondary' => $totalSecondary,
'students' => count($out),
],
];
}
/**
* Compute sticker counts per student for one class section
*/
protected function getStickerCountsPerStudentForClass(
string $schoolYear,
int $classSectionId,
?string $semester = null
): array {
$specialMap = [
'1-A' => 3,
'1-B' => 4,
'2-A' => 5,
'2-B' => 5,
'9' => 2,
];
$isUpperBlock = static fn($g) => (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
$isGrade5 = static fn($g) => (bool) preg_match('/^5(\-|$)/', trim($g));
$b = DB::table('student_class sc')
->select('sc.student_id', 's.firstname', 's.lastname', 's.is_new', 'cs.class_section_name AS grade_label')
->join('students s', 's.id', '=', 'sc.student_id')
->join('classSection cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->join('classes c', 'c.id', '=', 'cs.class_id')
->where('sc.school_year', $schoolYear)
->where('sc.class_section_id', $classSectionId);
if (!empty($semester)) {
$b->where('sc.semester', $semester);
}
$rows = $b->get()->toArray();
$rows = array_values(array_filter($rows, static function ($r) {
$g = trim((string) ($r->grade_label ?? ''));
return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0;
}));
$out = [];
$tp = 0;
$ts = 0;
foreach ($rows as $r) {
$g = trim((string) ($r->grade_label ?? ''));
$isNew = ((int) ($r->is_new ?? 0)) === 1;
$p = 0;
$s = 0;
if (isset($specialMap[$g])) {
$p = $specialMap[$g];
} elseif ($isUpperBlock($g)) {
if ($isNew) {
$p = 4;
} else {
$p = $isGrade5($g) ? 3 : 2;
$s = max(0, 4 - $p);
}
} else {
$p = 4;
}
$tp += $p;
$ts += $s;
$out[] = [
'student_id' => (int) $r->student_id,
'firstname' => trim((string) ($r->firstname ?? '')),
'lastname' => trim((string) ($r->lastname ?? '')),
'grade_label' => $g,
'primary_count' => $p,
'secondary_count' => $s,
];
}
return [
'students' => $out,
'totals' => [
'primary' => $tp,
'secondary' => $ts,
'students' => count($out),
],
];
}
/**
* POST /api/v1/printables/badge
* Generate staff badge PDF
*
* Note: This method requires FPDF library. The PDF generation logic from the
* CodeIgniter version should be integrated here when FPDF is available.
*/
public function badge(): Response
{
// TODO: Implement PDF badge generation using FPDF
// This requires the FPDF library to be installed and the drawBadgeInCell method
// to be implemented. For now, return an error indicating it's not implemented.
return $this->respondError(
'Badge PDF generation is not yet implemented. FPDF library integration required.',
Response::HTTP_NOT_IMPLEMENTED
);
}
/**
* POST /api/v1/printables/generate-stickers
* Generate student name stickers PDF
*
* Note: This method requires FPDF library. The PDF generation logic from the
* CodeIgniter version should be integrated here when FPDF is available.
*/
public function generateStickers(): Response
{
// TODO: Implement PDF sticker generation using FPDF
// This requires the FPDF library to be installed.
// For now, return an error indicating it's not implemented.
return $this->respondError(
'Sticker PDF generation is not yet implemented. FPDF library integration required.',
Response::HTTP_NOT_IMPLEMENTED
);
}
/**
* GET /api/v1/printables/report-card/{studentId}
* Generate single student report card PDF
*
* Note: This method requires FPDF library. The PDF generation logic from the
* CodeIgniter version should be integrated here when FPDF is available.
*/
public function generateSingleReport($studentId): Response
{
// TODO: Implement PDF report card generation using FPDF
// This requires the FPDF library to be installed and the formatReportPDF
// and prepareStudentReportData methods to be implemented.
return $this->respondError(
'Report card PDF generation is not yet implemented. FPDF library integration required.',
Response::HTTP_NOT_IMPLEMENTED
);
}
/**
* GET /api/v1/printables/report-card/class/{classSectionId}
* Generate class report cards PDF
*
* Note: This method requires FPDF library. The PDF generation logic from the
* CodeIgniter version should be integrated here when FPDF is available.
*/
public function generateClassReport($classSectionId): Response
{
// TODO: Implement PDF class report cards generation using FPDF
// This requires the FPDF library to be installed.
return $this->respondError(
'Class report cards PDF generation is not yet implemented. FPDF library integration required.',
Response::HTTP_NOT_IMPLEMENTED
);
}
}
@@ -1,621 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Project;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use App\Services\SemesterScoreService;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
class ProjectController extends BaseApiController
{
protected Project $project;
protected Configuration $config;
protected Student $student;
protected StudentClass $studentClass;
protected TeacherClass $teacherClass;
protected SemesterScoreService $semesterScoreService;
protected string $schoolYear;
protected string $semester;
public function __construct()
{
parent::__construct();
$this->project = model(Project::class);
$this->config = model(Configuration::class);
$this->student = model(Student::class);
$this->studentClass = model(StudentClass::class);
$this->teacherClass = model(TeacherClass::class);
$this->semesterScoreService = app(SemesterScoreService::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
}
/**
* GET /api/v1/projects
* List projects with optional filters
*/
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$studentId = $this->request->getGet('student_id');
$classSectionId = $this->request->getGet('class_section_id');
$query = $this->project->newQuery()
->where('school_year', $this->schoolYear)
->where('semester', $this->semester);
if ($studentId) {
$query->where('student_id', $studentId);
}
if ($classSectionId) {
$query->where('class_section_id', $classSectionId);
}
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Projects retrieved successfully');
}
/**
* GET /api/v1/projects/{id}
* Get a single project
*/
public function show($id = null)
{
$project = $this->project->find($id);
if (!$project) {
return $this->error('Project not found', Response::HTTP_NOT_FOUND);
}
return $this->success($project, 'Project retrieved successfully');
}
/**
* GET /api/v1/projects/student/{id}
* Get projects for a specific student
*/
public function getByStudent($id = null)
{
$projects = $this->project->newQuery()
->where('student_id', $id)
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->orderBy('project_index', 'ASC')
->get()
->toArray();
return $this->success($projects, 'Projects retrieved successfully');
}
/**
* POST /api/v1/projects
* Create a new project entry
*/
public function store()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'student_id' => 'required|integer',
'class_section_id' => 'required|integer',
'project_index' => 'required|integer',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$projectData = [
'student_id' => $data['student_id'],
'class_section_id' => $data['class_section_id'],
'project_index' => $data['project_index'],
'score' => $data['score'] ?? null,
'comment' => $data['comment'] ?? null,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'updated_by' => $user->id,
];
try {
$project = $this->project->create($projectData);
return $this->success($project->toArray(), 'Project created successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'Project creation error: ' . $e->getMessage());
return $this->respondError('Failed to create project', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* PATCH /api/v1/projects/{id}
* Update a project entry
*/
public function update($id = null)
{
$project = $this->project->find($id);
if (!$project) {
return $this->error('Project not found', Response::HTTP_NOT_FOUND);
}
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$allowedFields = ['score', 'comment', 'project_index'];
$updateData = [];
foreach ($allowedFields as $field) {
if (array_key_exists($field, $data)) {
$updateData[$field] = $data[$field];
}
}
if (empty($updateData)) {
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
}
$updateData['updated_by'] = $user->id;
$updateData['updated_at'] = utc_now();
try {
$this->project->update($id, $updateData);
$updatedProject = $this->project->find($id);
return $this->success($updatedProject, 'Project updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Project update error: ' . $e->getMessage());
return $this->respondError('Failed to update project', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/projects/add-project
* Get project data for teacher's class section (equivalent to addProject view)
*/
public function addProject()
{
$updatedBy = $this->getCurrentUserId();
if (!$updatedBy) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$teacherClass = $this->teacherClass->where('teacher_id', $updatedBy)->first();
if (!$teacherClass) {
return $this->respondError('No class section found for the current teacher.', Response::HTTP_NOT_FOUND);
}
$classSectionId = $teacherClass['class_section_id'];
session()->put('class_section_id', $classSectionId);
$projectRows = $this->project
->select('id, project_index')
->where('class_section_id', $classSectionId)
->where('updated_by', $updatedBy)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->orderBy('project_index', 'ASC')
->findAll();
$projectHeaders = [];
foreach ($projectRows as $row) {
$projectHeaders[$row['project_index']][] = $row['id'];
}
$headerLabels = array_keys($projectHeaders);
$studentsClasses = $this->studentClass->where('class_section_id', $classSectionId)->findAll();
$students = [];
foreach ($studentsClasses as $studentClass) {
$studentId = $studentClass['student_id'];
$student = $this->student->find($studentId);
if ($student) {
$scores = [];
foreach ($projectHeaders as $index => $ids) {
$entry = $this->project
->where('student_id', $studentId)
->where('project_index', $index)
->where('class_section_id', $classSectionId)
->where('semester', $this->semester)
->first();
$scores[$index] = $entry['score'] ?? '';
}
$students[] = [
'student_id' => $studentId,
'firstname' => $student['firstname'],
'lastname' => $student['lastname'],
'scores' => $scores
];
}
}
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
return $this->success([
'students' => $students,
'project_headers' => $headerLabels,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'class_section_id' => $classSectionId
], 'Project data retrieved successfully');
}
/**
* POST /api/v1/projects/update-scores
* Update project scores for multiple students
*/
public function updateProjectScores()
{
$updatedBy = $this->getCurrentUserId();
if (!$updatedBy) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
$scores = $payload['scores'] ?? null;
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
if ($classSectionId <= 0) {
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
}
if (!is_array($scores)) {
return $this->respondError('No project scores submitted.', Response::HTTP_BAD_REQUEST);
}
try {
foreach ($scores as $studentId => $projects) {
if (!is_numeric($studentId)) continue;
$student = $this->student->find($studentId);
if (!$student) continue;
foreach ($projects as $index => $score) {
if (!is_numeric($score)) continue;
$existing = $this->project->where([
'student_id' => $studentId,
'project_index' => $index,
'class_section_id' => $classSectionId,
'semester' => $this->semester
])->first();
$data = [
'student_id' => $studentId,
'school_id' => $student['school_id'] ?? '',
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'project_index' => $index,
'score' => is_numeric($score) ? (float)$score : null,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'updated_at' => utc_now(),
];
if ($existing) {
$this->project->update($existing['id'], $data);
} else {
$data['created_at'] = utc_now();
$this->project->insert($data);
}
}
}
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId);
// Call the updateScoresForStudents method
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
} catch (RuntimeException $e) {
log_message('error', 'Failed to update semester scores: ' . $e->getMessage());
}
return $this->success(null, 'Project scores updated successfully.');
} catch (\Throwable $e) {
log_message('error', 'Update project scores error: ' . $e->getMessage());
return $this->respondError('Failed to update project scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/projects/add-column
* Add a new project column for all students in a class section
*/
public function addNextProjectColumn()
{
$updatedBy = $this->getCurrentUserId();
if (!$updatedBy) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
if ($classSectionId <= 0) {
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
}
try {
$existingIndexes = $this->project
->select('project_index')
->where('class_section_id', $classSectionId)
->where('updated_by', $updatedBy)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->groupBy('project_index')
->orderBy('project_index', 'DESC')
->findAll();
$maxIndex = 0;
foreach ($existingIndexes as $row) {
if (isset($row['project_index']) && is_numeric($row['project_index'])) {
$maxIndex = max($maxIndex, (int)$row['project_index']);
}
}
$nextIndex = $maxIndex + 1;
$students = $this->studentClass->where('class_section_id', $classSectionId)->findAll();
$insertedCount = 0;
foreach ($students as $student) {
$exists = $this->project->where([
'student_id' => $student['student_id'],
'project_index' => $nextIndex,
'class_section_id' => $classSectionId,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
])->first();
if (!$exists) {
$studentRecord = $this->student->find($student['student_id']);
$this->project->insert([
'student_id' => $student['student_id'],
'school_id' => $studentRecord['school_id'] ?? '',
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'project_index' => $nextIndex,
'score' => null,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'created_at' => utc_now(),
'updated_at' => utc_now()
]);
$insertedCount++;
}
}
return $this->success([
'status' => 'success',
'project_index' => $nextIndex,
'students_processed' => $insertedCount
], 'Project column added successfully');
} catch (\Throwable $e) {
log_message('error', 'Add project column error: ' . $e->getMessage());
return $this->respondError('Failed to add project column: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/projects/management
* Get project management data for a class section
*/
public function showProjectMngt()
{
// Accept POST first, then GET (query string), then session fallback
$incomingId = $this->request->getPost('class_section_id');
if (!$incomingId) {
$incomingId = $this->request->getGet('class_section_id');
}
$classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0));
if ($classSectionId <= 0) {
return $this->respondError('No class section found for the current teacher or selection.', Response::HTTP_BAD_REQUEST);
}
session()->put('class_section_id', $classSectionId);
$projectScores = $this->getProjectScoresByStudent($classSectionId, $this->semester, $this->schoolYear);
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $this->schoolYear);
$projectHeaders = $this->getProjectHeaders($classSectionId, $this->semester, $this->schoolYear);
return $this->success([
'project_scores' => $projectScores,
'students' => $students,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'project_headers' => $projectHeaders,
'class_section_id' => $classSectionId,
], 'Project management data retrieved successfully');
}
/**
* POST /api/v1/projects/update
* Update project scores (management endpoint)
*/
public function updateProject()
{
$payload = $this->payloadData();
$scores = $payload['scores'] ?? null;
$studentIds = $payload['student_ids'] ?? null;
$semester = $payload['semester'] ?? $this->semester;
$schoolYear = $payload['school_year'] ?? $this->schoolYear;
$updatedBy = $payload['teacher_id'] ?? $this->getCurrentUserId();
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
if (!$updatedBy) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
if ($classSectionId <= 0) {
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
}
if ($scores && $studentIds && $semester && $schoolYear) {
try {
$this->saveProjectScores($scores, $studentIds, $semester, $schoolYear, $updatedBy, $classSectionId);
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId);
// Call the updateScoresForStudents method
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
} catch (RuntimeException $e) {
log_message('error', 'Failed to update semester scores: ' . $e->getMessage());
}
return $this->success(null, 'Project scores updated successfully.');
} catch (\Throwable $e) {
log_message('error', 'Update project error: ' . $e->getMessage());
return $this->respondError('Failed to update scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
return $this->respondError('Failed to update scores. Missing required data.', Response::HTTP_BAD_REQUEST);
}
/**
* Helper: Save project scores for multiple students
*/
private function saveProjectScores(array $scores, array $studentIds, string $semester, string $schoolYear, int $updatedBy, int $classSectionId)
{
$now = utc_now();
foreach ($studentIds as $studentId) {
if (!isset($scores[$studentId])) {
continue;
}
foreach ($scores[$studentId] as $index => $score) {
// Check if the record exists
$existing = $this->project
->where('student_id', $studentId)
->where('project_index', $index)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('class_section_id', $classSectionId)
->where('updated_by', $updatedBy)
->first();
$student = $this->student->find($studentId);
$data = [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'project_index' => $index,
'score' => is_numeric($score) ? floatval($score) : null,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => $now,
];
if ($existing && isset($existing['id'])) {
$this->project->update($existing['id'], $data);
} else {
$data['created_at'] = $now;
$data['school_id'] = $student['school_id'] ?? '';
$this->project->insert($data);
}
}
}
}
/**
* Helper: Get project headers for a class section
*/
private function getProjectHeaders($classSectionId, $semester, $schoolYear)
{
$rows = $this->project
->select('project_index')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->orderBy('project_index', 'ASC')
->findAll();
$headers = [];
foreach ($rows as $row) {
$index = $row['project_index'];
if (!is_array($index)) {
$headers[$index] = true;
}
}
ksort($headers);
return array_keys($headers);
}
/**
* Helper: Get students by class section and year
*/
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
{
// Step 1: Get student IDs from student_class table
$studentClassRows = $this->studentClass
->select('student_id')
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->findAll();
$studentIds = array_column($studentClassRows, 'student_id');
if (empty($studentIds)) {
return []; // No students found
}
// Step 2: Get student data from students table
$students = $this->student
->whereIn('id', $studentIds)
->orderBy('lastname', 'ASC')
->findAll();
return $students;
}
/**
* Helper: Get project scores by student
*/
private function getProjectScoresByStudent($classSectionId, $semester, $schoolYear)
{
$rows = $this->project
->select('student_id, project_index, score')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
$studentScores = [];
foreach ($rows as $row) {
$studentId = $row['student_id'];
$index = $row['project_index'];
$score = $row['score'];
$studentScores[$studentId]['scores'][$index] = $score;
}
return $studentScores;
}
}
@@ -1,352 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\InventoryItem;
use App\Models\InventoryMovement;
use App\Models\PurchaseOrderItem;
use App\Models\PurchaseOrder;
use App\Models\Supplier;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class PurchaseOrderController extends BaseApiController
{
protected PurchaseOrder $po;
protected PurchaseOrderItem $item;
protected Supplier $supplier;
protected InventoryItem $inventoryItem;
protected InventoryMovement $movement;
public function __construct()
{
parent::__construct();
$this->po = model(PurchaseOrder::class);
$this->item = model(PurchaseOrderItem::class);
$this->supplier = model(Supplier::class);
$this->inventoryItem = model(InventoryItem::class);
$this->movement = model(InventoryMovement::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage= min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$keyword= trim((string) ($this->request->getGet('q') ?? ''));
$query = $this->po->newQuery()
->select('purchase_orders.*', 'suppliers.name AS supplier_name')
->leftJoin('suppliers', 'suppliers.id', '=', 'purchase_orders.supplier_id')
->orderByDesc('purchase_orders.created_at');
if ($keyword !== '') {
$query->where(function ($q) use ($keyword) {
$q->where('po_number', 'LIKE', "%{$keyword}%")
->orWhere('suppliers.name', 'LIKE', "%{$keyword}%");
});
}
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Purchase orders retrieved successfully');
}
public function show($id = null)
{
$po = $this->po->newQuery()
->select('purchase_orders.*', 'suppliers.name AS supplier_name')
->leftJoin('suppliers', 'suppliers.id', '=', 'purchase_orders.supplier_id')
->where('purchase_orders.id', $id)
->first();
if (!$po) {
return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND);
}
$items = $this->item->newQuery()
->select('purchase_order_items.*', 'inventory_items.name AS supply_name', 'inventory_items.unit AS supply_unit')
->leftJoin('inventory_items', 'inventory_items.id', '=', 'purchase_order_items.supply_id')
->where('purchase_order_id', $id)
->get()
->toArray();
$po['items'] = $items;
return $this->success($po, 'Purchase order retrieved successfully');
}
public function store()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
// Support both CodeIgniter-style form arrays and modern JSON format
$supplyIds = $payload['item_supply_id'] ?? $payload['items'] ?? [];
$descs = $payload['item_description'] ?? [];
$qtys = $payload['item_quantity'] ?? [];
$unitCosts = $payload['item_unit_cost'] ?? [];
// If items array is provided (modern format), extract data
if (isset($payload['items']) && is_array($payload['items'])) {
$items = [];
foreach ($payload['items'] as $item) {
if (isset($item['supply_id']) && isset($item['quantity']) && $item['quantity'] > 0) {
$items[] = [
'supply_id' => $item['supply_id'],
'description' => $item['description'] ?? '',
'quantity' => (int)$item['quantity'],
'unit_cost' => (float)($item['unit_cost'] ?? 0),
];
}
}
} else {
// CodeIgniter-style form arrays
if (empty($supplyIds) || !is_array($supplyIds)) {
return $this->respondError('Add at least one line item.', Response::HTTP_BAD_REQUEST);
}
$items = [];
foreach ($supplyIds as $i => $sid) {
$q = max(0, (int)($qtys[$i] ?? 0));
$uc = (float)($unitCosts[$i] ?? 0);
if ($sid && $q > 0) {
$items[] = [
'supply_id' => (int)$sid,
'description' => trim($descs[$i] ?? ''),
'quantity' => $q,
'unit_cost' => $uc,
];
}
}
}
if (empty($items)) {
return $this->respondError('Valid line items required.', Response::HTTP_BAD_REQUEST);
}
$rules = [
'po_number' => 'required|max_length:255',
'supplier_id' => 'required|integer',
'order_date' => 'required|date',
];
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
try {
DB::beginTransaction();
// Compute totals
$subtotal = 0.0;
foreach ($items as $item) {
$subtotal += $item['quantity'] * $item['unit_cost'];
}
$tax = (float)($payload['tax'] ?? 0.00);
$total = $subtotal + $tax;
$status = $payload['status'] ?? 'ordered';
$status = in_array($status, ['draft', 'ordered'], true) ? $status : 'ordered';
$poData = [
'po_number' => $payload['po_number'],
'supplier_id' => $payload['supplier_id'],
'order_date' => $payload['order_date'],
'expected_date' => $payload['expected_date'] ?? null,
'status' => $status,
'subtotal' => $subtotal,
'tax' => $tax,
'total' => $total,
'notes' => $payload['notes'] ?? null,
];
$po = $this->po->create($poData);
$poId = $po->id ?? null;
if (!$poId) {
DB::rollBack();
return $this->respondError('Failed to create purchase order', Response::HTTP_INTERNAL_SERVER_ERROR);
}
foreach ($items as $item) {
$this->item->create([
'purchase_order_id' => $poId,
'supply_id' => $item['supply_id'],
'description' => $item['description'],
'quantity' => $item['quantity'],
'unit_cost' => $item['unit_cost'],
'received_qty' => 0,
]);
}
DB::commit();
$poArray = $this->show($poId)->getData(true)['data'];
return $this->success($poArray, 'Purchase order created successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
DB::rollBack();
log_message('error', 'Purchase order creation error: ' . $e->getMessage());
return $this->respondError('Failed to create purchase order: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update($id = null)
{
$po = $this->po->find($id);
if (!$po) {
return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND);
}
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$allowedFields = ['status', 'expected_date', 'notes'];
$updateData = [];
foreach ($allowedFields as $field) {
if (array_key_exists($field, $payload)) {
$updateData[$field] = $payload[$field];
}
}
if (empty($updateData)) {
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
}
try {
$this->po->update($id, $updateData);
$updatedPo = $this->po->find($id);
return $this->success($updatedPo, 'Purchase order updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Purchase order update error: ' . $e->getMessage());
return $this->respondError('Failed to update purchase order', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/purchase-orders/{id}/receive
* Receive items (partial or full) from a purchase order
*/
public function receive($id = null)
{
$po = $this->po->find($id);
if (!$po) {
return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND);
}
if (in_array($po['status'], ['canceled', 'received'], true)) {
return $this->respondError('PO not receivable. Status: ' . $po['status'], Response::HTTP_BAD_REQUEST);
}
$payload = $this->payloadData();
$received = $payload['received'] ?? []; // [itemId => qty]
if (empty($received) || !is_array($received)) {
return $this->respondError('No items to receive.', Response::HTTP_BAD_REQUEST);
}
$user = $this->getCurrentUser();
$issuedBy = $user ? ($user->email ?? $user->username ?? 'system') : 'system';
try {
DB::beginTransaction();
$completed = true;
foreach ($received as $itemId => $qty) {
$qty = (int)$qty;
if ($qty <= 0) continue;
$item = $this->item->where('purchase_order_id', $id)->find($itemId);
if (!$item) {
$completed = false;
continue;
}
$remaining = (int)$item['quantity'] - (int)$item['received_qty'];
$toReceive = min($remaining, $qty);
if ($toReceive <= 0) continue;
// Update item received qty
$this->item->update($itemId, [
'received_qty' => (int)$item['received_qty'] + $toReceive
]);
// Update inventory item on hand (if supply_id maps to inventory_items)
if (!empty($item['supply_id'])) {
$inventoryItem = $this->inventoryItem->find($item['supply_id']);
if ($inventoryItem) {
$newQty = (int)($inventoryItem['quantity'] ?? 0) + $toReceive;
$this->inventoryItem->update($inventoryItem['id'], [
'quantity' => $newQty
]);
// Log transaction IN using inventory movements
$this->movement->insert([
'item_id' => $inventoryItem['id'],
'qty_change' => $toReceive,
'movement_type' => 'in',
'reason' => 'PO ' . $po['po_number'],
'note' => 'Received against PO',
'performed_by' => $this->getCurrentUserId() ?? null,
]);
}
}
if (($item['received_qty'] + $toReceive) < $item['quantity']) {
$completed = false;
}
}
// Set PO status
$this->po->update($id, [
'status' => $completed ? 'received' : 'ordered'
]);
DB::commit();
$message = $completed ? 'PO fully received.' : 'PO partially received.';
return $this->success(null, $message);
} catch (\Throwable $e) {
DB::rollBack();
log_message('error', 'Receive PO error: ' . $e->getMessage());
return $this->respondError('Failed to receive items: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/purchase-orders/{id}/cancel
* Cancel a purchase order
*/
public function cancel($id = null)
{
$po = $this->po->find($id);
if (!$po) {
return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND);
}
if ($po['status'] === 'received') {
return $this->respondError('Cannot cancel a received PO.', Response::HTTP_BAD_REQUEST);
}
try {
$this->po->update($id, ['status' => 'canceled']);
$updatedPo = $this->po->find($id);
return $this->success($updatedPo, 'PO canceled.');
} catch (\Throwable $e) {
log_message('error', 'Cancel PO error: ' . $e->getMessage());
return $this->respondError('Failed to cancel purchase order: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
-656
View File
@@ -1,656 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Quiz;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use App\Models\User;
use App\Services\SemesterScoreService;
use RuntimeException;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class QuizController extends BaseApiController
{
protected Quiz $quiz;
protected Configuration $config;
protected Student $student;
protected StudentClass $studentClass;
protected TeacherClass $teacherClass;
protected User $user;
protected SemesterScoreService $semesterScoreService;
protected string $schoolYear;
protected string $semester;
public function __construct()
{
parent::__construct();
$this->quiz = model(Quiz::class);
$this->config = model(Configuration::class);
$this->student = model(Student::class);
$this->studentClass = model(StudentClass::class);
$this->teacherClass = model(TeacherClass::class);
$this->user = model(User::class);
$this->semesterScoreService = app(SemesterScoreService::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
}
/**
* GET /api/v1/quizzes
* List quizzes with optional filters
*/
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$studentId = $this->request->getGet('student_id');
$classSectionId = $this->request->getGet('class_section_id');
$query = $this->quiz->newQuery()
->where('school_year', $this->schoolYear)
->where('semester', $this->semester);
if ($studentId) {
$query->where('student_id', $studentId);
}
if ($classSectionId) {
$query->where('class_section_id', $classSectionId);
}
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Quizzes retrieved successfully');
}
/**
* GET /api/v1/quizzes/{id}
* Get a single quiz
*/
public function show($id = null)
{
$quiz = $this->quiz->find($id);
if (!$quiz) {
return $this->error('Quiz not found', Response::HTTP_NOT_FOUND);
}
return $this->success($quiz, 'Quiz retrieved successfully');
}
/**
* GET /api/v1/quizzes/student/{id}
* Get quizzes for a specific student
*/
public function getByStudent($id = null)
{
$quizzes = $this->quiz->newQuery()
->where('student_id', $id)
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->orderBy('quiz_index', 'ASC')
->get()
->toArray();
return $this->success($quizzes, 'Quizzes retrieved successfully');
}
/**
* POST /api/v1/quizzes
* Create a new quiz entry
*/
public function store()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'student_id' => 'required|integer',
'class_section_id' => 'required|integer',
'quiz_index' => 'required|integer',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$quizData = [
'student_id' => $data['student_id'],
'class_section_id' => $data['class_section_id'],
'quiz_index' => $data['quiz_index'],
'score' => $data['score'] ?? null,
'comment' => $data['comment'] ?? null,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'updated_by' => $user->id,
];
try {
$quiz = $this->quiz->create($quizData);
return $this->success($quiz->toArray(), 'Quiz created successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'Quiz creation error: ' . $e->getMessage());
return $this->respondError('Failed to create quiz', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* PATCH /api/v1/quizzes/{id}
* Update a quiz entry
*/
public function update($id = null)
{
$quiz = $this->quiz->find($id);
if (!$quiz) {
return $this->error('Quiz not found', Response::HTTP_NOT_FOUND);
}
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$allowedFields = ['score', 'comment', 'quiz_index'];
$updateData = [];
foreach ($allowedFields as $field) {
if (array_key_exists($field, $data)) {
$updateData[$field] = $data[$field];
}
}
if (empty($updateData)) {
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
}
$updateData['updated_by'] = $user->id;
$updateData['updated_at'] = utc_now();
try {
$this->quiz->update($id, $updateData);
$updatedQuiz = $this->quiz->find($id);
return $this->success($updatedQuiz, 'Quiz updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Quiz update error: ' . $e->getMessage());
return $this->respondError('Failed to update quiz', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/quizzes/add-quiz
* Get quiz data for teacher's class section (equivalent to addQuiz view)
*/
public function addQuiz()
{
// Get class_section_id from POST -> GET -> session
$incoming = $this->request->getPost('class_section_id')
?? $this->request->getGet('class_section_id')
?? session()->get('class_section_id');
$classSectionId = (int) ($incoming ?: 0);
if ($classSectionId <= 0) {
return $this->respondError('Missing class section.', Response::HTTP_BAD_REQUEST);
}
session()->put('class_section_id', $classSectionId);
// Get distinct quiz_index values for this class/term
$quizHeaderRows = $this->quiz
->select('quiz_index')
->where('class_section_id', $classSectionId)
->where('school_year', $this->schoolYear)
->groupBy('quiz_index')
->orderBy('quiz_index', 'ASC')
->findAll();
$quizHeaders = array_map(static fn($r) => (int)$r['quiz_index'], $quizHeaderRows);
// Get roster (distinct students for this section/term)
$roster = DB::table('student_class as sc')
->select('s.id AS student_id', 's.firstname', 's.lastname', 's.school_id')
->distinct()
->join('students as s', 's.id', '=', 'sc.student_id')
->where('sc.class_section_id', $classSectionId)
->where('sc.school_year', $this->schoolYear)
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->toArray();
// Get all existing quiz scores
$scoreRows = $this->quiz
->select('id, student_id, quiz_index, score')
->where('class_section_id', $classSectionId)
->where('school_year', $this->schoolYear)
->findAll();
$scoresMap = []; // [student_id][quiz_index] => score
$idMap = []; // [student_id][quiz_index] => id
foreach ($scoreRows as $r) {
$sid = (int)$r['student_id'];
$qi = (int)$r['quiz_index'];
$scoresMap[$sid][$qi] = $r['score'];
$idMap[$sid][$qi] = (int)$r['id'];
}
// Build students array with per-quiz scores
$students = [];
foreach ($roster as $st) {
$sid = (int)$st->student_id;
$quizScores = [];
foreach ($quizHeaders as $qi) {
$quizScores[$qi] = $scoresMap[$sid][$qi] ?? '';
}
$students[] = [
'student_id' => $sid,
'firstname' => $st->firstname,
'lastname' => $st->lastname,
'school_id' => $st->school_id ?? null,
'scores' => $quizScores,
'existingIds' => $idMap[$sid] ?? [],
];
}
return $this->success([
'students' => $students,
'quiz_headers' => $quizHeaders,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'class_section_id' => $classSectionId
], 'Quiz data retrieved successfully');
}
/**
* POST /api/v1/quizzes/update-scores
* Update quiz scores for multiple students
*/
public function updateQuizScores()
{
$updatedBy = $this->getCurrentUserId();
if (!$updatedBy) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
$scores = $payload['scores'] ?? null;
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
if ($classSectionId <= 0) {
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
}
if (!is_array($scores)) {
return $this->respondError('No quiz scores submitted.', Response::HTTP_BAD_REQUEST);
}
try {
foreach ($scores as $studentId => $quizData) {
if (!is_numeric($studentId) || $studentId <= 0) continue;
// Normalize: if single input submitted
if (!is_array($quizData)) {
$quizData = [1 => $quizData];
}
foreach ($quizData as $quizNumber => $score) {
if (!is_numeric($quizNumber) || !is_numeric($score)) continue;
$existing = $this->quiz->where([
'student_id' => $studentId,
'quiz_index' => $quizNumber,
'class_section_id' => $classSectionId,
'semester' => $this->semester
])->first();
if ($existing) {
$this->quiz->update($existing['id'], [
'score' => is_numeric($score) ? (float)$score : null,
'updated_at' => utc_now()
]);
} else {
$student = $this->student->find($studentId);
$this->quiz->insert([
'student_id' => $studentId,
'school_id' => $student['school_id'] ?? '',
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'quiz_index' => $quizNumber,
'score' => is_numeric($score) ? (float)$score : null,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'created_at' => utc_now(),
'updated_at' => utc_now()
]);
}
}
}
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId);
// Call the updateScoresForStudents method
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
} catch (RuntimeException $e) {
log_message('error', 'Failed to update semester scores: ' . $e->getMessage());
}
return $this->success(null, 'Quiz scores updated successfully.');
} catch (\Throwable $e) {
log_message('error', 'Update quiz scores error: ' . $e->getMessage());
return $this->respondError('Failed to update quiz scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/quizzes/add-column
* Add a new quiz column for all students in a class section
*/
public function addNextQuizColumn()
{
$updatedBy = $this->getCurrentUserId();
if (!$updatedBy) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
if ($classSectionId <= 0) {
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
}
try {
$existingQuizNumbers = $this->quiz
->select('quiz_index')
->where('class_section_id', $classSectionId)
->where('updated_by', $updatedBy)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->groupBy('quiz_index')
->orderBy('quiz_index', 'DESC')
->findAll();
$maxQuizNumber = 0;
foreach ($existingQuizNumbers as $row) {
if (is_numeric($row['quiz_index'])) {
$maxQuizNumber = max($maxQuizNumber, (int)$row['quiz_index']);
}
}
$nextQuizNumber = $maxQuizNumber + 1;
if ($nextQuizNumber <= 0) {
return $this->respondError('Invalid quiz number', Response::HTTP_BAD_REQUEST);
}
$students = $this->studentClass
->where('class_section_id', $classSectionId)
->findAll();
if (empty($students)) {
return $this->respondError('No students found in this class section.', Response::HTTP_NOT_FOUND);
}
$firstInsertedId = null;
$insertedCount = 0;
foreach ($students as $i => $student) {
$exists = $this->quiz->where([
'student_id' => $student['student_id'],
'quiz_index' => $nextQuizNumber,
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
])->first();
if ($exists) continue;
$studentRecord = $this->student->find($student['student_id']);
$insertData = [
'student_id' => $student['student_id'],
'school_id' => $studentRecord['school_id'] ?? '',
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'quiz_index' => $nextQuizNumber,
'score' => null,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'created_at' => utc_now(),
'updated_at' => utc_now(),
];
$id = $this->quiz->insert($insertData);
if ($i === 0) {
$firstInsertedId = $id;
}
$insertedCount++;
}
return $this->success([
'status' => 'success',
'quiz_index' => $nextQuizNumber,
'new_quiz_id' => $firstInsertedId,
'students_processed' => $insertedCount
], 'Quiz column added successfully');
} catch (\Throwable $e) {
log_message('error', 'Add quiz column error: ' . $e->getMessage());
return $this->respondError('Failed to add quiz column: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/quizzes/management
* Get quiz management data for a class section
*/
public function showQuizMngt()
{
// Accept POST first, then GET (query string), then session fallback
$incomingId = $this->request->getPost('class_section_id');
if (!$incomingId) {
$incomingId = $this->request->getGet('class_section_id');
}
$classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0));
if ($classSectionId <= 0) {
return $this->respondError('No class section found for the current teacher or selection.', Response::HTTP_BAD_REQUEST);
}
session()->put('class_section_id', $classSectionId);
$quizScores = $this->getQuizScoresByStudent($classSectionId, $this->semester, $this->schoolYear);
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $this->schoolYear);
$quizHeaders = $this->getQuizHeaders($classSectionId, $this->semester, $this->schoolYear);
return $this->success([
'quiz_scores' => $quizScores,
'students' => $students,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'quiz_headers' => $quizHeaders,
'class_section_id' => $classSectionId,
], 'Quiz management data retrieved successfully');
}
/**
* POST /api/v1/quizzes/update
* Update quiz scores (management endpoint)
*/
public function updateQuiz()
{
$payload = $this->payloadData();
$scores = $payload['scores'] ?? null;
$studentIds = $payload['student_ids'] ?? null;
$semester = $payload['semester'] ?? $this->semester;
$schoolYear = $payload['school_year'] ?? $this->schoolYear;
$updatedBy = $payload['teacher_id'] ?? $this->getCurrentUserId();
$classSectionId = (int) ($payload['class_section_id'] ?? session()->get('class_section_id') ?? 0);
if (!$updatedBy) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
if ($classSectionId <= 0) {
return $this->respondError('Class section ID is required', Response::HTTP_BAD_REQUEST);
}
if ($scores && $studentIds && $semester && $schoolYear) {
try {
// Update scores using the same logic as updateQuizScores
foreach ($scores as $studentId => $quizData) {
if (!is_numeric($studentId) || $studentId <= 0) continue;
// Normalize: if single input submitted
if (!is_array($quizData)) {
$quizData = [1 => $quizData];
}
foreach ($quizData as $quizNumber => $score) {
if (!is_numeric($quizNumber) || !is_numeric($score)) continue;
$existing = $this->quiz->where([
'student_id' => $studentId,
'quiz_index' => $quizNumber,
'class_section_id' => $classSectionId,
'semester' => $semester
])->first();
if ($existing) {
$this->quiz->update($existing['id'], [
'score' => is_numeric($score) ? (float)$score : null,
'updated_at' => utc_now()
]);
} else {
$student = $this->student->find($studentId);
$this->quiz->insert([
'student_id' => $studentId,
'school_id' => $student['school_id'] ?? '',
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'quiz_index' => $quizNumber,
'score' => is_numeric($score) ? (float)$score : null,
'semester' => $semester,
'school_year' => $schoolYear,
'created_at' => utc_now(),
'updated_at' => utc_now()
]);
}
}
}
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId($classSectionId);
// Call the updateScoresForStudents method
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
} catch (RuntimeException $e) {
log_message('error', 'Failed to update semester scores: ' . $e->getMessage());
}
return $this->success(null, 'Quiz scores updated successfully.');
} catch (\Throwable $e) {
log_message('error', 'Update quiz error: ' . $e->getMessage());
return $this->respondError('Failed to update scores: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
return $this->respondError('Failed to update scores. Missing required data.', Response::HTTP_BAD_REQUEST);
}
/**
* Helper: Get students by class section and year
*/
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
{
// Step 1: Get student IDs from student_class table
$studentClassRows = $this->studentClass
->select('student_id')
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->findAll();
$studentIds = array_column($studentClassRows, 'student_id');
if (empty($studentIds)) {
return []; // No students found
}
// Step 2: Get student data from students table
$students = $this->student
->whereIn('id', $studentIds)
->orderBy('lastname', 'ASC')
->findAll();
return $students;
}
/**
* Helper: Get quiz headers for a class section
*/
private function getQuizHeaders($classSectionId, $semester, $schoolYear)
{
$rows = $this->quiz
->select('quiz_index')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->orderBy('quiz_index', 'ASC')
->findAll();
$headers = [];
foreach ($rows as $row) {
$index = $row['quiz_index'];
if (!is_array($index)) {
$headers[$index] = true;
}
}
ksort($headers);
return array_keys($headers);
}
/**
* Helper: Get quiz scores by student
*/
private function getQuizScoresByStudent($classSectionId, $semester, $schoolYear)
{
$rows = $this->quiz
->select('student_id, quiz_index, score')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
$studentScores = [];
foreach ($rows as $row) {
$studentId = $row['student_id'];
$index = $row['quiz_index'];
$score = $row['score'];
$studentScores[$studentId]['scores'][$index] = $score;
}
return $studentScores;
}
}
-126
View File
@@ -1,126 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Student;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class RFIDController extends BaseApiController
{
protected User $user;
protected Student $student;
public function __construct()
{
parent::__construct();
$this->user = model(User::class);
$this->student = model(Student::class);
}
public function process()
{
$data = $this->payloadData();
if (empty($data)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'rfid' => 'required',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
try {
$rfidTag = $data['rfid'];
$user = $this->user->where('rfid_tag', $rfidTag)->first();
$student = null;
if (!$user) {
$student = $this->student->where('rfid_tag', $rfidTag)->first();
}
if (!$user && !$student) {
return $this->error('RFID tag not recognized', Response::HTTP_NOT_FOUND);
}
$logData = [
'user_id' => $user['id'] ?? null,
'student_id' => $student['id'] ?? null,
'card_id' => $rfidTag,
'scan_time' => Carbon::now('UTC')->toDateTimeString(),
];
DB::table('scan_log')->insert($logData);
$result = [
'rfid_tag' => $rfidTag,
'user' => $user ? [
'id' => $user['id'],
'name' => trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')),
] : null,
'student' => $student ? [
'id' => $student['id'],
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
] : null,
];
return $this->success($result, 'RFID processed successfully');
} catch (\Throwable $e) {
log_message('error', 'RFID process error: ' . $e->getMessage());
return $this->respondError('Failed to process RFID', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function logs()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
try {
$builder = DB::table('scan_log as sl')
->select([
'u.firstname as user_firstname',
'u.lastname as user_lastname',
's.firstname as student_firstname',
's.lastname as student_lastname',
'sl.card_id',
'sl.scan_time',
])
->leftJoin('users as u', 'u.id', '=', 'sl.user_id')
->leftJoin('students as s', 's.rfid_tag', '=', 'sl.card_id');
$offset = ($page - 1) * $perPage;
$total = (clone $builder)->count();
$logs = (clone $builder)
->orderBy('sl.scan_time', 'DESC')
->offset($offset)
->limit($perPage)
->get()
->toArray();
$result = [
'data' => $logs,
'pagination' => [
'current_page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => (int) ceil($total / $perPage),
],
];
return $this->success($result, 'RFID scan logs retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'RFID logs error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve RFID logs', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,151 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Refund;
use Carbon\Carbon;
use Symfony\Component\HttpFoundation\Response;
class RefundController extends BaseApiController
{
protected Refund $refund;
protected Configuration $config;
public function __construct()
{
parent::__construct();
$this->refund = model(Refund::class);
$this->config = model(Configuration::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$parentId = $this->request->getGet('parent_id');
$status = $this->request->getGet('status');
$query = $this->refund->newQuery();
if ($parentId) {
$query->where('parent_id', $parentId);
}
if ($status) {
$query->where('status', $status);
}
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Refunds retrieved successfully');
}
public function show($id = null)
{
$refund = $this->refund->find($id);
if (!$refund) {
return $this->error('Refund not found', Response::HTTP_NOT_FOUND);
}
return $this->success($refund, 'Refund retrieved successfully');
}
public function store()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'parent_id' => 'required|integer',
'invoice_id' => 'required|integer',
'refund_amount' => 'required|numeric',
'reason' => 'required',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$schoolYear = $this->config->getConfig('school_year');
$semester = $this->config->getConfig('semester');
$refundData = [
'parent_id' => $data['parent_id'],
'invoice_id' => $data['invoice_id'],
'refund_amount' => $data['refund_amount'],
'reason' => $data['reason'],
'request' => $data['request'] ?? null,
'status' => 'pending',
'requested_at' => Carbon::now('UTC')->toDateTimeString(),
'school_year' => $schoolYear,
'semester' => $semester,
];
try {
$refund = $this->refund->create($refundData);
return $this->success($refund->toArray(), 'Refund request created successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'Refund creation error: ' . $e->getMessage());
return $this->respondError('Failed to create refund request', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update($id = null)
{
$refund = $this->refund->find($id);
if (!$refund) {
return $this->error('Refund not found', Response::HTTP_NOT_FOUND);
}
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$allowedFields = ['status', 'refund_paid_amount', 'refund_method', 'check_nbr', 'note'];
$updateData = [];
foreach ($allowedFields as $field) {
if (array_key_exists($field, $data)) {
$updateData[$field] = $data[$field];
}
}
if (isset($updateData['status']) && $updateData['status'] === 'approved') {
$updateData['approved_at'] = Carbon::now('UTC')->toDateTimeString();
$updateData['approved_by'] = $user->id;
}
if (isset($updateData['status']) && $updateData['status'] === 'refunded') {
$updateData['refunded_at'] = Carbon::now('UTC')->toDateTimeString();
}
if (empty($updateData)) {
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
}
$updateData['updated_by'] = $user->id;
try {
$this->refund->update($id, $updateData);
$updatedRefund = $this->refund->find($id);
return $this->success($updatedRefund, 'Refund updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Refund update error: ' . $e->getMessage());
return $this->respondError('Failed to update refund', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,357 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\ParentModel;
use App\Models\Role;
use App\Models\User;
use App\Models\UserRole;
use App\Services\EmailService;
use App\Services\PhoneFormatterService;
use App\Services\SchoolIdService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\Response;
class RegisterController extends BaseApiController
{
protected Configuration $config;
protected User $user;
protected UserRole $userRole;
protected Role $role;
protected Parent $parent;
protected SchoolIdService $schoolIdService;
protected EmailService $emailService;
protected PhoneFormatterService $phoneFormatter;
protected string $semester;
protected string $schoolYear;
public function __construct()
{
parent::__construct();
$this->config = model(Configuration::class);
$this->user = model(User::class);
$this->userRole = model(UserRole::class);
$this->role = model(Role::class);
$this->parent = model(ParentModel::class);
$this->schoolIdService = app(SchoolIdService::class);
$this->emailService = app(EmailService::class);
$this->phoneFormatter = app(PhoneFormatterService::class);
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
}
public function index(): JsonResponse
{
$session = session();
log_message('debug', 'CAPTCHA session value: ' . ($session->get('captcha_answer') ?? ''));
if (!$session->has('captcha_answer')) {
$length = random_int(4, 8);
$session->put('captcha_answer', $this->generateCaptchaText($length));
}
return $this->respondSuccess([
'captcha_question' => $session->get('captcha_answer'),
], 'Captcha generated');
}
public function registrationSuccess(): JsonResponse
{
$session = session();
if (!$session->has('user_email')) {
return $this->respondError('No registration session found.', Response::HTTP_BAD_REQUEST);
}
return $this->respondSuccess([
'email' => $session->get('user_email'),
], 'Registration success data');
}
public function register(): JsonResponse
{
$payload = $this->payloadData();
log_message('debug', 'CAPTCHA session value: ' . (session()->get('captcha_answer') ?? ''));
$formatted = $this->formatUserInput($payload);
$validationPayload = array_merge($payload, $formatted);
$captcha = (string) ($payload['captcha'] ?? '');
$isParent = !empty($payload['is_parent']) && (string) $payload['is_parent'] === '1';
$noSecondParentInfo = !empty($payload['no_second_parent_info']) && (string) $payload['no_second_parent_info'] === '1';
$rules = [
'firstname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'],
'lastname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'],
'gender' => ['required', Rule::in(['Male', 'Female'])],
'email' => ['required', 'email', 'max:50'],
'confirm_email' => ['required', 'same:email'],
'cellphone' => ['required', 'regex:/^[\d\s\-\(\)\.]+$/', 'min:10', 'max:20'],
'address_street' => ['nullable', 'regex:/^[a-zA-Z0-9\s\-]+$/', 'max:150'],
'apt' => ['nullable', 'regex:/^[a-zA-Z0-9\s-]+$/', 'max:10'],
'city' => ['required', 'regex:/^[a-zA-Z\s]+$/', 'min:2', 'max:30'],
'state' => ['required', Rule::in(['CT', 'ME', 'MA', 'NH', 'NY', 'RI', 'VT'])],
'zip' => ['required', 'regex:/^\d{5}$/'],
'accept_school_policy' => ['required', 'accepted'],
'captcha' => ['required', 'alpha_num', 'min:4', 'max:10'],
];
if ($isParent && !$noSecondParentInfo) {
$rules = array_merge($rules, [
'second_firstname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'],
'second_lastname' => ['required', 'regex:/^[a-zA-Z\s-]+$/', 'min:2', 'max:30'],
'second_gender' => ['required', Rule::in(['Male', 'Female'])],
'second_email' => ['required', 'email', 'max:50'],
'second_cellphone' => ['required', 'regex:/^[\d\s\-\(\)\.]+$/', 'min:10', 'max:20'],
]);
}
$errors = $this->validateRequest($validationPayload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
if ($isParent && !$noSecondParentInfo && !$this->hasSecondParentData($formatted)) {
return $this->respondValidationError([
'second_parent_info' => ['As a parent, please provide second parent information or check "No second parent info".'],
]);
}
if ($captcha !== (string) session()->get('captcha_answer')) {
return $this->respondValidationError([
'captcha' => ['Incorrect CAPTCHA answer. Please try again.'],
], 'Incorrect CAPTCHA answer.');
}
$email = $formatted['email'] ?? '';
$existingUser = $this->user->newQuery()->where('email', $email)->first();
if ($existingUser) {
$isVerified = (int) ($existingUser->is_verified ?? 0);
$token = $existingUser->token ?? null;
if (!empty($token) && $isVerified === 0) {
return $this->respondError(
'This email address is already registered and is pending activation. Please check your email to activate your account.',
Response::HTTP_CONFLICT
);
}
return $this->respondError(
'The email address you entered is already in use. Please try a different one.',
Response::HTTP_CONFLICT
);
}
$roleName = $isParent ? 'parent' : 'guest';
$role = $this->role->newQuery()->where('name', $roleName)->first();
if (!$role) {
return $this->respondError('Role not found', Response::HTTP_BAD_REQUEST);
}
$token = bin2hex(random_bytes(48));
$userData = [
'firstname' => $formatted['firstname'] ?? null,
'lastname' => $formatted['lastname'] ?? null,
'gender' => $formatted['gender'] ?? null,
'email' => $email,
'cellphone' => $formatted['cellphone'] ?? null,
'address_street' => $formatted['address_street'] ?? null,
'apt' => $formatted['apt'] ?? null,
'city' => $formatted['city'] ?? null,
'state' => $formatted['state'] ?? null,
'zip' => $formatted['zip'] ?? null,
'token' => $token,
'is_verified' => 0,
'accept_school_policy' => (int) ($formatted['accept_school_policy'] ?? 0),
'status' => 'Inactive',
'school_id' => $this->schoolIdService->generate(),
'semester' => $this->semester,
'school_year' => $this->schoolYear,
];
DB::beginTransaction();
try {
$user = $this->user->newQuery()->create($userData);
$firstParentId = $user->id ?? null;
if (!$firstParentId) {
DB::rollBack();
return $this->respondError('Registration failed, please try again.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
$this->userRole->newQuery()->create([
'user_id' => $firstParentId,
'role_id' => $role->id,
'semester' => $this->semester,
'school_year'=> $this->schoolYear,
'created_at' => utc_now(),
]);
if ($isParent && !$noSecondParentInfo && $this->hasSecondParentData($formatted)) {
$this->parent->newQuery()->create([
'secondparent_firstname' => $formatted['second_firstname'] ?? null,
'secondparent_lastname' => $formatted['second_lastname'] ?? null,
'secondparent_gender' => $formatted['second_gender'] ?? null,
'secondparent_email' => $formatted['second_email'] ?? null,
'secondparent_phone' => $formatted['second_cellphone'] ?? null,
'firstparent_id' => $firstParentId,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
]);
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
log_message('error', 'Registration failed: ' . $e->getMessage());
return $this->respondError('Registration failed, please try again.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
$recipientName = trim(($formatted['firstname'] ?? '') . ' ' . ($formatted['lastname'] ?? ''));
$emailData = [
'recipientName' => $recipientName,
'activationLink'=> url('/user/confirm/' . $token),
'orgName' => 'Al Rahma Sunday School',
'contactInfo' => 'alrahma.isgl@gmail.com',
'logoUrl' => 'https://alrahmaisgl.org/assets/images/alrahma_logo.png',
];
$html = $this->buildEmailBody($emailData, $isParent);
$this->emailService->send($email, 'Email Confirmation', $html, 'general');
session()->put('user_email', $email);
session()->forget('captcha_answer');
return $this->respondSuccess([
'email' => $email,
], 'Registration successful');
}
private function generateCaptchaText(int $length): string
{
$characters = 'ABCDEFGHJKLMNOPQRSTUVWXYZabcdefghjkmnopqrstuvwxyz0123456789';
$captchaText = '';
for ($i = 0; $i < $length; $i++) {
$captchaText .= $characters[random_int(0, strlen($characters) - 1)];
}
return $captchaText;
}
private function formatUserInput(array $post): array
{
$g = static function (string $key) use ($post): string {
return trim((string) ($post[$key] ?? ''));
};
$data = [
'firstname' => $this->formatName($g('firstname')),
'lastname' => $this->formatName($g('lastname')),
'gender' => $g('gender'),
'email' => $this->formatEmail($g('email')),
'confirm_email' => $this->formatEmail($g('confirm_email')),
'cellphone' => $this->phoneFormatter->formatPhoneNumber($g('cellphone')),
'address_street' => $this->nullIfEmpty($this->formatAddress($g('address_street'))),
'apt' => $this->nullIfEmpty(strtoupper($g('apt'))),
'city' => $this->formatName($g('city')),
'state' => strtoupper($g('state')),
'zip' => $g('zip'),
'accept_school_policy' => (int) ($post['accept_school_policy'] ?? 0),
];
$noSecondInfo = !empty($post['no_second_parent_info']);
$second_firstname = $this->formatName($g('second_firstname'));
$second_lastname = $this->formatName($g('second_lastname'));
$second_gender = $g('second_gender');
$second_email = $this->formatEmail($g('second_email'));
$second_cellphone = $this->phoneFormatter->formatPhoneNumber($g('second_cellphone'));
$secondProvided = !$noSecondInfo && (
$second_firstname !== '' ||
$second_lastname !== '' ||
$second_gender !== '' ||
$second_email !== '' ||
preg_replace('/\D/', '', $second_cellphone) !== ''
);
if ($secondProvided) {
$data['second_firstname'] = $second_firstname;
$data['second_lastname'] = $second_lastname;
$data['second_gender'] = $second_gender;
$data['second_email'] = $second_email;
$data['second_cellphone'] = $second_cellphone;
}
return $data;
}
private function formatName(string $name): string
{
$name = trim($name);
return ucwords(strtolower($name), " -");
}
private function formatEmail(?string $email): string
{
return strtolower(trim((string) $email));
}
private function formatAddress(?string $address): string
{
$address = trim((string) $address);
if ($address === '') {
return '';
}
return ucwords(strtolower($address));
}
private function nullIfEmpty(?string $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function hasSecondParentData(array $data): bool
{
return !empty($data['second_firstname'])
|| !empty($data['second_lastname'])
|| !empty($data['second_gender'])
|| !empty($data['second_email'])
|| (!empty($data['second_cellphone']) && preg_replace('/\D/', '', $data['second_cellphone']) !== '');
}
private function buildEmailBody(array $data, bool $isParent): string
{
$audience = $isParent ? 'Parent' : 'Staff Member';
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Email Confirmation</title>
</head>
<body>
<p>Dear {$data['recipientName']},</p>
<p>Thank you for registering with {$data['orgName']} as a {$audience}. Please confirm your email address by clicking the link below:</p>
<p><a href="{$data['activationLink']}" target="_blank">Activate My Account</a></p>
<p>If the link above does not work, copy and paste this URL into your browser:</p>
<p>{$data['activationLink']}</p>
<p>For questions, reach us at {$data['contactInfo']}.</p>
<p>Thank you,<br>{$data['orgName']}</p>
</body>
</html>
HTML;
}
}
@@ -1,149 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Reimbursement;
use Symfony\Component\HttpFoundation\Response;
class ReimbursementController extends BaseApiController
{
protected Reimbursement $reimbursement;
protected Configuration $config;
public function __construct()
{
parent::__construct();
$this->reimbursement = model(Reimbursement::class);
$this->config = model(Configuration::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$status = $this->request->getGet('status');
$reimbursedTo = $this->request->getGet('reimbursed_to');
$schoolYear = $this->config->getConfig('school_year');
$semester = $this->config->getConfig('semester');
$query = $this->reimbursement->newQuery()
->where('school_year', $schoolYear)
->where('semester', $semester);
if (!empty($status)) {
$query->where('status', $status);
}
if (!empty($reimbursedTo)) {
$query->where('reimbursed_to', $reimbursedTo);
}
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Reimbursements retrieved successfully');
}
public function show($id = null)
{
$reimbursement = $this->reimbursement->find($id);
if (!$reimbursement) {
return $this->error('Reimbursement not found', Response::HTTP_NOT_FOUND);
}
return $this->success($reimbursement, 'Reimbursement retrieved successfully');
}
public function store()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'amount' => 'required|numeric',
'reimbursed_to' => 'required|integer',
'description' => 'required',
'expense_id' => 'required|integer',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$schoolYear = $this->config->getConfig('school_year');
$semester = $this->config->getConfig('semester');
$reimbursementData = [
'amount' => $data['amount'],
'reimbursed_to' => $data['reimbursed_to'],
'description' => $data['description'],
'expense_id' => $data['expense_id'],
'added_by' => $user->id,
'school_year' => $schoolYear,
'semester' => $semester,
'status' => $data['status'] ?? 'pending',
'reimbursement_method' => $data['reimbursement_method'] ?? null,
'check_number' => $data['check_number'] ?? null,
];
try {
$reimbursement = $this->reimbursement->create($reimbursementData);
return $this->success($reimbursement->toArray(), 'Reimbursement created successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'Reimbursement creation error: ' . $e->getMessage());
return $this->respondError('Failed to create reimbursement', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update($id = null)
{
$reimbursement = $this->reimbursement->find($id);
if (!$reimbursement) {
return $this->error('Reimbursement not found', Response::HTTP_NOT_FOUND);
}
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$allowedFields = ['status', 'reimbursement_method', 'check_number'];
$updateData = [];
foreach ($allowedFields as $field) {
if (array_key_exists($field, $data)) {
$updateData[$field] = $data[$field];
}
}
if (isset($updateData['status']) && $updateData['status'] === 'approved') {
$updateData['approved_by'] = $user->id;
}
if (empty($updateData)) {
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
}
try {
$this->reimbursement->update($id, $updateData);
$updatedReimbursement = $this->reimbursement->find($id);
return $this->success($updatedReimbursement, 'Reimbursement updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Reimbursement update error: ' . $e->getMessage());
return $this->respondError('Failed to update reimbursement', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,727 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Permission;
use App\Models\Role;
use App\Models\RolePermission;
use App\Models\Staff;
use App\Models\User;
use App\Models\UserRole;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\Response;
class RolePermissionController extends BaseApiController
{
protected Role $role;
protected Permission $permission;
protected RolePermission $rolePermission;
protected User $user;
protected UserRole $userRole;
protected Staff $staff;
protected Configuration $config;
protected string $schoolYear;
protected string $semester;
public function __construct()
{
parent::__construct();
$this->role = model(Role::class);
$this->permission = model(Permission::class);
$this->rolePermission = model(RolePermission::class);
$this->user = model(User::class);
$this->userRole = model(UserRole::class);
$this->staff = model(Staff::class);
$this->config = model(Configuration::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
}
/**
* GET /api/v1/rolepermission/roles
* List all roles with user counts
*/
public function roles()
{
$rows = DB::table('roles')
->select('roles.id', 'roles.name', 'roles.description', DB::raw('COUNT(DISTINCT user_roles.user_id) AS user_count'))
->leftJoin('user_roles', 'user_roles.role_id', '=', 'roles.id')
->groupBy('roles.id', 'roles.name', 'roles.description')
->orderBy('roles.name', 'ASC')
->get()
->toArray();
$normalized = array_map(static function ($row) {
$row = (array) $row;
if (!is_array($row)) {
return [];
}
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'description' => $row['description'] ?? null,
'user_count' => (int) ($row['user_count'] ?? 0),
];
}, $rows ?? []);
return $this->success(['roles' => $normalized], 'Roles retrieved successfully');
}
/**
* GET /api/v1/rolepermission/roles/{id}
* Get a single role
*/
public function showRole($id = null)
{
$role = $this->role->find($id);
if (!$role) {
return $this->error('Role not found', Response::HTTP_NOT_FOUND);
}
return $this->success($role, 'Role retrieved successfully');
}
/**
* POST /api/v1/rolepermission/roles
* Create a new role
*/
public function storeRole()
{
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'name' => 'required|min:3|max:255|unique:roles,name',
'slug' => 'nullable|max:64|unique:roles,slug',
'description' => 'nullable|max:500',
'dashboard_route' => ['required', 'min:1', 'max:255', 'regex:/^[a-z0-9_\/\-]+$/'],
'priority' => 'required|integer|min:0|max:100000',
'is_active' => 'required|in:0,1',
];
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$rawName = trim((string) ($payload['name'] ?? ''));
$rawSlug = trim((string) ($payload['slug'] ?? ''));
$route = trim((string) ($payload['dashboard_route'] ?? ''));
$desc = trim((string) ($payload['description'] ?? ''));
$priority = (int) ($payload['priority'] ?? 0);
$isActive = (string) ($payload['is_active'] ?? '0') === '1' ? 1 : 0;
$name = strtolower($rawName);
$dashboard_route = strtolower($route);
$description = strtolower($desc);
// Build slug: from input if provided, else from name
$slug = $rawSlug !== '' ? $rawSlug : $name;
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $slug));
$slug = trim($slug, '_');
$slug = $this->ensureUniqueSlug($slug);
$data = [
'name' => $name,
'slug' => $slug,
'description' => $description,
'dashboard_route' => $dashboard_route,
'priority' => $priority,
'is_active' => $isActive,
'created_at' => utc_now(),
'updated_at' => utc_now(),
];
try {
$role = $this->role->create($data);
return $this->success($role->toArray(), 'Role created successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'Role creation error: ' . $e->getMessage());
return $this->respondError('Failed to create role', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* PATCH /api/v1/rolepermission/roles/{id}
* Update a role
*/
public function updateRole($id = null)
{
$role = $this->role->find($id);
if (!$role) {
return $this->error('Role not found', Response::HTTP_NOT_FOUND);
}
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'name' => ['required', 'min:3', 'max:255', Rule::unique('roles', 'name')->ignore($id)],
'slug' => ['nullable', 'max:64', Rule::unique('roles', 'slug')->ignore($id)],
'description' => 'nullable|max:500',
'dashboard_route' => ['required', 'min:1', 'max:255', 'regex:/^[a-z0-9_\/\-]+$/'],
'priority' => 'required|integer|min:0|max:100000',
'is_active' => 'required|in:0,1',
];
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$rawName = trim((string) ($payload['name'] ?? ''));
$rawSlug = trim((string) ($payload['slug'] ?? ''));
$route = trim((string) ($payload['dashboard_route'] ?? ''));
$desc = trim((string) ($payload['description'] ?? ''));
$priority = (int) ($payload['priority'] ?? 0);
$isActive = (string) ($payload['is_active'] ?? '0') === '1' ? 1 : 0;
$name = strtolower($rawName);
$dashboard_route = strtolower($route);
$description = strtolower($desc);
// slug: if blank, derive from name; else normalize
$slug = $rawSlug !== '' ? $rawSlug : $name;
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $slug));
$slug = trim($slug, '_');
// Ensure unique slug if changed
if ($slug !== $role['slug']) {
$slug = $this->ensureUniqueSlug($slug, (int) $id);
}
$data = [
'name' => $name,
'slug' => $slug,
'description' => $description,
'dashboard_route' => $dashboard_route,
'priority' => $priority,
'is_active' => $isActive,
'updated_at' => utc_now(),
];
try {
$this->role->update($id, $data);
$updatedRole = $this->role->find($id);
return $this->success($updatedRole, 'Role updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Role update error: ' . $e->getMessage());
return $this->respondError('Failed to update role', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* DELETE /api/v1/rolepermission/roles/{id}
* Delete a role
*/
public function deleteRole($id = null)
{
$role = $this->role->find($id);
if (!$role) {
return $this->error('Role not found', Response::HTTP_NOT_FOUND);
}
try {
$this->role->delete($id);
return $this->success(null, 'Role deleted successfully');
} catch (\Throwable $e) {
log_message('error', 'Role deletion error: ' . $e->getMessage());
return $this->respondError('Failed to delete role', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/rolepermission/permissions
* List all permissions
*/
public function permissions()
{
$rows = $this->permission
->select('id, name, description, created_at, updated_at')
->orderBy('id', 'ASC')
->findAll();
$normalized = array_map(static function ($row) {
if (!is_array($row)) {
return [];
}
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'description' => $row['description'] ?? null,
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
}, $rows ?? []);
return $this->success(['permissions' => $normalized], 'Permissions retrieved successfully');
}
/**
* POST /api/v1/rolepermission/permissions
* Create a new permission
*/
public function storePermission()
{
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$name = trim((string) ($payload['name'] ?? ''));
if ($name === '') {
return $this->respondError('Permission name is required', Response::HTTP_BAD_REQUEST);
}
// Check existence
$existing = $this->permission->where('name', $name)->first();
if ($existing) {
return $this->success($existing, 'Permission already exists. No changes made.');
}
$data = [
'name' => $name,
'description' => trim((string) ($payload['description'] ?? '')),
];
try {
$permission = $this->permission->create($data);
return $this->success($permission->toArray(), 'Permission created successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'Permission creation error: ' . $e->getMessage());
return $this->respondError('Failed to create permission', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* PATCH /api/v1/rolepermission/permissions/{id}
* Update a permission
*/
public function updatePermission($id = null)
{
$permission = $this->permission->find($id);
if (!$permission) {
return $this->error('Permission not found', Response::HTTP_NOT_FOUND);
}
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$data = [
'id' => $id,
'name' => trim((string) ($payload['name'] ?? '')),
'description' => trim((string) ($payload['description'] ?? '')),
];
try {
$this->permission->update($id, $data);
$updatedPermission = $this->permission->find($id);
return $this->success($updatedPermission, 'Permission updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Permission update error: ' . $e->getMessage());
return $this->respondError('Failed to update permission', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* DELETE /api/v1/rolepermission/permissions/{id}
* Delete a permission
*/
public function deletePermission($id = null)
{
$permission = $this->permission->find($id);
if (!$permission) {
return $this->error('Permission not found', Response::HTTP_NOT_FOUND);
}
try {
$this->permission->delete($id);
return $this->success(null, 'Permission deleted successfully');
} catch (\Throwable $e) {
log_message('error', 'Permission deletion error: ' . $e->getMessage());
return $this->respondError('Failed to delete permission', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/rolepermission/roles/{roleId}/permissions
* Get permissions for a specific role
*/
public function rolePermissions($roleId = null)
{
$role = $this->role->find($roleId);
if (!$role) {
return $this->error('Role not found', Response::HTTP_NOT_FOUND);
}
$permissions = $this->rolePermission
->where('role_id', $roleId)
->findAll();
// Get all permissions with role permission data
$allPermissions = $this->permission->findAll();
$existingPermissions = [];
foreach ($permissions as $rolePermission) {
$existingPermissions[$rolePermission['permission_id']] = [
'can_create' => $rolePermission['can_create'],
'can_read' => $rolePermission['can_read'],
'can_update' => $rolePermission['can_update'],
'can_delete' => $rolePermission['can_delete'],
];
}
return $this->success([
'role' => $role,
'permissions' => $allPermissions,
'role_permissions' => $existingPermissions,
], 'Role permissions retrieved successfully');
}
/**
* POST /api/v1/rolepermission/roles/{roleId}/permissions
* Save role permissions
*/
public function saveRolePermissions($roleId = null)
{
$role = $this->role->find($roleId);
if (!$role) {
return $this->error('Role not found', Response::HTTP_NOT_FOUND);
}
$payload = $this->payloadData();
$permissions = $payload['permissions'] ?? null;
if (!$permissions || !is_array($permissions)) {
return $this->respondError('Permissions data is required', Response::HTTP_BAD_REQUEST);
}
// Fetch existing permissions for the role
$rolePermissions = $this->rolePermission->where('role_id', $roleId)->findAll();
$existingPermissions = [];
foreach ($rolePermissions as $rolePermission) {
$existingPermissions[$rolePermission['permission_id']] = $rolePermission;
}
try {
DB::beginTransaction();
// Process the new permissions
foreach ($permissions as $permissionId => $crudValues) {
$permissionData = [
'can_create' => isset($crudValues['create']) ? 1 : 0,
'can_read' => isset($crudValues['read']) ? 1 : 0,
'can_update' => isset($crudValues['update']) ? 1 : 0,
'can_delete' => isset($crudValues['delete']) ? 1 : 0,
'updated_at' => utc_now(),
];
if (isset($existingPermissions[$permissionId])) {
// Update existing permission
$this->rolePermission->where('role_id', $roleId)
->where('permission_id', $permissionId)
->update($permissionData);
} else {
// Insert new permission
$permissionData['role_id'] = $roleId;
$permissionData['permission_id'] = $permissionId;
$permissionData['created_at'] = utc_now();
$this->rolePermission->insert($permissionData);
}
}
// Remove permissions that are no longer present
foreach ($existingPermissions as $permissionId => $permissionData) {
if (!isset($permissions[$permissionId])) {
$this->rolePermission->where('role_id', $roleId)
->where('permission_id', $permissionId)
->delete();
}
}
DB::commit();
return $this->success(null, 'Permissions saved successfully');
} catch (\Throwable $e) {
DB::rollBack();
log_message('error', 'Failed to save permissions: ' . $e->getMessage());
return $this->respondError('Failed to save permissions: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/rolepermission/users
* Get users with their roles
*/
public function users()
{
$users = $this->buildUsersWithRolesPayload();
return $this->success($users, 'Users with roles retrieved successfully');
}
/**
* POST /api/v1/rolepermission/users/{userId}/roles
* Assign roles to a user
*/
public function assignRole($userId = null)
{
$user = $this->user->find($userId);
if (!$user) {
return $this->error('User not found', Response::HTTP_NOT_FOUND);
}
$payload = $this->payloadData();
$roleIdsRaw = $payload['role_ids'] ?? [];
$roleIdsArray = is_array($roleIdsRaw) ? $roleIdsRaw : ($roleIdsRaw !== null ? [$roleIdsRaw] : []);
$roleIds = array_values(array_unique(array_filter(array_map('intval', $roleIdsArray), fn($id) => $id > 0)));
$adminId = $this->getCurrentUserId();
if (!$adminId) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
try {
DB::beginTransaction();
// Remove all existing roles for the user
$this->userRole->where('user_id', $userId)->delete();
// Assign new roles
if (!empty($roleIds)) {
foreach ($roleIds as $roleId) {
$this->userRole->insert([
'user_id' => $userId,
'role_id' => $roleId,
'created_at' => utc_now(),
'updated_by' => $adminId
]);
}
}
// Load role names after saving
$assignedRoles = !empty($roleIds)
? $this->role->whereIn('id', $roleIds)->findAll()
: [];
$roleNames = array_map(fn($r) => strtolower($r['name']), $assignedRoles ?? []);
// Update staff record if needed
$this->updateStaffRecord($userId, $roleNames);
DB::commit();
return $this->success(null, 'Roles updated successfully');
} catch (\Throwable $e) {
DB::rollBack();
log_message('error', 'Error updating roles: ' . $e->getMessage());
return $this->respondError('Failed to update roles: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/rolepermission/admins
* Get admin users
*/
public function admins()
{
$admins = $this->user->newQuery()
->select(['users.id', 'users.firstname', 'users.lastname', 'users.email'])
->join('user_roles', 'user_roles.user_id', '=', 'users.id')
->join('roles', 'roles.id', '=', 'user_roles.role_id')
->where('roles.name', 'admin')
->groupBy('users.id', 'users.firstname', 'users.lastname', 'users.email')
->get()
->toArray();
return $this->success($admins, 'Admin users retrieved successfully');
}
/**
* Helper: Build users with roles payload
*/
private function buildUsersWithRolesPayload(): array
{
$rows = DB::table('users')
->select('users.id', 'users.firstname', 'users.lastname', 'users.email', 'users.account_id', 'roles.name AS role_name')
->leftJoin('user_roles', 'users.id', '=', 'user_roles.user_id')
->leftJoin('roles', 'user_roles.role_id', '=', 'roles.id')
->orderBy('users.id', 'ASC')
->get()
->toArray();
$users = [];
foreach ($rows as $row) {
$row = (array) $row;
if (!is_array($row)) {
continue;
}
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
if (!isset($users[$id])) {
$users[$id] = [
'id' => $id,
'firstname' => $row['firstname'] ?? '',
'lastname' => $row['lastname'] ?? '',
'email' => $row['email'] ?? '',
'account_id' => $row['account_id'] ?? '',
'roles' => [],
];
}
$roleName = $row['role_name'] ?? null;
if ($roleName) {
$users[$id]['roles'][] = $roleName;
}
}
$normalized = array_map(static function ($user) {
$roles = array_values(array_unique(array_filter($user['roles'] ?? [], 'strlen')));
return [
'id' => $user['id'],
'firstname' => $user['firstname'] ?? '',
'lastname' => $user['lastname'] ?? '',
'email' => $user['email'] ?? '',
'account_id' => $user['account_id'] ?? '',
'roles' => $roles,
];
}, array_values($users));
return ['users' => $normalized];
}
/**
* Helper: Update staff record based on roles
*/
private function updateStaffRecord(int $userId, array $newRoleNames): void
{
$excludedRoles = ['parent', 'student', 'guest'];
$loweredNewRoles = array_map('strtolower', $newRoleNames);
$user = $this->user->find($userId);
if (!$user) {
log_message('error', "updateStaffRecord: user_id {$userId} not found");
return;
}
// Fetch existing staff record if it exists
$existingStaff = $this->staff->where('user_id', $userId)->first();
// Extract existing roles from DB if any
$existingRoles = [];
if (!empty($existingStaff['role_name'])) {
$existingRoles = array_map('strtolower', array_map('trim', explode(',', $existingStaff['role_name'])));
}
// Merge and deduplicate roles
$allRoles = array_unique(array_merge($existingRoles, $loweredNewRoles));
// Determine staff roles (excluding parent/student/guest)
$staffRoles = array_filter($newRoleNames, fn($role) => !in_array($role, $excludedRoles));
// Determine active role
$activeRole = !empty($staffRoles) ? $staffRoles[0] : 'inactive';
$email = $this->generateUniqueStaffEmail($user['firstname'], $user['lastname'], $userId);
$now = utc_now();
$row = [
'user_id' => $userId,
'firstname' => $user['firstname'],
'lastname' => $user['lastname'],
'email' => $email,
'phone' => $user['cellphone'] ?? null,
'role_name' => implode(', ', $allRoles),
'active_role' => $activeRole,
'school_year' => $this->schoolYear,
'updated_at' => $now,
];
$this->staff->upsert($row);
}
/**
* Helper: Generate unique staff email
*/
private function generateUniqueStaffEmail(string $firstname, string $lastname, int $userId): string
{
$domain = '@alrahmaisgl.org';
// Sanitize names: lowercase, letters only
$firstname = strtolower(preg_replace('/[^a-z]/i', '', $firstname));
$lastname = strtolower(preg_replace('/[^a-z]/i', '', $lastname));
// Use at least 1 letter from firstname
$min = 1;
$max = strlen($firstname);
$base = '';
$email = '';
for ($i = $min; $i <= $max; $i++) {
$base = substr($firstname, 0, $i) . $lastname;
$email = $base . $domain;
// If email not taken or belongs to same user, use it
$exists = $this->staff->where('email', $email)
->where('user_id !=', $userId)
->first();
if (!$exists) {
return $email;
}
}
// If all combinations used, fallback to adding user ID
return $base . $userId . $domain;
}
/**
* Helper: Ensure slug is unique
*/
private function ensureUniqueSlug(string $baseSlug, ?int $ignoreId = null): string
{
$slug = $baseSlug;
$i = 2;
while (true) {
$builder = $this->role->where('slug', $slug);
if ($ignoreId !== null) {
$builder->where('id !=', $ignoreId);
}
$exists = $builder->countAllResults() > 0;
if (!$exists) {
break;
}
$slug = $baseSlug . '_' . $i;
$i++;
if ($i > 100000) { // safety stop
break;
}
}
return $slug;
}
}
@@ -1,166 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Role;
use App\Models\UserRole;
use Illuminate\Support\Facades\Session;
use Symfony\Component\HttpFoundation\Response;
class RoleSwitcherController extends BaseApiController
{
protected UserRole $userRole;
protected Role $role;
protected Configuration $config;
protected string $semester;
protected string $schoolYear;
public function __construct()
{
parent::__construct();
$this->userRole = model(UserRole::class);
$this->role = model(Role::class);
$this->config = model(Configuration::class);
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
}
/**
* GET /api/v1/role-switcher
* Get available roles for the current user
* If user has only one role, returns auto_redirect flag with route
*/
public function index()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->error('Authentication required', Response::HTTP_UNAUTHORIZED);
}
try {
$roles = $this->userRole
->select('roles.name')
->join('roles', 'roles.id', '=', 'user_roles.role_id')
->where('user_roles.user_id', $user->id)
->whereNull('user_roles.deleted_at')
->get()
->toArray();
if (empty($roles)) {
return $this->error('No roles assigned to this user', Response::HTTP_NOT_FOUND);
}
$roleNames = array_column($roles, 'name');
// If user has only one role, return auto-redirect information
if (count($roleNames) === 1) {
$roleName = $roleNames[0];
$route = $this->getDashboardRoute($roleName);
return $this->success([
'roles' => $roleNames,
'auto_redirect' => true,
'route' => $route,
'role' => $roleName,
], 'Single role found, auto-redirect available');
}
return $this->success([
'roles' => $roleNames,
'auto_redirect' => false,
'current_role' => Session::get('active_role'),
], 'Roles retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Get roles error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve roles', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/role-switcher/switch
* Switch to a specific role (role in request body)
*/
public function switchRole()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->error('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data) || !isset($data['role'])) {
return $this->error('Role is required', Response::HTTP_BAD_REQUEST);
}
return $this->switchToRole($data['role'], $user->id);
}
/**
* POST /api/v1/role-switcher/switch/{role}
* Switch to a specific role (role in URL parameter)
*/
public function switchTo($role)
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->error('Authentication required', Response::HTTP_UNAUTHORIZED);
}
return $this->switchToRole($role, $user->id);
}
/**
* Internal method to handle role switching logic
*/
private function switchToRole(string $role, int $userId)
{
try {
$roleKey = is_string($role) ? $role : (string) $role;
// Verify user has this role
$hasRole = $this->userRole
->select('roles.name')
->join('roles', 'roles.id', '=', 'user_roles.role_id')
->where('user_roles.user_id', $userId)
->where(function ($query) use ($roleKey) {
$query->where('LOWER(roles.name)', strtolower($roleKey))
->orWhere('LOWER(roles.slug)', strtolower($roleKey));
})
->whereNull('user_roles.deleted_at')
->first();
if (!$hasRole) {
return $this->error('You do not have this role', Response::HTTP_FORBIDDEN);
}
// Set active role in session
Session::put('active_role', $roleKey);
// Get dashboard route for this role
$route = $this->getDashboardRoute($roleKey);
log_message('debug', 'Redirecting user to: ' . $route);
return $this->success([
'role' => $roleKey,
'route' => $route,
'message' => 'Role switched successfully',
], 'Role switched successfully');
} catch (\Throwable $e) {
log_message('error', 'Role switch error: ' . $e->getMessage());
return $this->respondError('Failed to switch role', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Get dashboard route for a role
*/
private function getDashboardRoute(string $role): string
{
$key = is_string($role) ? $role : (string) $role;
$route = $this->role->getRouteByNameOrSlug($key);
return $route ?? '/landing_page/guest_dashboard';
}
}
@@ -1,331 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Calendar;
use App\Models\Configuration;
use Symfony\Component\HttpFoundation\Response;
class SchoolCalendarController extends BaseApiController
{
protected Calendar $calendar;
protected Configuration $config;
protected string $schoolYear;
protected string $semester;
public function __construct()
{
parent::__construct();
$this->calendar = model(Calendar::class);
$this->config = model(Configuration::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
}
/**
* GET /api/v1/school-calendar
* Get calendar events filtered by school_year and optional semester
*/
public function index()
{
$schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear);
$semester = $this->request->getGet('semester');
try {
$query = $this->calendar->newQuery();
if ($schoolYear !== '') {
$query->where('school_year', $schoolYear);
}
if ($semester !== null && $semester !== '') {
$events = $query->where('semester', $semester)->orderBy('date', 'ASC')->get()->toArray();
} else {
$events = $query->orderBy('date', 'ASC')->get()->toArray();
}
return $this->success([
'events' => $events,
'school_year' => $schoolYear,
'semester' => $semester,
], 'Calendar events retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'School calendar error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve calendar events', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/school-calendar/{id}
* Get a single calendar event
*/
public function show($id = null)
{
$event = $this->calendar->find($id);
if (!$event) {
return $this->error('Event not found', Response::HTTP_NOT_FOUND);
}
return $this->success($event, 'Event retrieved successfully');
}
/**
* POST /api/v1/school-calendar
* Create a new calendar event
*/
public function store()
{
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'title' => 'required|min_length[3]',
'start' => 'required|valid_date[Y-m-d]',
];
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$data = [
'title' => trim((string) ($payload['title'] ?? '')),
'description' => trim((string) ($payload['description'] ?? '')),
'date' => trim((string) ($payload['start'] ?? '')),
'notify_parent' => isset($payload['notify_parent']) && $payload['notify_parent'] ? 1 : 0,
'notify_teacher' => isset($payload['notify_teacher']) && $payload['notify_teacher'] ? 1 : 0,
'notify_admin' => isset($payload['notify_admin']) && $payload['notify_admin'] ? 1 : 0,
'no_school' => isset($payload['no_school']) && $payload['no_school'] ? 1 : 0,
'school_year' => trim((string) ($payload['school_year'] ?? $this->schoolYear)),
'semester' => trim((string) ($payload['semester'] ?? $this->semester)),
];
try {
$event = $this->calendar->create($data);
log_message('info', 'Event saved successfully: ' . print_r($data, true));
return $this->success($event, 'Event added successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'Failed to save event: ' . $e->getMessage());
return $this->respondError('Failed to save the event', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* PATCH /api/v1/school-calendar/{id}
* Update a calendar event
*/
public function update($id = null)
{
$event = $this->calendar->find($id);
if (!$event) {
return $this->error('Event not found', Response::HTTP_NOT_FOUND);
}
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'title' => 'permit_empty|min_length[3]',
'start' => 'permit_empty|valid_date[Y-m-d]',
];
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$data = [];
if (isset($payload['title'])) {
$data['title'] = trim((string) $payload['title']);
}
if (isset($payload['description'])) {
$data['description'] = trim((string) $payload['description']);
}
if (isset($payload['start'])) {
$data['date'] = trim((string) $payload['start']);
}
if (isset($payload['notify_parent'])) {
$data['notify_parent'] = $payload['notify_parent'] ? 1 : 0;
}
if (isset($payload['notify_teacher'])) {
$data['notify_teacher'] = $payload['notify_teacher'] ? 1 : 0;
}
if (isset($payload['notify_admin'])) {
$data['notify_admin'] = $payload['notify_admin'] ? 1 : 0;
}
if (isset($payload['no_school'])) {
$data['no_school'] = $payload['no_school'] ? 1 : 0;
}
if (isset($payload['school_year'])) {
$data['school_year'] = trim((string) $payload['school_year']);
}
if (isset($payload['semester'])) {
$data['semester'] = trim((string) $payload['semester']);
}
try {
$this->calendar->update($id, $data);
$updatedEvent = $this->calendar->find($id);
return $this->success($updatedEvent, 'Event updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Failed to update event: ' . $e->getMessage());
return $this->respondError('Failed to update the event', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* DELETE /api/v1/school-calendar/{id}
* Delete a calendar event
*/
public function delete($id = null)
{
$event = $this->calendar->find($id);
if (!$event) {
return $this->error('Event not found', Response::HTTP_NOT_FOUND);
}
try {
$this->calendar->delete($id);
return $this->success(null, 'Event deleted successfully');
} catch (\Throwable $e) {
log_message('error', 'Failed to delete event: ' . $e->getMessage());
return $this->respondError('Failed to delete the event', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/school-calendar/view/parent
* Get calendar events formatted for parent view
*/
public function calendarParentView()
{
$formattedEvents = $this->calendarView('parent');
return $this->success(['events' => $formattedEvents], 'Parent calendar view retrieved successfully');
}
/**
* GET /api/v1/school-calendar/view/teacher
* Get calendar events formatted for teacher view
*/
public function calendarTeacherView()
{
$formattedEvents = $this->calendarView('teacher');
return $this->success(['events' => $formattedEvents], 'Teacher calendar view retrieved successfully');
}
/**
* GET /api/v1/school-calendar/view/admin
* Get calendar events formatted for admin view
*/
public function calendarAdminView()
{
$formattedEvents = $this->calendarView('admin');
return $this->success(['events' => $formattedEvents], 'Admin calendar view retrieved successfully');
}
/**
* Private method to format calendar events based on audience visibility rules
*/
private function calendarView(?string $audience = null): array
{
// Filter events by school year
$query = $this->calendar->newQuery();
if ($this->schoolYear !== '') {
$query->where('school_year', $this->schoolYear);
}
$events = $query->orderBy('date', 'ASC')->get()->toArray();
// Apply audience visibility rules:
// - If no checkboxes selected (all notify_* = 0) => visible to everyone
// - Otherwise visible only to the selected audience
if (in_array($audience, ['parent', 'teacher', 'admin'], true)) {
$events = array_values(array_filter($events, function ($event) use ($audience) {
$p = (int) ($event['notify_parent'] ?? 0);
$t = (int) ($event['notify_teacher'] ?? 0);
$a = (int) ($event['notify_admin'] ?? 0);
$ns = (int) ($event['no_school'] ?? 0);
$hasAny = ($p + $t + $a) > 0;
// If marked as no_school, it should be visible to all audiences
if ($ns === 1) {
return true;
}
if (!$hasAny) {
// No audience explicitly selected: show to everyone
return true;
}
// Audience explicitly selected: restrict to matching audience
if ($audience === 'parent') {
return $p === 1;
}
if ($audience === 'teacher') {
return $t === 1;
}
if ($audience === 'admin') {
return $a === 1;
}
return true;
}));
}
// Format for FullCalendar
$aud = $audience; // capture for closure
$formattedEvents = array_map(function ($event) use ($aud) {
$p = (int) ($event['notify_parent'] ?? 0);
$t = (int) ($event['notify_teacher'] ?? 0);
$a = (int) ($event['notify_admin'] ?? 0);
$ns = (int) ($event['no_school'] ?? 0);
$backgroundColor = null;
// Color no-school events yellow on teacher and parent calendars
if (in_array($aud, ['teacher', 'parent'], true) && $ns === 1) {
$backgroundColor = '#ffc107'; // yellow
}
// Highlight events dedicated to staff (teachers/admins) only in green
// Definition: visible to staff but not to parents, and not a no-school marker
if (in_array($aud, ['teacher', 'admin'], true) && $backgroundColor === null) {
$isStaffOnly = ($p === 0 && ($t === 1 || $a === 1) && $ns === 0);
if ($isStaffOnly) {
// Use a distinct green to differentiate from the default blue
$backgroundColor = '#28a745';
}
}
// Title-casing for "no school" phrase when marked as no_school
$title = $event['title'] ?? 'Untitled';
if ($ns === 1 && is_string($title)) {
// Replace common variants with Title Case
$title = preg_replace('/\bno\s*[- ]\s*school\b/i', 'No School', $title);
}
return [
'id' => $event['id'],
'title' => $title,
'start' => $event['date'] ?? '', // FullCalendar expects 'start'
'description' => $event['description'] ?? '',
'extendedProps' => [
'semester' => $event['semester'] ?? '',
'school_year' => $event['school_year'] ?? '',
'notify_parent' => $p,
'notify_teacher' => $t,
'notify_admin' => $a,
'no_school' => $ns,
'backgroundColor' => $backgroundColor,
]
];
}, $events);
return $formattedEvents;
}
}
@@ -1,379 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\ScoreComment;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Session;
use Symfony\Component\HttpFoundation\Response;
class ScoreCommentController extends BaseApiController
{
protected ScoreComment $scoreComment;
protected Configuration $config;
protected string $schoolYear;
protected string $semester;
public function __construct()
{
parent::__construct();
$this->scoreComment = model(ScoreComment::class);
$this->config = model(Configuration::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
}
/**
* POST /api/v1/score-comments
* Save comments for students
*/
public function save()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$comments = $payload['comments'] ?? null;
$subjectId = $payload['subject_id'] ?? null;
if (!is_array($comments)) {
return $this->error('No comments were submitted', Response::HTTP_BAD_REQUEST);
}
if (!$subjectId) {
return $this->error('Subject ID is required', Response::HTTP_BAD_REQUEST);
}
$semester = Session::get('semester') ?? $this->semester;
$teacherId = $user->id;
$saved = [];
$errors = [];
try {
foreach ($comments as $studentId => $commentTypes) {
if (!is_array($commentTypes)) {
continue;
}
foreach ($commentTypes as $scoreType => $comment) {
if (trim((string) $comment) === '') {
continue;
}
$normalizedScoreType = strtolower((string) $scoreType);
$data = [
'student_id' => (int) $studentId,
'subject_id' => (int) $subjectId,
'score_type' => $normalizedScoreType,
'semester' => $semester,
'school_year' => $this->schoolYear,
'comment' => trim((string) $comment),
'commented_by' => $teacherId,
'created_at' => utc_now(),
];
// Check if a comment already exists
$existing = $this->scoreComment
->where('student_id', $studentId)
->where('subject_id', $subjectId)
->where('score_type', $normalizedScoreType)
->where('semester', $semester)
->where('school_year', $this->schoolYear)
->first();
try {
if ($existing) {
$this->scoreComment->update($existing['id'], $data);
$saved[] = $this->scoreComment->find($existing['id']);
} else {
$record = $this->scoreComment->create($data);
$saved[] = is_array($record) ? $record : $record->toArray();
}
} catch (\Throwable $e) {
$errors[] = "Failed to save comment for student {$studentId}, type {$scoreType}: " . $e->getMessage();
}
}
}
if (!empty($errors)) {
return $this->error('Some comments failed to save', Response::HTTP_PARTIAL_CONTENT, [
'saved' => $saved,
'errors' => $errors,
]);
}
return $this->success(['saved' => $saved], 'Comments saved successfully');
} catch (\Throwable $e) {
log_message('error', 'Score comment save error: ' . $e->getMessage());
return $this->respondError('Failed to save comments', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/score-comments/student/{studentId}
* Get comments for a specific student
*/
public function getByStudent($studentId = null)
{
$subjectId = $this->request->getGet('subject_id');
$scoreType = $this->request->getGet('score_type');
$query = $this->scoreComment->newQuery()
->where('student_id', $studentId)
->where('school_year', $this->schoolYear)
->where('semester', $this->semester);
if (!empty($subjectId)) {
$query->where('subject_id', $subjectId);
}
if (!empty($scoreType)) {
$query->where('score_type', $scoreType);
}
$comments = $query->get()->toArray();
return $this->success($comments, 'Comments retrieved successfully');
}
/**
* GET /api/v1/score-comments/management
* Get comments management view data for a class section
*/
public function viewCommentsMngt()
{
$payload = $this->payloadData();
$classSectionId = (int) (
$payload['class_section_id'] ??
$this->request->getGet('class_section_id') ??
Session::get('class_section_id') ??
0
);
if ($classSectionId <= 0) {
return $this->error('Missing class section', Response::HTTP_BAD_REQUEST);
}
Session::put('class_section_id', $classSectionId);
try {
// Get roster for this class/term (distinct to avoid dupes)
$students = DB::table('student_class as sc')
->select('s.id AS student_id', 's.firstname', 's.lastname', 's.school_id')
->distinct()
->join('students as s', 's.id', '=', 'sc.student_id')
->where('sc.class_section_id', $classSectionId)
->where('sc.school_year', $this->schoolYear)
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->toArray();
// Load only comments for these students in this term (ptap, midterm, final)
$comments = [];
if (!empty($students)) {
$studentIds = array_map(static fn($r) => (int) $r->student_id, $students);
$rawComments = $this->scoreComment
->whereIn('student_id', $studentIds)
->whereIn('score_type', ['midterm', 'final', 'ptap'])
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->findAll();
foreach ($rawComments as $c) {
$sid = (int) $c['student_id'];
$typ = (string) $c['score_type'];
$comments[$sid][$typ] = [
'comment' => $c['comment'] ?? null,
'comment_review' => $c['comment_review'] ?? null,
'commented_by' => $c['commented_by'] ?? null,
'reviewed_by' => $c['reviewed_by'] ?? null,
];
}
}
return $this->success([
'students' => array_map(function ($s) {
return [
'student_id' => $s->student_id,
'firstname' => $s->firstname,
'lastname' => $s->lastname,
'school_id' => $s->school_id,
];
}, $students),
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'class_section_id' => $classSectionId,
'comments' => $comments,
], 'Comments management data retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Comments management error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve comments management data', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/score-comments/update
* Update comments and reviews
*/
public function updateComments()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
if (empty($payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$semester = $payload['semester'] ?? $this->semester;
$schoolYear = $payload['school_year'] ?? $this->schoolYear;
$teacherId = $payload['teacher_id'] ?? $user->id;
$comments = $payload['comments'] ?? [];
$reviews = $payload['reviews'] ?? [];
if (!is_array($comments)) {
return $this->error('Comments must be an array', Response::HTTP_BAD_REQUEST);
}
$isReviewer = $this->isReviewer();
$updated = [];
$errors = [];
try {
foreach ($comments as $studentId => $types) {
if (!is_array($types)) {
continue;
}
foreach ($types as $scoreType => $commentText) {
// Check if comment already exists
$existing = $this->scoreComment->where([
'student_id' => (int) $studentId,
'score_type' => strtolower((string) $scoreType),
'semester' => $semester,
'school_year' => $schoolYear,
])->first();
$reviewText = $reviews[$studentId][$scoreType] ?? null;
$data = [
'comment' => trim((string) $commentText) === '' ? null : trim((string) $commentText),
'commented_by' => (int) $teacherId,
];
// Only update review if user is reviewer and review text exists
if ($isReviewer && $reviewText !== null) {
$data['comment_review'] = trim((string) $reviewText) === '' ? null : trim((string) $reviewText);
$data['reviewed_by'] = (int) $teacherId;
}
try {
if ($existing) {
// Update
$this->scoreComment->update($existing['id'], $data);
$updated[] = $this->scoreComment->find($existing['id']);
} else {
// Insert
$insertData = array_merge([
'student_id' => (int) $studentId,
'score_type' => strtolower((string) $scoreType),
'semester' => $semester,
'school_year' => $schoolYear,
], $data);
$record = $this->scoreComment->create($insertData);
$updated[] = is_array($record) ? $record : $record->toArray();
}
} catch (\Throwable $e) {
$errors[] = "Failed to update comment for student {$studentId}, type {$scoreType}: " . $e->getMessage();
}
}
}
if (!empty($errors)) {
return $this->error('Some comments failed to update', Response::HTTP_PARTIAL_CONTENT, [
'updated' => $updated,
'errors' => $errors,
]);
}
return $this->success(['updated' => $updated], 'Comments updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Comments update error: ' . $e->getMessage());
return $this->respondError('Failed to update comments', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* PATCH /api/v1/score-comments/{id}
* Update a single comment
*/
public function update($id = null)
{
$comment = $this->scoreComment->find($id);
if (!$comment) {
return $this->error('Comment not found', Response::HTTP_NOT_FOUND);
}
$payload = $this->payloadData();
if (empty($payload) || !array_key_exists('comment', $payload)) {
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
}
try {
$this->scoreComment->update($id, ['comment' => $payload['comment']]);
$updated = $this->scoreComment->find($id);
return $this->success($updated, 'Comment updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Comment update error: ' . $e->getMessage());
return $this->respondError('Failed to update comment', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Helper function to check if current user is a reviewer
*/
private function isReviewer(): bool
{
$user = $this->getCurrentUser();
if (!$user || empty($user->roles)) {
return false;
}
// Get reviewer roles from configuration table
$reviewerRoles = $this->config->getConfig('comment_reviewer');
if (!$reviewerRoles) {
return false;
}
// Convert comma-separated string to array and trim whitespace
$allowedRoles = array_map('trim', explode(',', (string) $reviewerRoles));
$allowedRoles = array_map('strtolower', $allowedRoles);
// Check if any of the user's roles match the allowed reviewer roles
$userRoles = array_map('strtolower', $user->roles);
foreach ($userRoles as $userRole) {
if (in_array($userRole, $allowedRoles, true)) {
return true;
}
}
return false;
}
}
@@ -1,695 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\AttendanceRecord;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\FinalExam;
use App\Models\Homework;
use App\Models\MidtermExam;
use App\Models\Project;
use App\Models\Quiz;
use App\Models\ScoreComment;
use App\Models\SemesterScore;
use App\Models\StudentClass;
use App\Models\Student;
use App\Models\TeacherClass;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Session;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
class ScoreController extends BaseApiController
{
protected SemesterScore $semesterScore;
protected Configuration $config;
protected ClassSection $classSection;
protected StudentClass $studentClass;
protected TeacherClass $teacherClass;
protected User $user;
protected Student $student;
protected string $schoolYear;
protected string $semester;
protected string $targetHigh;
protected string $targetLow;
protected $semesterScoreService;
public function __construct()
{
parent::__construct();
$this->semesterScore = model(SemesterScore::class);
$this->config = model(Configuration::class);
$this->classSection = model(ClassSection::class);
$this->teacherClass = model(TeacherClass::class);
$this->studentClass = model(StudentClass::class);
$this->user = model(User::class);
$this->student = model(Student::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
$this->targetHigh = (string) ($this->config->getConfig('trophy_score') ?? '');
$this->targetLow = (string) ($this->config->getConfig('pass_score') ?? '');
$this->semesterScoreService = \Config\Services::semesterScoreService();
}
/**
* GET /api/v1/scores
* Get student scores for teacher's class section
*/
public function index()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$teacherId = $user->id;
$teacher = $this->user->find($teacherId);
$teacherName = $teacher ? trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')) : 'Unknown';
$activeSemester = $this->resolveActiveSemester();
$effectiveSemester = $activeSemester;
$effectiveSchoolYear = $this->schoolYear;
// Get class section ID for the active term
$classSection = $this->teacherClass
->where('teacher_id', $teacherId)
->where('school_year', $effectiveSchoolYear)
->where('semester', $effectiveSemester)
->first();
if (!$classSection) {
$fallback = $this->teacherClass
->where('teacher_id', $teacherId)
->first();
if ($fallback) {
$classSection = $fallback;
$effectiveSemester = $classSection['semester'] ?? $effectiveSemester;
$effectiveSchoolYear = $classSection['school_year'] ?? $effectiveSchoolYear;
log_message('info', sprintf(
'Teacher ID %d class fallback to stored term %s / %s.',
$teacherId,
$effectiveSemester,
$effectiveSchoolYear
));
} else {
return $this->error('You do not have an assigned class yet. Please contact the administration.', Response::HTTP_NOT_FOUND);
}
}
$classSectionId = $classSection['class_section_id'];
// Build assigned teacher/TA names for this section
$rows = DB::table('teacher_class as tc')
->select('tc.position', 'u.firstname', 'u.lastname')
->join('users as u', 'u.id', '=', 'tc.teacher_id')
->where('tc.class_section_id', $classSectionId)
->where('tc.school_year', $effectiveSchoolYear)
->where('tc.semester', $effectiveSemester)
->orderByRaw("FIELD(tc.position, 'main', 'ta')")
->orderBy('u.firstname', 'ASC')
->get()
->toArray();
$mains = [];
$tas = [];
foreach ($rows as $r) {
$name = trim(($r->firstname ?? '') . ' ' . ($r->lastname ?? ''));
if ($name === '') continue;
$pos = strtolower((string) ($r->position ?? ''));
if (in_array($pos, ['main', 'teacher'], true)) {
$mains[] = $name;
} elseif (in_array($pos, ['ta', 'assistant', 'teacher_assistant'], true)) {
$tas[] = $name;
}
}
if (empty($mains) && !empty($teacherName)) {
$mains = [$teacherName];
}
// Resolve class section name
$csRow = DB::table('classSection')
->select('class_section_name')
->where('class_section_id', $classSectionId)
->first();
$classSectionName = $csRow->class_section_name ?? '';
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId(
$classSectionId,
$effectiveSemester,
$effectiveSchoolYear
);
if (!empty($studentTeacherInfo) && $this->semesterScoreService !== null) {
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
} catch (\Throwable $e) {
log_message('error', 'ScoreController::index semester score refresh failed: ' . $e->getMessage());
}
}
// Retrieve the student scores data
$students = $this->getStudentScores($classSectionId, $effectiveSemester, $effectiveSchoolYear, $teacherId);
// Sort students by last name, then first name
usort($students, function ($a, $b) {
$lastNameComparison = strcmp($a['lastname'], $b['lastname']);
return $lastNameComparison === 0
? strcmp($a['firstname'], $b['firstname'])
: $lastNameComparison;
});
return $this->success([
'students' => array_values($students),
'teacher_name' => $teacherName,
'class_section_id' => $classSectionId,
'class_section_name' => $classSectionName,
'mains_text' => implode(', ', $mains),
'tas_text' => implode(', ', $tas),
'active_semester' => strtolower((string) $effectiveSemester),
], 'Student scores retrieved successfully');
}
/**
* GET /api/v1/scores/student/{studentId}
* Get scores for a specific student
*/
public function getByStudent($studentId = null)
{
$schoolYear = $this->config->getConfig('school_year');
$semester = $this->config->getConfig('semester');
$scores = $this->semesterScore->newQuery()
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->get()
->toArray();
return $this->success($scores, 'Scores retrieved successfully');
}
/**
* POST /api/v1/scores/update-final-exam
* Update final exam scores
*/
public function updateFinalExamScores()
{
return $this->updateScores('final_exam');
}
/**
* POST /api/v1/scores/update-midterm
* Update midterm exam scores
*/
public function updateMidtermScores()
{
return $this->updateScores('midterm_exam');
}
/**
* POST /api/v1/scores/update
* Generic method to update scores for various types
*/
public function updateScores(string $table = null)
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->payloadData();
$table = $table ?? $payload['table'] ?? null;
if (!$table) {
return $this->error('Table type is required', Response::HTTP_BAD_REQUEST);
}
$scores = $payload['final_score'] ?? null;
$teacherId = $user->id;
$classSectionId = (int) ($payload['class_section_id'] ?? Session::get('class_section_id') ?? 0);
$semester = $this->resolveActiveSemester();
$affectedStudents = [];
if (!is_array($scores)) {
return $this->error('No scores submitted', Response::HTTP_BAD_REQUEST);
}
if ($classSectionId <= 0) {
return $this->error('Missing class section', Response::HTTP_BAD_REQUEST);
}
try {
foreach ($scores as $studentId => $data) {
if (!is_numeric($studentId) || !is_numeric($data['score'] ?? null)) {
continue;
}
$builder = DB::table($table);
// Check if a record already exists
$existing = $builder
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $this->schoolYear)
->first();
$insertData = [
'student_id' => (int) $studentId,
'class_section_id' => $classSectionId,
'updated_by' => $teacherId,
'score' => is_numeric($data['score']) ? (float) $data['score'] : null,
'semester' => $semester,
'school_year' => $this->schoolYear,
'updated_at' => utc_now(),
];
if (strcasecmp($table, 'quiz') === 0) {
$insertData['quiz_index'] = $data['quiz_index'] ?? null;
}
if ($existing) {
$builder->where('id', $existing->id)->update($insertData);
} else {
$insertData['created_at'] = utc_now();
$builder->insert($insertData);
}
$affectedStudents[(int) $studentId] = true;
}
// If final exam scores updated during Spring, trigger semester score recalculation
if (strcasecmp($table, 'final_exam') === 0 && strcasecmp((string) $this->semester, 'Spring') === 0 && $this->semesterScoreService !== null) {
try {
$studentTeacherInfo = $this->student->getStudentInfoByClassSectionId(
$classSectionId,
$semester,
$this->schoolYear
);
if (!empty($studentTeacherInfo)) {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
}
} catch (\Throwable $e) {
log_message('error', 'semester score resync failed after final exam update: ' . $e->getMessage());
}
}
return $this->success([
'updated_count' => count($affectedStudents),
'table' => $table,
], ucfirst($table) . ' scores updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Score update error: ' . $e->getMessage());
return $this->respondError('Failed to update scores', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/scores/parent-view
* Get student scores for parent view
*/
public function viewStudentScore()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$parentId = $user->id;
$userType = Session::get('user_type');
$firstParentId = null;
// Identify the firstparent based on user type
if ($userType === 'primary') {
$firstParentId = $parentId;
} elseif ($userType === 'secondary') {
$parentData = DB::table('parents')
->select('parent_id')
->where('secondparent_user_id', $parentId)
->first();
if ($parentData) {
$firstParentId = $parentData->parent_id;
}
} elseif ($userType === 'tertiary') {
$authUserData = DB::table('authorized_users')
->select('user_id as parent_id')
->where('authorized_user_id', $parentId)
->first();
if ($authUserData) {
$firstParentId = $authUserData->parent_id;
}
}
if (!$firstParentId) {
return $this->error('Unable to retrieve student data. Please contact support.', Response::HTTP_NOT_FOUND);
}
$selectedYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$requestedSemester = $this->semester;
// Fetch distinct school years from semester_scores
$schoolYears = DB::table('semester_scores')
->select('school_year')
->distinct()
->pluck('school_year')
->unique()
->sortDesc()
->values()
->toArray();
$scores = [];
// Get all students with their class section info for this parent
$students = DB::table('students')
->select('students.id', 'students.school_id', 'students.firstname', 'students.lastname', 'classSection.class_section_name')
->join('student_class', 'student_class.student_id', '=', 'students.id', 'left')
->join('classSection', 'classSection.class_section_id', '=', 'student_class.class_section_id', 'left')
->where('students.parent_id', $firstParentId)
->get()
->toArray();
if (!empty($students)) {
$studentIdList = array_column($students, 'id');
// Fetch data from semester_scores for these students
$semesterScores = DB::table('semester_scores')
->whereIn('student_id', $studentIdList)
->where('school_year', $selectedYear)
->where('semester', $requestedSemester)
->get()
->toArray();
foreach ($students as $student) {
$studentId = $student->id;
// Find this student's scores
$studentScores = array_filter($semesterScores, function ($score) use ($studentId) {
return $score->student_id == $studentId;
});
if (!empty($studentScores)) {
$score = reset($studentScores);
$scores[$selectedYear][$requestedSemester][$studentId] = [
'school_id' => $student->school_id,
'student_firstname' => $student->firstname,
'student_lastname' => $student->lastname,
'class_section_name' => $student->class_section_name ?? 'Unknown Class',
'comment' => $score->comment ?? null,
'scores' => [
'homework' => ['score' => $score->homework_avg ?? '-'],
'quiz' => ['score' => $score->quiz_avg ?? '-'],
'project' => ['score' => $score->project_avg ?? '-'],
'attendance' => ['score' => $score->attendance_score ?? '-'],
'participation_score' => ['score' => $score->participation_score ?? '-'],
'midterm_exam' => ['score' => $score->midterm_exam_score ?? '-'],
'final_exam' => ['score' => $score->final_exam_score ?? '-'],
'semester' => ['score' => $score->semester_score ?? '-'],
],
];
}
}
}
return $this->success([
'organized_scores' => $scores,
'school_years' => $schoolYears,
'selected_year' => $selectedYear,
'selected_semester' => $requestedSemester,
], 'Student scores retrieved successfully');
}
/**
* POST /api/v1/scores/recalculate
* Recalculate scores for students
*/
public function recalculate()
{
$payload = $this->payloadData();
$classSectionId = (int) ($payload['class_section_id'] ?? 0);
$students = (array) ($payload['students'] ?? []);
$semester = (string) ($payload['semester'] ?? $this->config->getConfig('semester'));
$schoolYear = (string) ($payload['school_year'] ?? $this->config->getConfig('school_year'));
try {
if ($classSectionId > 0) {
$students = DB::table('student_class as sc')
->select('s.id AS student_id', 's.school_id', 'sc.class_section_id')
->join('students as s', 's.id', '=', 'sc.student_id')
->where('sc.class_section_id', $classSectionId)
->where('sc.school_year', $schoolYear)
->get()
->map(fn ($row) => (array) $row)
->toArray();
}
if (empty($students)) {
return $this->error('No students provided to recalculate', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$service = \Config\Services::semesterScoreService();
$count = 0;
foreach ($students as $student) {
$studentId = (int) ($student['student_id'] ?? 0);
$studentSchool = (string) ($student['school_id'] ?? '');
$classSection = (int) ($student['class_section_id'] ?? 0);
if ($studentId <= 0 || $classSection <= 0) {
continue;
}
$service->updateStudentScores([
'student_id' => $studentId,
'school_id' => $studentSchool,
'class_section_id' => $classSection,
'updated_by' => $this->getCurrentUserId(),
], $semester, $schoolYear);
$count++;
}
return $this->success(['students_processed' => $count], 'Scores recalculated');
} catch (\Throwable $e) {
log_message('error', 'Score recalc error: ' . $e->getMessage());
return $this->respondError('Failed to recalculate scores', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Private method to get student scores
*/
private function getStudentScores(int $classSectionId, $semester, $schoolYear, int $teacherId): array
{
if (!$semester || !$schoolYear) {
throw new RuntimeException('Semester or school year configuration is missing.');
}
$totalSemesterDays = strtolower($semester) === 'fall'
? $this->config->getConfig('total_semester1_days')
: $this->config->getConfig('total_semester2_days');
if (!$totalSemesterDays) {
throw new RuntimeException('Total semester days configuration is missing.');
}
if ($classSectionId <= 0) {
throw new RuntimeException('Invalid class section for the current teacher.');
}
$students = $this->prepareStudents($classSectionId, $teacherId, $schoolYear);
$studentIds = array_keys($students);
// Load regular scores
$this->loadScoresByType(new Homework(), 'homework', $students, $classSectionId, $semester, $schoolYear);
$this->loadScoresByType(new Quiz(), 'quiz', $students, $classSectionId, $semester, $schoolYear);
$this->loadScoresByType(new Project(), 'project', $students, $classSectionId, $semester, $schoolYear);
$this->loadScoresByType(new MidtermExam(), 'midterm', $students, $classSectionId, $semester, $schoolYear);
$this->loadScoresByType(new FinalExam(), 'final_exam', $students, $classSectionId, $semester, $schoolYear);
$attendanceRecord = new AttendanceRecord();
$this->calculateStudentScores($students, $semester, $totalSemesterDays, $attendanceRecord);
// Load all semester scores in one query
$this->loadSemesterScores($students, $classSectionId, $semester, $schoolYear);
$this->loadComments(new ScoreComment(), $students, $studentIds, $semester, $schoolYear);
return $students;
}
private function loadSemesterScores(&$students, $classSectionId, $semester, $schoolYear): void
{
$results = $this->semesterScore
->select('student_id, attendance_score, ptap_score, semester_score, midterm_exam_score, final_exam_score')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
foreach ($results as $row) {
$studentId = $row['student_id'];
if (isset($students[$studentId])) {
$attendanceScore = $this->roundScore($row['attendance_score'] ?? null);
if ($attendanceScore !== null) {
$students[$studentId]['attendance_score'] = $attendanceScore;
}
$ptapScore = $this->roundScore($row['ptap_score'] ?? null);
if ($ptapScore !== null) {
$students[$studentId]['ptap_score'] = $ptapScore;
}
$semesterScore = $this->roundScore($row['semester_score'] ?? null);
if ($semesterScore !== null) {
$students[$studentId]['semester_score'] = $semesterScore;
}
$midtermScore = $this->roundScore($row['midterm_exam_score'] ?? null);
if ($midtermScore !== null && empty($students[$studentId]['midterm'])) {
$students[$studentId]['midterm'][] = $midtermScore;
}
$finalExamScore = $this->roundScore($row['final_exam_score'] ?? null);
if ($finalExamScore !== null && empty($students[$studentId]['final_exam'])) {
$students[$studentId]['final_exam'][] = $finalExamScore;
}
}
}
}
private function roundScore($value): ?float
{
if ($value === null) {
return null;
}
return is_numeric($value) ? round((float) $value, 1) : null;
}
private function prepareStudents($classSectionId, $teacherId, $schoolYear): array
{
$students = [];
$studentClasses = $this->studentClass->where('class_section_id', $classSectionId)->findAll();
foreach ($studentClasses as $studentClass) {
$student = $this->student->select('id, firstname, lastname, age, school_id')->find($studentClass['student_id']);
if ($student) {
$students[$student['id']] = [
'student_id' => $student['id'],
'firstname' => $student['firstname'],
'lastname' => $student['lastname'],
'age' => $student['age'],
'school_id' => $student['school_id'],
'class_section_id' => $classSectionId,
'school_year' => $schoolYear,
'teacher_id' => $teacherId,
'homework' => [],
'quiz' => [],
'midterm' => [],
'final_exam' => [],
'project' => [],
'ptap' => [],
'ptap_comment' => '',
'midterm_comment' => '',
'final_comment' => '',
];
}
}
return $students;
}
private function loadScoresByType($model, $type, &$students, $classSectionId, $semester, $schoolYear): void
{
$records = $model->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
foreach ($records as $rec) {
if (isset($students[$rec['student_id']])) {
$students[$rec['student_id']][$type][] = $rec['score'];
}
}
}
private function loadComments($comment, &$students, $studentIds, $semester, $schoolYear): void
{
if (empty($studentIds)) {
return;
}
$comments = $comment->whereIn('student_id', $studentIds)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
foreach ($comments as $comment) {
$sid = $comment['student_id'];
if (!isset($students[$sid])) {
continue;
}
$type = strtolower($comment['score_type']);
if ($type === 'ptap') {
$students[$sid]['ptap_comment'] = $comment['comment'];
$students[$sid]['ptap_comment_review'] = $comment['comment_review'] ?? null;
} elseif ($type === 'midterm') {
$students[$sid]['midterm_comment'] = $comment['comment'];
$students[$sid]['midterm_comment_review'] = $comment['comment_review'] ?? null;
} elseif ($type === 'final') {
$students[$sid]['final_comment'] = $comment['comment'];
$students[$sid]['final_comment_review'] = $comment['comment_review'] ?? null;
}
}
}
private function calculateStudentScores(&$students, $semester, $totalDays, $attendanceRecord): void
{
foreach ($students as $sid => &$s) {
$absences = $attendanceRecord->getTotalAbsences($sid, $semester, $s['school_year'] ?? '');
$attendance = min((($totalDays - $absences + 1) / $totalDays) * 100, 100);
$avgHomework = !empty($s['homework']) ? array_sum($s['homework']) / count($s['homework']) : 0;
$avgQuiz = !empty($s['quiz']) ? array_sum($s['quiz']) / count($s['quiz']) : 0;
$avgProject = !empty($s['project']) ? array_sum($s['project']) / count($s['project']) : 0;
$ptap = round(($avgHomework + $avgQuiz + $avgProject) / 3, 1);
$midterm = !empty($s['midterm']) ? $s['midterm'][0] : 0;
$final = !empty($s['final_exam']) ? $s['final_exam'][0] : 0;
$score = 0.2 * $ptap + 0.2 * $attendance;
if (strtolower($semester) === 'fall') {
$score += 0.6 * $midterm;
} else {
$score += 0.6 * $final;
}
$s['average_homework'] = round($avgHomework, 1);
$s['average_quiz'] = round($avgQuiz, 1);
$s['average_project'] = round($avgProject, 1);
$s['ptap_score'] = $ptap;
$s['midterm_score'] = round($midterm, 1);
$s['final_exam_score'] = round($final, 1);
$s['attendance_score'] = round($attendance, 1);
$s['semester_score'] = round(min($score, 100), 1);
}
}
private function resolveActiveSemester(): string
{
return Session::get('semester') ?? $this->semester;
}
}
@@ -1,317 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\ClassSection;
use App\Models\Configuration;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class ScorePredictorController extends BaseApiController
{
protected Configuration $config;
protected ClassSection $classSection;
protected string $semester;
protected string $schoolYear;
protected $targetTrophy;
protected $targetLow;
public function __construct()
{
parent::__construct();
$this->config = model(Configuration::class);
$this->classSection = model(ClassSection::class);
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->targetTrophy = $this->config->getConfig('trophy_score');
$this->targetLow = $this->config->getConfig('pass_score');
}
/**
* GET /api/v1/score-predictor/combined-report
* Generate combined score prediction report with trophy winners
*/
public function combinedReport()
{
$selectedYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$classSectionId = $this->request->getGet('class_section_id');
try {
$classSections = $this->classSection->findAll();
$students = DB::table('students as s')
->select([
's.id as student_id',
's.school_id',
's.firstname',
's.lastname',
'fall.semester_score as fall_score',
'spring.semester_score as spring_score',
'sc.class_section_id',
])
->leftJoin('student_class as sc', function ($join) use ($selectedYear) {
$join->on('sc.student_id', '=', 's.id')
->where('sc.school_year', $selectedYear);
})
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->leftJoin('semester_scores as fall', function ($join) use ($selectedYear) {
$join->on('fall.student_id', '=', 's.id')
->where('fall.semester', 'fall')
->where('fall.school_year', $selectedYear);
})
->leftJoin('semester_scores as spring', function ($join) use ($selectedYear) {
$join->on('spring.student_id', '=', 's.id')
->where('spring.semester', 'spring')
->where('spring.school_year', $selectedYear);
})
->when(!empty($classSectionId), fn ($query) => $query->where('sc.class_section_id', $classSectionId))
->groupBy('s.id')
->get()
->toArray();
// Stats for spring
$springStatsQuery = DB::table('semester_scores as ss')
->selectRaw('AVG(ss.semester_score) as mean, STDDEV(ss.semester_score) as std')
->join('student_class as sc', 'sc.student_id', '=', 'ss.student_id')
->where('ss.semester', 'spring')
->where('ss.school_year', $selectedYear)
->where('sc.school_year', $selectedYear);
if (!empty($classSectionId)) {
$springStatsQuery->where('sc.class_section_id', $classSectionId);
}
$springStats = $springStatsQuery->first();
$mean = (float) ($springStats->mean ?? 70.0);
$std = (float) ($springStats->std ?? 0.0);
$results = $this->buildPredictions($students, $mean, $std);
// Sort students by final average descending
usort($results, function ($a, $b) {
$aAvg = is_numeric($a['final_average']) ? $a['final_average'] : 0;
$bAvg = is_numeric($b['final_average']) ? $b['final_average'] : 0;
return $bAvg <=> $aAvg;
});
$results = $this->assignTrophyStatuses($results);
return $this->success([
'students' => $results,
'school_year' => $selectedYear,
'class_sections' => $classSections,
'selected_class_section_id' => $classSectionId,
'semester' => $this->semester,
'stats' => [
'mean' => $mean,
'std' => $std,
'target_trophy' => $this->targetTrophy,
'target_pass' => $this->targetLow,
],
], 'Score prediction generated successfully');
} catch (\Throwable $e) {
log_message('error', 'Score predictor error: ' . $e->getMessage());
return $this->respondError('Failed to generate score prediction', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
protected function buildPredictions(array $students, float $mean, float $std): array
{
$results = [];
$trophyThreshold = max(94.0, (float) ($this->targetTrophy ?? 94.0));
$passThreshold = (float) ($this->targetLow ?? 60.0);
foreach ($students as $student) {
$student = (array) $student;
$fall = isset($student['fall_score']) ? (float) $student['fall_score'] : null;
$spring = isset($student['spring_score']) ? (float) $student['spring_score'] : null;
$isNew = $fall === null;
$finalAverage = $isNew || $spring === null ? null : round(($fall + $spring) / 2, 1);
$requiredHigh = $isNew ? null : round(2 * $trophyThreshold - $fall, 2);
$requiredLow = $isNew ? null : round(2 * $passThreshold - $fall, 2);
$probHigh = $this->probability($requiredHigh, $mean, $std);
$probLow = $this->probability($requiredLow, $mean, $std, false);
$results[] = [
'student_id' => (int) ($student['student_id'] ?? 0),
'school_id' => $student['school_id'] ?? null,
'firstname' => $student['firstname'] ?? '',
'lastname' => $student['lastname'] ?? '',
'class_section_id' => $student['class_section_id'] ?? null,
'fall_score' => $fall ?? 'N/A',
'spring_score' => $spring ?? 'N/A',
'final_average' => $finalAverage ?? 'N/A',
'required_spring_score' => $requiredHigh ?? 'N/A',
'winning_risk' => $this->riskLabelHigh($finalAverage, $requiredHigh, $probHigh, $trophyThreshold),
'required_spring_to_pass' => $requiredLow ?? 'N/A',
'failure_risk' => $this->riskLabelLow($requiredLow, $probLow),
'status' => $this->statusLabel($finalAverage),
];
}
return $results;
}
protected function probability(?float $required, float $mean, float $std, bool $isHigh = true): ?float
{
if ($required === null) {
return null;
}
if ($std <= 0) {
return $required <= $mean ? ($isHigh ? 1.0 : 0.0) : ($isHigh ? 0.0 : 1.0);
}
$z = ($required - $mean) / $std;
$value = self::normalCDF($z);
return $isHigh ? 1 - $value : $value;
}
protected function riskLabelHigh($finalAverage, ?float $required, ?float $probability, float $threshold): string
{
if ($finalAverage !== null && $finalAverage >= $threshold) {
return 'Achieved Trophy';
}
if ($required !== null && $required > 100) {
return 'Unreachable';
}
if ($probability === null) {
return 'New student';
}
return match (true) {
$probability >= 0.66 => 'Low',
$probability >= 0.33 => 'Medium',
default => 'High',
};
}
protected function riskLabelLow(?float $required, ?float $probability): string
{
if ($required !== null && $required > 100) {
return 'Unlikely to pass';
}
if ($probability === null) {
return 'New student';
}
return match (true) {
$probability >= 0.66 => 'High',
$probability >= 0.33 => 'Medium',
default => 'Low',
};
}
protected function statusLabel($finalAverage): string
{
if ($finalAverage === null || !is_numeric($finalAverage)) {
return 'Undetermined';
}
return $finalAverage >= 60 ? 'Passed' : 'Failed';
}
protected function assignTrophyStatuses(array $results): array
{
$tolerance = 0.2;
$trophyThreshold = max(94.0, (float) ($this->targetTrophy ?? 94.0));
// Determine trophy winners per class
$indicesByClass = [];
foreach ($results as $idx => $r) {
$cid = $r['class_section_id'] ?? null;
$indicesByClass[$cid][] = $idx;
}
foreach ($indicesByClass as $cid => $idxList) {
// Keep only indices with numeric final averages, in current sorted order
$graded = array_values(array_filter($idxList, function ($i) use ($results) {
return isset($results[$i]['final_average']) && is_numeric($results[$i]['final_average']);
}));
$classSize = count($graded);
if ($classSize === 0) {
continue;
}
$minTrophies = max((int) ceil($classSize / 4), 3);
$minTrophies = min($minTrophies, $classSize);
$winners = [];
// 1) Include all students at/above threshold within this class
foreach ($graded as $i) {
$fa = $results[$i]['final_average'];
if ($fa >= $trophyThreshold) {
$winners[$i] = true;
} else {
break; // below threshold; following are lower
}
}
// 2) Ensure at least the minimum trophies for this class
if (count($winners) < $minTrophies) {
foreach ($graded as $i) {
if (!isset($winners[$i])) {
$winners[$i] = true;
if (count($winners) >= $minTrophies) {
break;
}
}
}
}
// 3) Tolerance tie-break within the class
if (!empty($winners)) {
$lastIncludedPos = null;
$lastIncludedIdx = null;
for ($pos = count($graded) - 1; $pos >= 0; $pos--) {
$gi = $graded[$pos];
if (isset($winners[$gi])) {
$lastIncludedPos = $pos;
$lastIncludedIdx = $gi;
break;
}
}
if ($lastIncludedPos !== null) {
$lastGrade = $results[$lastIncludedIdx]['final_average'];
for ($k = $lastIncludedPos + 1; $k < $classSize; $k++) {
$gi = $graded[$k];
$grade = $results[$gi]['final_average'];
if (($lastGrade - $grade) <= $tolerance + 1e-9) {
$winners[$gi] = true;
} else {
break;
}
}
}
}
// Mark winners (per class)
foreach ($winners as $i => $_) {
$results[$i]['winning_risk'] = 'Achieved Trophy';
}
}
return $results;
}
public static function normalCDF($z): float
{
$t = 1 / (1 + 0.3275911 * abs($z));
$a1 = 0.254829592;
$a2 = -0.284496736;
$a3 = 1.421413741;
$a4 = -1.453152027;
$a5 = 1.061405429;
$erf = 1 - ((((($a5 * $t + $a4) * $t + $a3) * $t + $a2) * $t + $a1) * $t) * exp(-$z * $z);
$sign = $z < 0 ? -1 : 1;
return 0.5 * (1 + $sign * $erf);
}
}
@@ -1,91 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Services\EmailService;
use Symfony\Component\HttpFoundation\Response;
class SendSMSController extends BaseApiController
{
protected array $carrierGateways = [
'att' => ['sms' => '@txt.att.net', 'mms' => '@mms.att.net'],
'verizon' => ['sms' => '@vtext.com', 'mms' => '@vzwpix.com'],
'tmobile' => ['sms' => '@tmomail.net', 'mms' => '@tmomail.net'],
'sprint' => ['sms' => '@messaging.sprintpcs.com', 'mms' => '@pm.sprint.com'],
'boost' => ['sms' => '@sms.myboostmobile.com', 'mms' => '@myboostmobile.com'],
'cricket' => ['sms' => '@sms.cricketwireless.net', 'mms' => '@mms.cricketwireless.net'],
'uscellular' => ['sms' => '@email.uscc.net', 'mms' => '@mms.uscc.net'],
'googlefi' => ['sms' => '@msg.fi.google.com', 'mms' => '@msg.fi.google.com'],
'virgin' => ['sms' => '@vmobl.com', 'mms' => '@vmpix.com'],
'metro' => ['sms' => '@mymetropcs.com', 'mms' => '@mymetropcs.com'],
];
public function send()
{
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'phone' => 'required|regex_match[/^\\d{10}$/]',
'message' => 'required|max_length[160]',
'carrier' => 'required|in_list[att,verizon,tmobile,sprint,boost,cricket,uscellular,googlefi,virgin,metro]',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$phone = preg_replace('/[^0-9]/', '', $data['phone']);
$message = $data['message'];
$carrier = strtolower($data['carrier']);
$type = $data['type'] ?? 'sms';
if (!isset($this->carrierGateways[$carrier])) {
return $this->respondError('Invalid carrier', Response::HTTP_BAD_REQUEST);
}
$gateway = $this->carrierGateways[$carrier][$type] ?? $this->carrierGateways[$carrier]['sms'];
$recipient = $phone . $gateway;
try {
/** @var EmailService $mailer */
$mailer = app(EmailService::class);
$mailer->setTo($recipient);
$mailer->setFrom(env('MAIL_FROM_ADDRESS', 'no-reply@alrahmaisgl.org'), env('MAIL_FROM_NAME', 'Alrahma Team'));
$mailer->setSubject('');
$mailer->setMessage($message);
if (!$mailer->send()) {
return $this->respondError('Failed to send SMS', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success([
'phone' => $phone,
'carrier' => $carrier,
'type' => $type,
'sent' => true,
], 'SMS sent successfully');
} catch (\Throwable $e) {
log_message('error', 'SMS send error: ' . $e->getMessage());
return $this->respondError('Failed to send SMS', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function carriers()
{
$carriers = [];
foreach ($this->carrierGateways as $key => $gateways) {
$carriers[] = [
'id' => $key,
'name' => ucfirst(str_replace(['us', 'google'], ['US ', 'Google '], $key)),
'sms_gateway' => $gateways['sms'],
'mms_gateway' => $gateways['mms'],
];
}
return $this->success($carriers, 'Carriers retrieved successfully');
}
}
@@ -1,92 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Config\SessionTimeout;
use Symfony\Component\HttpFoundation\Response;
class SessionController extends BaseApiController
{
public function getTimeoutConfig()
{
try {
return $this->success([
'timeout' => SessionTimeout::TIMEOUT_DURATION,
'warning_time' => SessionTimeout::WARNING_THRESHOLD,
'check_interval' => SessionTimeout::CHECK_INTERVAL,
'logout_url' => '/api/v1/logout',
'keep_alive_url' => '/api/v1/session/ping',
'check_url' => '/api/v1/session/check',
], 'Timeout configuration retrieved successfully');
} catch (\Throwable $e) {
log_message('error', 'Timeout config error: ' . $e->getMessage());
return $this->respondError('Failed to retrieve timeout configuration', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function checkTimeout()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Session expired', Response::HTTP_UNAUTHORIZED);
}
try {
$session = session();
if (!$session->has('last_activity')) {
return $this->respondError('Session expired', Response::HTTP_UNAUTHORIZED);
}
$lastActivity = (int) $session->get('last_activity');
$elapsed = time() - $lastActivity;
if ($elapsed > SessionTimeout::TIMEOUT_DURATION) {
$session->invalidate();
return $this->respondError('Session expired', Response::HTTP_UNAUTHORIZED);
}
if ($elapsed > SessionTimeout::WARNING_THRESHOLD) {
return $this->success([
'status' => 'warning',
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed,
], 'Session warning');
}
return $this->success([
'status' => 'active',
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed,
], 'Session active');
} catch (\Throwable $e) {
log_message('error', 'Session check error: ' . $e->getMessage());
return $this->respondError('Failed to check session', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function pingActivity()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Session expired', Response::HTTP_UNAUTHORIZED);
}
try {
$session = session();
$lastActivity = (int) ($session->get('last_activity') ?? 0);
if (!$lastActivity || (time() - $lastActivity > SessionTimeout::TIMEOUT_DURATION)) {
$session->invalidate();
return $this->respondError('Session expired', Response::HTTP_UNAUTHORIZED);
}
$session->put('last_activity', time());
return $this->success([
'status' => 'active',
'time_remaining' => SessionTimeout::TIMEOUT_DURATION,
], 'Session activity updated');
} catch (\Throwable $e) {
log_message('error', 'Session ping error: ' . $e->getMessage());
return $this->respondError('Failed to update session activity', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,82 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Preferences;
use App\Models\Settings;
use Symfony\Component\HttpFoundation\Response;
class SettingsController extends BaseApiController
{
protected Preferences $preferences;
protected Settings $settings;
public function __construct()
{
parent::__construct();
$this->preferences = model(Preferences::class);
$this->settings = model(Settings::class);
}
public function preferences()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$preference = $this->preferences->newQuery()
->where('user_id', $user->id)
->first();
return $this->success($preference ? $preference->toArray() : [], 'Preferences retrieved successfully');
}
public function updatePreferences()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$allowedFields = ['timezone', 'language', 'notifications_enabled', 'email_notifications'];
$payload = ['user_id' => $user->id];
foreach ($allowedFields as $field) {
if (array_key_exists($field, $data)) {
$payload[$field] = $data[$field];
}
}
try {
$preference = $this->preferences->newQuery()
->updateOrCreate(['user_id' => $user->id], $payload);
return $this->success($preference->fresh()->toArray(), 'Preferences updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Preferences update error: ' . $e->getMessage());
return $this->respondError('Failed to update preferences', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function system()
{
$settings = $this->settings->newQuery()->get();
$formatted = [];
foreach ($settings as $setting) {
$row = $setting->toArray();
$key = $row['key'] ?? $row['name'] ?? null;
if ($key !== null) {
$formatted[$key] = $row['value'] ?? $row;
}
}
return $this->success($formatted, 'System settings retrieved successfully');
}
}
@@ -1,615 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\LateSlipLog;
use App\Models\User;
use Carbon\Carbon;
use Dompdf\Dompdf;
use Dompdf\Options;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
use Mike42\Escpos\Printer;
use Mike42\Escpos\PrintConnectors\NetworkPrintConnector;
use Mike42\Escpos\PrintConnectors\UsbPrintConnector;
use Mike42\Escpos\PrintConnectors\WindowsPrintConnector;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class SlipPrinterController extends BaseApiController
{
protected Configuration $config;
protected LateSlipLog $log;
protected User $user;
protected string $schoolYear;
protected string $semester;
public function __construct()
{
parent::__construct();
$this->config = model(Configuration::class);
$this->log = model(LateSlipLog::class);
$this->user = model(User::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
}
/**
* POST /api/v1/slips/print
* Generate and return a PDF late slip
*/
public function print(): SymfonyResponse|JsonResponse
{
$data = $this->payloadData();
$slipData = [
'school_year' => trim((string) ($data['school_year'] ?? $this->schoolYear ?: '')),
'student_name' => trim((string) ($data['student_name'] ?? '')),
'date' => trim((string) ($data['date'] ?? date('m/d/Y'))),
'time_in' => trim((string) ($data['time_in'] ?? date('h:i A'))),
'grade' => trim((string) ($data['grade'] ?? '')),
'reason' => trim((string) ($data['reason'] ?? '')),
'admin_name' => trim((string) ($data['admin_name'] ?? '')),
];
// Always prefer current logged-in user's full name from DB
$adminFromDb = $this->currentAdminName();
if ($adminFromDb !== '') {
$slipData['admin_name'] = $adminFromDb;
}
// Validate minimal fields
if ($slipData['student_name'] === '') {
return $this->error('Student name is required.', SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY);
}
try {
// Persist a log (best-effort) before generating PDF
try {
$printedBy = $this->getCurrentUserId();
$logData = [
'school_year' => $slipData['school_year'],
'semester' => $this->semester,
'student_name' => $slipData['student_name'],
'slip_date' => $this->toDbDate($slipData['date']),
'time_in' => $this->toDbTime($slipData['time_in']),
'grade' => $slipData['grade'],
'reason' => $slipData['reason'],
'admin_name' => $slipData['admin_name'],
];
$this->log->logSlip($logData, $printedBy);
} catch (\Throwable $e) {
Log::error('Late slip log insert failed: ' . $e->getMessage());
}
// Generate and return PDF
return $this->renderSlipPdfResponse($slipData);
} catch (\Throwable $e) {
Log::error('Late slip PDF generation failed: ' . $e->getMessage());
return $this->error('Printing failed.', SymfonyResponse::HTTP_INTERNAL_SERVER_ERROR, [
'details' => $e->getMessage(),
]);
}
}
/**
* GET/POST /api/v1/slips/preview
* Show preview of late slips (GET returns list, POST returns JSON preview)
*/
public function preview(): JsonResponse
{
try {
// Resolve current school year
$currentYear = trim((string) ($this->schoolYear ?? ''));
if ($currentYear === '') {
$latest = $this->log->newQuery()
->select('school_year')
->orderBy('id', 'DESC')
->first();
$currentYear = $latest['school_year'] ?? (date('Y') . '-' . (date('Y') + 1));
}
$data = $this->payloadData();
$selectedYear = trim((string) ($data['school_year'] ?? $currentYear));
$selectedSemester = strtolower(trim((string) ($data['semester'] ?? '')));
$previewData = [
'school_year' => $selectedYear,
'student_name' => trim((string) ($data['student_name'] ?? '')),
'date' => trim((string) ($data['date'] ?? date('m/d/Y'))),
'time_in' => trim((string) ($data['time_in'] ?? date('h:i A'))),
'grade' => trim((string) ($data['grade'] ?? '')),
'reason' => trim((string) ($data['reason'] ?? '')),
'admin_name' => trim((string) ($data['admin_name'] ?? '')),
];
// Prefer logged-in admin name
$adminFromDb = $this->currentAdminName();
if ($adminFromDb !== '') {
$previewData['admin_name'] = $adminFromDb;
}
// Handle GET (return list of recent slips)
if (strtolower($this->laravelRequest->method()) === 'get') {
$builder = $this->log->newQuery();
// Filter by school year if not empty
if ($selectedYear !== '') {
$builder->where('school_year', $selectedYear);
}
// Normalize and apply semester filter
$hasFilter = false;
if ($selectedSemester !== '') {
$hasFilter = true;
if ($selectedSemester === 'fall') {
$builder->where(function ($q) {
$q->where('semester', 'fall')
->orWhere('semester', '1')
->orWhere('semester', 1);
});
} elseif ($selectedSemester === 'spring') {
$builder->where(function ($q) {
$q->where('semester', 'spring')
->orWhere('semester', '2')
->orWhere('semester', 2);
});
}
}
// Execute query
$logs = $builder->orderBy('id', 'DESC')->limit(50)->get()->toArray();
// Only use fallback when no filters are selected
if (empty($logs) && !$hasFilter && $selectedYear === '') {
$logs = $this->log->newQuery()->orderBy('id', 'DESC')->limit(50)->get()->toArray();
}
// Format rows
$rows = [];
foreach ($logs as $r) {
$dispDate = '';
if (!empty($r['slip_date'])) {
$ts = strtotime($r['slip_date']);
$dispDate = $ts ? date('m/d/Y', $ts) : '';
}
$dispTime = '';
if (!empty($r['time_in'])) {
$t = strtotime($r['time_in']) ?: strtotime('today ' . $r['time_in']);
$dispTime = $t ? date('h:i A', $t) : '';
}
$rows[] = [
'id' => (int) ($r['id'] ?? 0),
'school_year' => (string) ($r['school_year'] ?? ''),
'semester' => (string) ($r['semester'] ?? ''),
'student_name' => (string) ($r['student_name'] ?? ''),
'date' => $dispDate,
'time_in' => $dispTime,
'grade' => (string) ($r['grade'] ?? ''),
'reason' => (string) ($r['reason'] ?? ''),
'admin_name' => (string) ($r['admin_name'] ?? ''),
];
}
return $this->success([
'rows' => $rows,
'school_year' => $selectedYear,
'semester' => $selectedSemester,
], 'Late slip logs retrieved');
}
// POST mode (JSON preview for printer)
$cfg = $this->printerConfig();
$lineWidth = (int) ($cfg['chars_per_line'] ?? 48);
$feedLines = (int) ($cfg['feed_lines'] ?? 3);
$header = "Al Rahma School at ISGL\nSchool Year: {$previewData['school_year']}\n\n";
$body = implode("\n", $this->buildSlipLines($previewData, $lineWidth));
$footer = str_repeat("\n", $feedLines);
$full = $header . $body . $footer;
return $this->success([
'ok' => true,
'text' => $full,
'width' => $lineWidth,
], 'Preview generated');
} catch (\Throwable $e) {
Log::error('Late slip preview failed: ' . $e->getMessage());
return $this->error('Preview failed.', SymfonyResponse::HTTP_INTERNAL_SERVER_ERROR, [
'details' => $e->getMessage(),
]);
}
}
/**
* POST /api/v1/slips/reprint/{id}
* Reprint a late slip from a saved log row
*/
public function reprint($id = null): SymfonyResponse|JsonResponse
{
$id = (int) ($id ?? 0);
if ($id <= 0) {
return $this->error('Invalid slip id.', SymfonyResponse::HTTP_BAD_REQUEST);
}
try {
$row = $this->log->find($id);
} catch (\Throwable $e) {
$row = null;
}
if (!$row) {
return $this->error('Slip not found.', SymfonyResponse::HTTP_NOT_FOUND);
}
// Map DB row to print payload
$dateDisplay = '';
if (!empty($row['slip_date'])) {
$ts = strtotime($row['slip_date']);
$dateDisplay = $ts ? date('m/d/Y', $ts) : '';
}
if ($dateDisplay === '') {
$dateDisplay = date('m/d/Y');
}
$timeDisplay = '';
if (!empty($row['time_in'])) {
$ts = strtotime($row['time_in']);
if ($ts === false) {
$ts = strtotime('today ' . $row['time_in']);
}
$timeDisplay = $ts ? date('h:i A', $ts) : '';
}
if ($timeDisplay === '') {
$timeDisplay = date('h:i A');
}
$data = [
'school_year' => (string) ($row['school_year'] ?? ''),
'student_name' => (string) ($row['student_name'] ?? ''),
'date' => $dateDisplay,
'time_in' => $timeDisplay,
'grade' => (string) ($row['grade'] ?? ''),
'reason' => (string) ($row['reason'] ?? ''),
'admin_name' => (string) ($row['admin_name'] ?? ''),
];
// Always prefer current logged-in user's full name from DB
$adminFromDb = $this->currentAdminName();
if ($adminFromDb !== '') {
$data['admin_name'] = $adminFromDb;
}
try {
// Log again (best-effort) for audit trail
try {
$printedBy = $this->getCurrentUserId();
$logData = [
'school_year' => $data['school_year'],
'semester' => $this->semester,
'student_name' => $data['student_name'],
'slip_date' => $this->toDbDate($data['date']),
'time_in' => $this->toDbTime($data['time_in']),
'grade' => $data['grade'],
'reason' => $data['reason'],
'admin_name' => $data['admin_name'],
];
$this->log->logSlip($logData, $printedBy);
} catch (\Throwable $e) {
Log::error('Late slip reprint log insert failed: ' . $e->getMessage());
}
// Generate and return PDF
return $this->renderSlipPdfResponse($data);
} catch (\Throwable $e) {
Log::error('Reprint failed: ' . $e->getMessage());
return $this->error('Reprint failed: ' . $e->getMessage(), SymfonyResponse::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Build monospaced slip lines for receipt printing
*/
private function buildSlipLines(array $data, ?int $lineWidth = null): array
{
$cfgWidth = (int) ($this->printerConfig()['chars_per_line'] ?? 0);
$w = $lineWidth ?? ($cfgWidth > 0 ? $cfgWidth : 32);
$lines = [];
$lines[] = $this->fitLine('Student Name: ' . (string) ($data['student_name'] ?? ''), $w);
$lines[] = $this->fitLine('Date: ' . (string) ($data['date'] ?? '') . ' ' . 'Time In: ' . (string) ($data['time_in'] ?? ''), $w);
$lines[] = $this->fitLine('Grade: ' . (string) ($data['grade'] ?? ''), $w);
$lines[] = $this->fitLine('Reason: ' . (string) ($data['reason'] ?? ''), $w);
$lines[] = $this->fitLine('Admin: ' . (string) ($data['admin_name'] ?? ''), $w);
return $lines;
}
/**
* Create and return a configured Printer instance
*/
private function makePrinter(): Printer
{
$cfg = $this->printerConfig();
$mode = strtolower((string) ($cfg['mode'] ?? 'network'));
if ($mode === 'windows') {
if (stripos(PHP_OS_FAMILY, 'Windows') !== false) {
$name = (string) ($cfg['windows_name'] ?? 'POS-80');
$connector = new WindowsPrintConnector($name);
return new Printer($connector);
}
Log::warning('PRINTER_MODE=windows on non-Windows OS; falling back to network.');
$mode = 'network';
}
if ($mode === 'usb') {
if (stripos(PHP_OS_FAMILY, 'Windows') !== false) {
$name = (string) ($cfg['windows_name'] ?? 'POS-80');
$connector = new WindowsPrintConnector($name);
return new Printer($connector);
}
$vidStr = (string) ($cfg['usb_vid'] ?? '');
$pidStr = (string) ($cfg['usb_pid'] ?? '');
if ($vidStr === '' || $pidStr === '') {
throw new \RuntimeException('USB mode requires PRINTER_USB_VID and PRINTER_USB_PID');
}
$vid = $this->hexToInt($vidStr);
$pid = $this->hexToInt($pidStr);
$connector = new UsbPrintConnector($vid, $pid);
return new Printer($connector);
}
// Default: network
$host = (string) ($cfg['host'] ?? '192.168.1.100');
$port = (int) ($cfg['port'] ?? 9100);
$connector = new NetworkPrintConnector($host, $port);
return new Printer($connector);
}
/**
* Ensure any line is exactly $width characters
*/
private function fitLine(string $line, int $width): string
{
$line = (string) $line;
if (strlen($line) > $width) {
return substr($line, 0, $width);
}
return $line . str_repeat(' ', $width - strlen($line));
}
/**
* Convert hex string to int
*/
private function hexToInt(string $hexOrDec): int
{
$hexOrDec = trim($hexOrDec);
if ($hexOrDec === '') {
return 0;
}
if (stripos($hexOrDec, '0x') === 0) {
return intval($hexOrDec, 16);
}
return (int) $hexOrDec;
}
/**
* Get printer configuration from environment
*/
private function printerConfig(): array
{
$get = function ($keys, $default = null) {
$keys = is_array($keys) ? $keys : [$keys];
foreach ($keys as $k) {
$v = env($k);
if ($v !== null && $v !== '') {
return $v;
}
}
return $default;
};
return [
'mode' => strtolower((string) $get(['printer.mode', 'PRINTER_MODE'], 'network')),
'host' => (string) $get(['printer.host', 'PRINTER_HOST'], '192.168.1.100'),
'port' => (int) $get(['printer.port', 'PRINTER_PORT'], 9100),
'usb_vid' => (string) $get(['printer.usb.vid', 'PRINTER_USB_VID'], ''),
'usb_pid' => (string) $get(['printer.usb.pid', 'PRINTER_USB_PID'], ''),
'windows_name' => (string) $get(['printer.windows.name', 'PRINTER_WINDOWS_NAME'], 'POS-80'),
'chars_per_line' => (int) $get(['printer.chars_per_line', 'PRINTER_CHARS_PER_LINE'], 48),
'feed_lines' => (int) $get(['printer.feed_lines', 'PRINTER_FEED_LINES'], 3),
];
}
/**
* Convert user-provided date/time strings into DB formats
*/
private function toDbDate(?string $s): ?string
{
$s = trim((string) $s);
if ($s === '') {
return null;
}
$ts = strtotime($s);
return $ts ? date('Y-m-d', $ts) : null;
}
private function toDbTime(?string $s): ?string
{
$s = trim((string) $s);
if ($s === '') {
return null;
}
$ts = strtotime($s);
return $ts ? date('H:i:s', $ts) : null;
}
/**
* Get current admin full name using session user_id
*/
private function currentAdminName(): string
{
try {
$uid = $this->getCurrentUserId();
if ($uid && $uid > 0) {
$user = $this->user->select('firstname', 'lastname')->find($uid);
if ($user) {
$full = trim((string) ($user['firstname'] ?? '') . ' ' . (string) ($user['lastname'] ?? ''));
if ($full !== '') {
return $full;
}
}
}
} catch (\Throwable $e) {
// ignore and fall back
}
$first = trim((string) (Session::get('firstname') ?? ''));
$last = trim((string) (Session::get('lastname') ?? ''));
$full = trim($first . ' ' . $last);
if ($full !== '') {
return $full;
}
return (string) (Session::get('user_name') ?? '');
}
/**
* Render the slip as a PDF and return it as a response
*/
private function renderSlipPdfResponse(array $data): SymfonyResponse
{
// Determine paper size (mm)
$paper = strtolower((string) ($this->laravelRequest->input('paper') ?? env('SLIP_PAPER', 'card')));
// Estimate dynamic height
$fontPt = (float) ($this->laravelRequest->input('font_pt') ?? env('SLIP_FONT_PT', 13.0));
$lineH = (float) ($this->laravelRequest->input('line_h') ?? env('SLIP_LINE_H', 2.0));
$linesCount = (int) ($this->laravelRequest->input('rows') ?? 8);
if ($linesCount < 1) {
$linesCount = 8;
}
$ptPerLine = $fontPt * $lineH;
$mmPerPt = 25.4 / 72.0;
$contentMm = $ptPerLine * $linesCount * $mmPerPt;
$pageMarginsMm = 3.0 * 2;
$fudgeMm = (float) ($this->laravelRequest->input('fudge_mm') ?? env('SLIP_FUDGE_MM', 12.0));
$dynHeightMm = $contentMm + $pageMarginsMm + $fudgeMm;
switch ($paper) {
case 'cardp':
case 'card-portrait':
$wMm = 53.98;
$hMm = max($dynHeightMm, 40.0);
$wChars = 30;
break;
case 'receipt80':
$wMm = 72.0;
$hMm = 90.0;
$wChars = 30;
break;
case 'receipt58':
$wMm = 58.0;
$hMm = 80.0;
$wChars = 24;
break;
case 'card':
default:
$wMm = 85.60;
$hMm = max($dynHeightMm, 40.0);
$wChars = 40;
}
// Optional manual override
$hOverride = $this->laravelRequest->input('height_mm') ?? $this->laravelRequest->input('h');
if (is_numeric($hOverride)) {
$hMm = max(30.0, min(200.0, (float) $hOverride));
}
// Build lines
$w = $wChars;
$lines = [];
$lines[] = $this->fitLine('Al Rahma School at ISGL ' . ($data['school_year'] ?? ''), $w);
$lines[] = $this->fitLine('Student Name: ' . (string) ($data['student_name'] ?? ''), $w);
$lines[] = $this->fitLine('Date: ' . (string) ($data['date'] ?? ''), $w);
$lines[] = $this->fitLine('Time In: ' . (string) ($data['time_in'] ?? ''), $w);
$lines[] = $this->fitLine('Grade: ' . (string) ($data['grade'] ?? ''), $w);
$lines[] = $this->fitLine('Reason: ' . (string) ($data['reason'] ?? ''), $w);
$lines[] = $this->fitLine('Admin: ' . (string) ($data['admin_name'] ?? ''), $w);
// Generate HTML for PDF
$html = $this->generateSlipHtml($data, $lines, ['w_mm' => $wMm, 'h_mm' => $hMm]);
$options = new Options();
$options->set('isRemoteEnabled', true);
$options->set('defaultFont', 'Courier');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');
if ($hMm < $dynHeightMm) {
$hMm = $dynHeightMm;
}
// Set paper size
$mmToPt = 72 / 25.4;
$wPt = $wMm * $mmToPt;
$hPt = $hMm * $mmToPt;
$dompdf->setPaper([0, 0, $wPt, $hPt]);
$dompdf->render();
$filename = 'LateSlip_' . preg_replace('/[^A-Za-z0-9]+/', '_', (string) ($data['student_name'] ?? 'slip')) . '_' . date('Ymd_His') . '.pdf';
return response($dompdf->output(), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="' . $filename . '"',
]);
}
/**
* Generate HTML for the slip PDF
*/
private function generateSlipHtml(array $data, array $lines, array $page): string
{
$wMm = $page['w_mm'];
$hMm = $page['h_mm'];
$html = '<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
@page {
margin: 0;
size: ' . $wMm . 'mm ' . $hMm . 'mm;
}
body {
margin: 3mm;
font-family: "Courier New", Courier, monospace;
font-size: 13pt;
line-height: 2.0;
}
.line {
white-space: pre;
}
</style>
</head>
<body>';
foreach ($lines as $line) {
$html .= '<div class="line">' . htmlspecialchars($line, ENT_QUOTES, 'UTF-8') . '</div>';
}
$html .= '</body>
</html>';
return $html;
}
}
@@ -1,244 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Staff;
use App\Models\TeacherClass;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class StaffController extends BaseApiController
{
protected Staff $staff;
protected User $user;
protected TeacherClass $teacherClass;
protected Configuration $config;
protected string $schoolYear;
protected string $semester;
public function __construct()
{
parent::__construct();
$this->staff = model(Staff::class);
$this->user = model(User::class);
$this->teacherClass = model(TeacherClass::class);
$this->config = model(Configuration::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$role = $this->request->getGet('role');
// Roles we never show
$excludedRoles = ['student', 'parent', 'guest', 'inactive'];
$normalizedExcluded = array_map('strtolower', $excludedRoles);
$placeholders = implode(',', array_fill(0, count($normalizedExcluded), '?'));
$query = $this->staff->newQuery()
->whereRaw('LOWER(active_role) NOT IN (' . $placeholders . ')', $normalizedExcluded);
if (!empty($role)) {
$query->whereRaw('LOWER(active_role) = ?', [strtolower((string) $role)]);
}
$query->orderByDesc('created_at');
// Preload assignments for the selected school year (across all semesters)
$assignRows = DB::table('teacher_class as tc')
->select('tc.teacher_id', 'tc.position', 'cs.class_section_name')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
->where('tc.school_year', (string) $this->schoolYear)
->get()
->toArray();
$assignByTeacher = [];
foreach ($assignRows as $row) {
$teacherId = (int) ($row->teacher_id ?? 0);
if ($teacherId <= 0) {
continue;
}
$section = trim((string) ($row->class_section_name ?? ''));
if ($section === '') {
continue;
}
$position = strtolower((string) ($row->position ?? ''));
if (!in_array($position, ['main', 'ta'], true)) {
continue;
}
$assignByTeacher[$teacherId][] = $section . ' (' . $position . ')';
}
// Get all staff (not paginated) for issues count calculation
$allStaff = $query->get()->toArray();
$issuesCount = 0;
foreach ($allStaff as $staff) {
$roleName = strtolower((string) ($staff['active_role'] ?? ''));
if (in_array($roleName, ['teacher', 'teacher_assistant'], true)) {
$userId = (int) ($staff['user_id'] ?? 0);
$labels = $assignByTeacher[$userId] ?? [];
if (empty($labels)) {
$issuesCount++;
}
}
}
// Paginate the results
$result = $this->paginate($query, $page, $perPage);
// Apply transformations to paginated data
foreach ($result['data'] as &$staff) {
$userId = (int) ($staff['user_id'] ?? 0);
$staff['school_id'] = $userId > 0 ? $this->user->getSchoolIdByUserId($userId) : null;
$roleName = strtolower((string) ($staff['active_role'] ?? ''));
if (in_array($roleName, ['teacher', 'teacher_assistant'], true)) {
$labels = $assignByTeacher[$userId] ?? [];
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;
}
} else {
$staff['class_section'] = '—';
$staff['verification_issue'] = false;
}
}
return $this->success([
'data' => $result['data'],
'pagination' => $result['pagination'],
'issues_count' => $issuesCount,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
], 'Staff members retrieved successfully');
}
public function show($id = null)
{
$staff = $this->staff->find($id);
if (!$staff) {
return $this->respondError('Staff member not found', Response::HTTP_NOT_FOUND);
}
$staff['school_id'] = $this->user->getSchoolIdByUserId((int) ($staff['user_id'] ?? 0));
return $this->success($staff, 'Staff member retrieved successfully');
}
public function store()
{
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$now = utc_now();
$payload = [
'firstname' => trim((string) ($data['firstname'] ?? '')),
'lastname' => trim((string) ($data['lastname'] ?? '')),
'email' => trim((string) ($data['email'] ?? '')),
'phone' => trim((string) ($data['phone'] ?? '')),
'role_name' => trim((string) ($data['role_name'] ?? '')),
'school_year' => trim((string) ($data['school_year'] ?? $this->schoolYear)),
'status' => trim((string) ($data['status'] ?? 'Active')),
'created_at' => $now,
'updated_at' => $now,
];
// Set active_role from role_name if provided
if (!empty($payload['role_name'])) {
$payload['active_role'] = strtolower($payload['role_name']);
}
try {
$inserted = $this->staff->insert($payload);
if (!$inserted) {
return $this->respondError('Failed to create staff member', Response::HTTP_INTERNAL_SERVER_ERROR);
}
// Fetch the created record
$staff = $this->staff->where('created_at', $now)->orderBy('id', 'DESC')->first();
return $this->success($staff, 'Staff member added successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error('Staff creation error: ' . $e->getMessage());
return $this->respondError('Failed to create staff member', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update($id = null)
{
$staff = $this->staff->find($id);
if (!$staff) {
return $this->respondError('Staff member not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
// Only update fields provided by the request to avoid wiping data
$updateData = [];
$allowedFields = ['firstname', 'lastname', 'email', 'phone', 'role_name', 'school_year', 'status'];
foreach ($allowedFields as $field) {
if (array_key_exists($field, $data)) {
$value = $data[$field];
if ($value !== null && $value !== '') {
$updateData[$field] = trim((string) $value);
}
}
}
// Set active_role from role_name if role_name is being updated
if (isset($updateData['role_name']) && !empty($updateData['role_name'])) {
$updateData['active_role'] = strtolower($updateData['role_name']);
}
// Always bump updated_at
$updateData['updated_at'] = utc_now();
if (empty($updateData)) {
return $this->respondError('No changes detected', Response::HTTP_BAD_REQUEST);
}
try {
$updated = $this->staff->update($id, $updateData);
if (!$updated) {
return $this->respondError('Failed to update staff member', Response::HTTP_INTERNAL_SERVER_ERROR);
}
$updatedStaff = $this->staff->find($id);
return $this->success($updatedStaff, 'Staff member updated successfully');
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error('Staff update error: ' . $e->getMessage());
return $this->respondError('Failed to update staff member', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function destroy($id = null)
{
$staff = $this->staff->find($id);
if (!$staff) {
return $this->respondError('Staff member not found', Response::HTTP_NOT_FOUND);
}
try {
$this->staff->delete($id);
return $this->respondDeleted(null, 'Staff member deleted successfully');
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error('Staff deletion error: ' . $e->getMessage());
return $this->respondError('Failed to delete staff member', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\BaseApiController;
use App\Models\Stats;
use Symfony\Component\HttpFoundation\Response;
@@ -1,904 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\Configuration;
use App\Models\ClassSection;
use App\Models\Enrollment;
use App\Models\EmergencyContact;
use App\Models\StudentAllergy;
use App\Models\StudentMedicalCondition;
use App\Models\PromotionQueue;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
use Symfony\Component\HttpFoundation\Response;
class StudentController extends BaseApiController
{
protected Student $student;
protected StudentClass $studentClass;
protected Configuration $config;
protected ClassSection $classSection;
protected Enrollment $enrollment;
protected EmergencyContact $emergencyContact;
protected StudentAllergy $allergy;
protected StudentMedicalCondition $condition;
protected PromotionQueue $promotionQueue;
protected string $schoolYear;
protected string $semester;
public function __construct()
{
parent::__construct();
$this->student = model(Student::class);
$this->studentClass = model(StudentClass::class);
$this->config = model(Configuration::class);
$this->classSection = model(ClassSection::class);
$this->enrollment = model(Enrollment::class);
$this->emergencyContact = model(EmergencyContact::class);
$this->allergy = model(StudentAllergy::class);
$this->condition = model(StudentMedicalCondition::class);
$this->promotionQueue = model(PromotionQueue::class);
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$parentId = $this->request->getGet('parent_id');
$classId = $this->request->getGet('class_id');
$query = $this->student->newQuery();
if (!empty($parentId)) {
$query->where('parent_id', $parentId);
}
if (!empty($classId)) {
$studentIds = $this->studentClass->newQuery()
->where('class_section_id', $classId)
->pluck('student_id')
->toArray();
if (!empty($studentIds)) {
$query->whereIn('id', $studentIds);
} else {
return $this->success([
'data' => [],
'pagination' => [
'current_page' => $page,
'per_page' => $perPage,
'total' => 0,
'total_pages' => 0,
],
], 'No students found');
}
}
$result = $this->paginate($query, $page, $perPage);
$schoolYear = $this->config->getConfig('school_year');
foreach ($result['data'] as &$student) {
$student['current_class'] = $this->studentClass->newQuery()
->select([
'cs.*',
'c.class_name as class_name',
])
->from('student_class as sc')
->join('class_sections as cs', 'cs.id', '=', 'sc.class_section_id')
->join('classes as c', 'c.id', '=', 'cs.class_id')
->where('sc.student_id', $student['id'])
->where('sc.school_year', $schoolYear)
->where('sc.semester', $this->config->getConfig('semester'))
->where('cs.school_year', $schoolYear)
->where('cs.semester', $this->config->getConfig('semester'))
->first();
}
return $this->success($result, 'Students retrieved successfully');
}
public function show($id = null)
{
$student = $this->student->find($id);
if (!$student) {
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
}
$schoolYear = $this->config->getConfig('school_year');
$student['current_class'] = $this->studentClass->newQuery()
->select([
'cs.*',
'c.class_name as class_name',
])
->from('student_class as sc')
->join('class_sections as cs', 'cs.id', '=', 'sc.class_section_id')
->join('classes as c', 'c.id', '=', 'cs.class_id')
->where('sc.student_id', $id)
->where('sc.school_year', $schoolYear)
->where('sc.semester', $this->config->getConfig('semester'))
->where('cs.school_year', $schoolYear)
->where('cs.semester', $this->config->getConfig('semester'))
->first();
return $this->success($student, 'Student retrieved successfully');
}
public function store()
{
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'firstname' => 'required|min:2|max:100',
'lastname' => 'required|min:2|max:100',
'dob' => 'required|date',
'gender' => 'required|in:male,female',
'parent_id' => 'required|integer',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$data['school_year'] = $this->config->getConfig('school_year');
$data['semester'] = $this->config->getConfig('semester');
$data['registration_date'] = now()->toDateTimeString();
try {
$student = $this->student->create($data);
return $this->success($student->toArray(), 'Student created successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
Log::error('Student creation error: ' . $e->getMessage());
return $this->respondError('Failed to create student', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update($id = null)
{
$student = $this->student->find($id);
if (!$student) {
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
try {
DB::beginTransaction();
// Build student data payload
$studentData = [];
$allowedFields = [
'school_id', 'firstname', 'lastname', 'dob', 'age', 'gender',
'registration_grade', 'photo_consent', 'parent_id', 'registration_date',
'tuition_paid', 'year_of_registration', 'school_year', 'rfid_tag',
'semester', 'is_new'
];
foreach ($allowedFields as $field) {
if (array_key_exists($field, $data) && $data[$field] !== null && $data[$field] !== '') {
$value = $data[$field];
// Apply title case to names
if (in_array($field, ['firstname', 'lastname'])) {
$value = $this->titleCase((string) $value);
}
$studentData[$field] = $value;
}
}
// Handle DOB and age calculation
if (isset($data['dob'])) {
try {
$dob = Carbon::parse($data['dob']);
$studentData['dob'] = $dob->format('Y-m-d');
$studentData['age'] = $dob->age;
} catch (\Throwable $e) {
DB::rollBack();
return $this->respondError('Invalid date of birth format', Response::HTTP_BAD_REQUEST);
}
}
// Handle registration_date
if (isset($data['registration_date']) && $data['registration_date'] !== '') {
try {
$regDate = Carbon::parse($data['registration_date']);
$studentData['registration_date'] = $regDate->utc()->format('Y-m-d H:i:00');
} catch (\Throwable $e) {
// Ignore invalid dates
}
}
// Normalize boolean fields
if (isset($data['photo_consent'])) {
$studentData['photo_consent'] = (int) ($data['photo_consent'] === '1' || $data['photo_consent'] === true);
}
if (isset($data['tuition_paid'])) {
$studentData['tuition_paid'] = (int) ($data['tuition_paid'] === '1' || $data['tuition_paid'] === true);
}
if (isset($data['is_new'])) {
$studentData['is_new'] = (int) ($data['is_new'] === '1' || $data['is_new'] === true);
}
// Update student if there are changes
if (!empty($studentData)) {
$this->student->where('id', $id)->update($studentData);
}
// Handle health lists if provided
$rawPost = $data;
if (isset($rawPost['medical_conditions']) || isset($rawPost['medical_touched'])) {
$medTouched = ($rawPost['medical_touched'] ?? '0') === '1';
$medConditions = $rawPost['medical_conditions'] ?? '';
if ($medTouched || (isset($rawPost['medical_conditions']) && trim($medConditions) !== '')) {
$normConditions = $this->normalizeHealthList($medConditions, 100);
$this->syncHealthList($id, $this->condition, 'condition_name', $normConditions);
}
}
if (isset($rawPost['allergies']) || isset($rawPost['allergies_touched'])) {
$allTouched = ($rawPost['allergies_touched'] ?? '0') === '1';
$allergies = $rawPost['allergies'] ?? '';
if ($allTouched || (isset($rawPost['allergies']) && trim($allergies) !== '')) {
$normAllergies = $this->normalizeHealthList($allergies, 100);
$this->syncHealthList($id, $this->allergy, 'allergy', $normAllergies);
}
}
DB::commit();
$updated = $this->student->find($id);
return $this->success($updated, 'Student updated successfully');
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Student update error: ' . $e->getMessage());
return $this->respondError('Failed to update student: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function destroy($id = null)
{
$student = $this->student->find($id);
if (!$student) {
return $this->respondError('Student not found', Response::HTTP_NOT_FOUND);
}
try {
$this->student->delete($id);
return $this->respondDeleted(null, 'Student deleted successfully');
} catch (\Throwable $e) {
Log::error('Student deletion error: ' . $e->getMessage());
return $this->respondError('Failed to delete student', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/students/assign-class
* Assign a student to a class section
*/
public function assignClassStudent(): JsonResponse
{
$data = $this->payloadData();
$studentId = (int) ($data['student_id'] ?? 0);
$classSectionId = (int) ($data['class_section_id'] ?? 0);
$userId = $this->getCurrentUserId();
$now = utc_now();
// Validate input
if (!$studentId || !$classSectionId) {
return $this->error('Missing required data (student/class section).', Response::HTTP_BAD_REQUEST);
}
// Check student
$student = $this->student->find($studentId);
if (!$student) {
return $this->error('Student not found.', Response::HTTP_NOT_FOUND);
}
// Robust section lookup: allow id OR class_section_id
$section = $this->classSection->newQuery()
->where(function ($q) use ($classSectionId) {
$q->where('id', $classSectionId)
->orWhere('class_section_id', $classSectionId);
})
->first();
if (!$section) {
return $this->error('Class/Section not found.', Response::HTTP_NOT_FOUND);
}
$displayName = $this->formatClassSectionDisplayName($section, $classSectionId);
// Derive parent class_id from the section row
$parentClassId = (int) ($section['class_id'] ?? 0);
if (!$parentClassId) {
$row = DB::table('classSection')
->select('class_id')
->where(function ($q) use ($section, $classSectionId) {
$q->where('id', $section['id'] ?? $classSectionId)
->orWhere('class_section_id', $classSectionId);
})
->first();
if ($row && isset($row->class_id)) {
$parentClassId = (int) $row->class_id;
}
}
try {
DB::beginTransaction();
// Upsert student_class
$payload = [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'updated_by' => $userId,
'updated_at' => $now,
];
$existing = $this->studentClass->newQuery()
->where('student_id', $studentId)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->first();
if ($existing) {
$this->studentClass->where('id', $existing['id'])->update($payload);
} else {
$payload['created_at'] = $now;
$this->studentClass->insert($payload);
}
// Update enrollment for current term (if exists)
$enroll = $this->enrollment->newQuery()
->where('student_id', $studentId)
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->first();
if ($enroll) {
$this->enrollment->where('id', $enroll['id'])->update([
'class_section_id' => $classSectionId,
'enrollment_status' => 'payment pending',
'admission_status' => 'accepted',
'updated_at' => $now,
]);
}
// Re-tag attendance rows
$attnStats = $this->updateStudentAttendanceSection(
$studentId,
$classSectionId,
$parentClassId ?: 0,
$this->semester,
$this->schoolYear,
$userId
);
// Re-tag score rows
$scoreStats = $this->updateStudentScoresSection(
$studentId,
$classSectionId,
$this->semester,
$this->schoolYear,
$userId
);
DB::commit();
return $this->success([
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'class_section_name' => $displayName,
'attendance_updates' => $attnStats,
'score_updates' => $scoreStats,
], 'Assignment saved.');
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Unable to assign class: ' . $e->getMessage());
return $this->error('Unable to assign class: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/v1/students/auto-distribute
* Auto distribute students into lettered sections for a class
*/
public function autoDistributeSections(): JsonResponse
{
$data = $this->payloadData();
$classId = (int) ($data['class_id'] ?? 0);
$classSectionId = (int) ($data['class_section_id'] ?? 0);
$perSec = (int) ($data['students_per_section'] ?? 0);
$year = trim((string) ($data['school_year'] ?? $this->schoolYear));
if ($classId <= 0 && $classSectionId > 0) {
$cid = $this->classSection->getClassId($classSectionId);
$classId = (int) ($cid ?? 0);
}
if ($classId <= 0 || $perSec <= 0) {
return $this->error('Invalid class_id or students_per_section.', Response::HTTP_BAD_REQUEST);
}
try {
// Candidates from promotion queue
$cands = $this->promotionQueue->newQuery()
->select('promotion_queue.*', 'students.gender')
->join('students', 'students.id', '=', 'promotion_queue.student_id', 'left')
->where('promotion_queue.to_class_id', $classId)
->where('promotion_queue.school_year_to', $year)
->whereIn('promotion_queue.status', ['queued', 'assigned'])
->get()
->toArray();
if (empty($cands)) {
return $this->error('No students found in promotion queue for selected class/year.', Response::HTTP_BAD_REQUEST);
}
// Filter to those with enrollment
$studentIds = array_map(fn($r) => (int) $r['student_id'], $cands);
$enrolledIds = [];
if (!empty($studentIds)) {
$rows = DB::table('enrollments')
->select('student_id')
->whereIn('student_id', $studentIds)
->where('school_year', $year)
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
->groupBy('student_id')
->get()
->toArray();
$enrolledIds = array_map(fn($r) => (int) $r->student_id, $rows);
}
$cands = array_values(array_filter($cands, fn($r) => in_array((int) $r['student_id'], $enrolledIds, true)));
if (empty($cands)) {
return $this->error('No eligible enrolled students found to distribute.', Response::HTTP_BAD_REQUEST);
}
$total = count($cands);
$sectionsNeeded = (int) ceil($total / $perSec);
// Fetch lettered sections
$letters = $this->classSection->getLetterSectionsByClassId($classId);
if (empty($letters)) {
return $this->error('No lettered sections found for the selected class.', Response::HTTP_BAD_REQUEST);
}
if (count($letters) < $sectionsNeeded) {
return $this->error('Not enough sections available. Needed: ' . $sectionsNeeded . ', available: ' . count($letters), Response::HTTP_BAD_REQUEST);
}
$letters = array_slice($letters, 0, $sectionsNeeded);
// Prepare buckets
$buckets = [];
foreach ($letters as $idx => $sec) {
$buckets[$idx] = [
'class_section_id' => (int) $sec['class_section_id'],
'assigned' => [],
'male' => 0,
'female' => 0,
];
}
// Split by gender
$males = [];
$females = [];
foreach ($cands as $r) {
$g = strtolower((string) ($r['gender'] ?? ''));
if ($g === 'female') {
$females[] = $r;
} else {
$males[] = $r;
}
}
// Helper to pick next bucket
$pickBucket = function (string $gender) use (&$buckets, $perSec): ?int {
$bestIdx = null;
$bestCnt = PHP_INT_MAX;
foreach ($buckets as $i => $b) {
if (count($b['assigned']) >= $perSec) {
continue;
}
$cnt = ($gender === 'female') ? $b['female'] : $b['male'];
if ($cnt < $bestCnt) {
$bestCnt = $cnt;
$bestIdx = $i;
}
}
return $bestIdx;
};
// Assign males then females
foreach ($males as $r) {
$bi = $pickBucket('male');
if ($bi === null) {
break;
}
$buckets[$bi]['assigned'][] = (int) $r['student_id'];
$buckets[$bi]['male']++;
}
foreach ($females as $r) {
$bi = $pickBucket('female');
if ($bi === null) {
break;
}
$buckets[$bi]['assigned'][] = (int) $r['student_id'];
$buckets[$bi]['female']++;
}
// Persist assignments
$promoIdsBySid = [];
foreach ($cands as $r) {
$promoIdsBySid[(int) $r['student_id']] = (int) $r['id'];
}
$updatedBy = $this->getCurrentUserId();
$now = utc_now();
DB::beginTransaction();
foreach ($buckets as $b) {
$secId = (int) $b['class_section_id'];
foreach ($b['assigned'] as $sid) {
// Update promotion queue
if (isset($promoIdsBySid[$sid])) {
$this->promotionQueue->where('id', $promoIdsBySid[$sid])->update([
'to_class_section_id' => $secId,
'status' => 'assigned',
'updated_by' => $updatedBy,
'updated_at' => $now,
]);
}
// Upsert student_class
$exists = $this->studentClass->newQuery()
->where('student_id', $sid)
->where('school_year', $year)
->where('semester', $this->semester)
->first();
$payload = [
'student_id' => $sid,
'class_section_id' => $secId,
'school_year' => $year,
'semester' => $this->semester,
'updated_by' => $updatedBy,
'updated_at' => $now,
];
if ($exists) {
$this->studentClass->where('id', $exists['id'])->update($payload);
} else {
$payload['created_at'] = $now;
$this->studentClass->insert($payload);
}
}
}
DB::commit();
// Build summary
$nameById = [];
foreach ($letters as $secRow) {
$nameById[(int) $secRow['class_section_id']] = (string) ($secRow['class_section_name'] ?? '');
}
$summary = [];
foreach ($buckets as $b) {
$secId = (int) $b['class_section_id'];
$summary[] = [
'class_section_id' => $secId,
'class_section_name' => $nameById[$secId] ?? (string) $secId,
'total' => count($b['assigned']),
'male' => $b['male'],
'female' => $b['female'],
];
}
return $this->success([
'sections' => $summary,
], 'Auto distribution completed.');
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Auto distribution failed: ' . $e->getMessage());
return $this->error('Auto distribution failed: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/students/promotion-totals
* Return totals per base class for promotion_queue in selected year
*/
public function promotionTotalsApi(): JsonResponse
{
try {
$year = trim((string) ($this->laravelRequest->query('school_year') ?? $this->schoolYear));
// Fetch base sections
$bases = $this->classSection->newQuery()
->whereRaw("class_section_name NOT LIKE '%-%'")
->orderBy('class_id', 'ASC')
->get()
->toArray();
$wanted = [];
foreach ($bases as $r) {
$nameRaw = (string) ($r['class_section_name'] ?? '');
$name = strtolower($nameRaw);
if ($name === 'kg' || $name === 'youth') {
$wanted[] = $r;
continue;
}
if (ctype_digit($name)) {
$num = (int) $name;
if ($num >= 1 && $num <= 9) {
$wanted[] = $r;
}
}
}
$out = [];
foreach ($wanted as $r) {
$classId = (int) $r['class_id'];
$cands = DB::table('promotion_queue as pq')
->select('pq.student_id')
->join('enrollments as e', function ($join) use ($year) {
$join->on('e.student_id', '=', 'pq.student_id')
->where('e.school_year', $year);
}, 'left')
->where('pq.to_class_id', $classId)
->where('pq.school_year_to', $year)
->whereIn('pq.status', ['queued', 'assigned', 'applied'])
->whereIn('e.enrollment_status', ['payment pending', 'enrolled'])
->groupBy('pq.student_id')
->get()
->toArray();
$out[] = [
'class_id' => $classId,
'class_section_id' => (int) ($r['class_section_id'] ?? 0),
'class_section_name' => (string) ($r['class_section_name'] ?? ''),
'total' => count($cands),
];
}
return $this->success([
'year' => $year,
'rows' => $out,
], 'Promotion totals retrieved');
} catch (\Throwable $e) {
Log::error('Promotion totals error: ' . $e->getMessage());
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Update attendance tables for a student's new class section
*/
private function updateStudentAttendanceSection(
int $studentId,
int $newClassSectionId,
int $newClassId,
string $semester,
string $schoolYear,
?int $modifiedBy = null
): array {
$now = utc_now();
// Update attendance_data
$dataUpdated = DB::table('attendance_data')
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->update([
'class_section_id' => $newClassSectionId,
'class_id' => $newClassId,
'updated_at' => $now,
'modified_by' => $modifiedBy,
]);
// Update attendance_record
$recUpdated = DB::table('attendance_record')
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->update([
'class_section_id' => $newClassSectionId,
'updated_at' => $now,
'modified_by' => $modifiedBy,
]);
return [
'attendance_data_updated' => $dataUpdated,
'attendance_record_updated' => $recUpdated,
];
}
/**
* Update all score-related tables for a student to the new class_section_id
*/
private function updateStudentScoresSection(
int $studentId,
int $newClassSectionId,
string $semester,
string $schoolYear,
?int $modifiedBy = null
): array {
$now = utc_now();
$tables = [
'homework',
'quiz',
'project',
'participation',
'midterm_exam',
'final_exam',
'final_score',
'semester_scores',
];
$results = [];
foreach ($tables as $tbl) {
$updated = DB::table($tbl)
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->update([
'class_section_id' => $newClassSectionId,
'updated_at' => $now,
'updated_by' => $modifiedBy,
]);
$results[$tbl . '_updated'] = $updated;
}
return $results;
}
/**
* Build a display name from section row
*/
private function formatClassSectionDisplayName(array $row, int $fallbackId): string
{
if (!empty($row['class_section_name'])) {
return (string) $row['class_section_name'];
}
if (!empty($row['section_name'])) {
return (string) $row['section_name'];
}
if (!empty($row['name'])) {
return (string) $row['name'];
}
if (!empty($row['title'])) {
return (string) $row['title'];
}
$grade = $row['grade_name'] ?? $row['grade'] ?? $row['class_name'] ?? null;
$letter = $row['section'] ?? $row['section_letter'] ?? $row['letter'] ?? null;
if ($grade && $letter) {
return "{$grade} - {$letter}";
}
if ($grade) {
return (string) $grade;
}
return 'Section #' . $fallbackId;
}
/**
* Normalize a comma/semicolon/newline list
*/
private function normalizeHealthList(?string $s, int $maxLen = 100): array
{
$s = (string) $s;
if ($s === '') {
return [];
}
$parts = preg_split('/[,\n;]+/u', $s, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$outAssoc = [];
foreach ($parts as $p) {
$v = trim(preg_replace('/\s+/u', ' ', $p));
if ($v === '') {
continue;
}
if (preg_match('/^(none|n\/a|na|null|nil|no)$/i', $v)) {
continue;
}
$v = mb_substr($v, 0, $maxLen, 'UTF-8');
$outAssoc[mb_strtolower($v, 'UTF-8')] = $v;
}
return array_values($outAssoc);
}
/**
* Diff-sync helper for health lists
*/
private function syncHealthList(int $studentId, $model, string $field, array $newValues): void
{
// Fetch current values
$rows = $model->newQuery()->where('student_id', $studentId)->select("id, {$field}")->get()->toArray();
$current = [];
$byId = [];
foreach ($rows as $r) {
$val = (string) ($r[$field] ?? '');
$key = mb_strtolower(trim($val), 'UTF-8');
if ($key === '') {
continue;
}
$current[$key] = $val;
$byId[$key] = (int) $r['id'];
}
// Build new set
$incoming = [];
foreach ($newValues as $v) {
$key = mb_strtolower(trim($v), 'UTF-8');
if ($key === '') {
continue;
}
$incoming[$key] = $v;
}
// Compute diffs
$toDeleteKeys = array_diff(array_keys($current), array_keys($incoming));
$toInsertKeys = array_diff(array_keys($incoming), array_keys($current));
// Delete removed
if (!empty($toDeleteKeys)) {
$ids = array_map(fn($k) => $byId[$k], $toDeleteKeys);
if (!empty($ids)) {
$model->newQuery()->whereIn('id', $ids)->delete();
}
}
// Insert new
if (!empty($toInsertKeys)) {
$batch = [];
foreach ($toInsertKeys as $k) {
$batch[] = [
'student_id' => $studentId,
$field => $incoming[$k],
];
}
if (!empty($batch)) {
$model->newQuery()->insert($batch);
}
}
}
/**
* Convert string to title case
*/
private function titleCase(string $s): string
{
$s = strip_tags($s);
$s = mb_strtolower($s ?: '', 'UTF-8');
return mb_convert_case($s, MB_CASE_TITLE, 'UTF-8');
}
}
@@ -1,134 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Supplier;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;
class SupplierController extends BaseApiController
{
protected Supplier $supplier;
public function __construct()
{
parent::__construct();
$this->supplier = model(Supplier::class);
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$keyword = trim((string) ($this->request->getGet('q') ?? ''));
$query = $this->supplier->newQuery()->orderBy('name', 'ASC');
if ($keyword !== '') {
$query->where(function ($q) use ($keyword) {
$q->where('name', 'LIKE', "%{$keyword}%")
->orWhere('email', 'LIKE', "%{$keyword}%")
->orWhere('phone', 'LIKE', "%{$keyword}%");
});
}
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Suppliers retrieved successfully');
}
public function show($id = null)
{
$supplier = $this->supplier->find($id);
if (!$supplier) {
return $this->respondError('Supplier not found', Response::HTTP_NOT_FOUND);
}
return $this->success($supplier, 'Supplier retrieved successfully');
}
public function store()
{
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'name' => 'required|max:255',
'email' => 'nullable|email',
'phone' => 'nullable|max:50',
];
$validator = Validator::make($data, $rules);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
try {
$supplier = $this->supplier->create([
'name' => $data['name'],
'email' => $data['email'] ?? null,
'phone' => $data['phone'] ?? null,
'address' => $data['address'] ?? null,
'notes' => $data['notes'] ?? null,
]);
return $this->success($supplier->toArray(), 'Supplier created successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
Log::error('Supplier creation error: ' . $e->getMessage());
return $this->respondError('Failed to create supplier', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update($id = null)
{
$supplier = $this->supplier->find($id);
if (!$supplier) {
return $this->respondError('Supplier not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$allowed = ['name', 'email', 'phone', 'address', 'notes'];
$updateData = [];
foreach ($allowed as $field) {
if (array_key_exists($field, $data)) {
$updateData[$field] = $data[$field];
}
}
if (empty($updateData)) {
return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST);
}
try {
$this->supplier->where('id', $id)->update($updateData);
$updated = $this->supplier->find($id);
return $this->success($updated, 'Supplier updated successfully');
} catch (\Throwable $e) {
Log::error('Supplier update error: ' . $e->getMessage());
return $this->respondError('Failed to update supplier', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function destroy($id = null)
{
$supplier = $this->supplier->find($id);
if (!$supplier) {
return $this->respondError('Supplier not found', Response::HTTP_NOT_FOUND);
}
try {
$this->supplier->delete($id);
return $this->respondDeleted(null, 'Supplier deleted successfully');
} catch (\Throwable $e) {
Log::error('Supplier deletion error: ' . $e->getMessage());
return $this->respondError('Failed to delete supplier', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,110 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\SupplyCategory;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;
class SupplyCategoryController extends BaseApiController
{
protected SupplyCategory $category;
public function __construct()
{
parent::__construct();
$this->category = model(SupplyCategory::class);
}
public function index()
{
$categories = $this->category->newQuery()
->orderBy('name', 'ASC')
->get()
->toArray();
return $this->success($categories, 'Supply categories retrieved successfully');
}
public function show($id = null)
{
$category = $this->category->find($id);
if (!$category) {
return $this->respondError('Category not found', Response::HTTP_NOT_FOUND);
}
return $this->success($category, 'Category retrieved successfully');
}
public function store()
{
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'name' => 'required|max:255|unique:supply_categories,name',
];
$validator = Validator::make($data, $rules);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
try {
$category = $this->category->create(['name' => $data['name']]);
return $this->success($category->toArray(), 'Category created successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'Category creation error: ' . $e->getMessage());
return $this->respondError('Failed to create category', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update($id = null)
{
$category = $this->category->find($id);
if (!$category) {
return $this->respondError('Category not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
if (empty($data) || !array_key_exists('name', $data)) {
return $this->respondError('Name is required', Response::HTTP_BAD_REQUEST);
}
$rules = [
'name' => 'required|max:255|unique:supply_categories,name,' . $id,
];
$validator = Validator::make($data, $rules);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
try {
$this->category->where('id', $id)->update(['name' => $data['name']]);
$updated = $this->category->find($id);
return $this->success($updated, 'Category updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Category update error: ' . $e->getMessage());
return $this->respondError('Failed to update category', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function destroy($id = null)
{
$category = $this->category->find($id);
if (!$category) {
return $this->respondError('Category not found', Response::HTTP_NOT_FOUND);
}
try {
$this->category->delete($id);
return $this->respondDeleted(null, 'Category deleted successfully');
} catch (\Throwable $e) {
log_message('error', 'Category deletion error: ' . $e->getMessage());
return $this->respondError('Failed to delete category', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,151 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\SupportRequest;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use Symfony\Component\HttpFoundation\Response;
class SupportController extends BaseApiController
{
protected SupportRequest $supportRequest;
protected User $user;
public function __construct()
{
parent::__construct();
$this->supportRequest = model(SupportRequest::class);
$this->user = model(User::class);
}
public function submit()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('You must be logged in to submit a support request', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'subject' => 'required|min:3|max:255',
'message' => 'required|min:10',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
try {
$userRecord = $this->user->find($user->id);
if (!$userRecord) {
return $this->respondError('User not found', Response::HTTP_NOT_FOUND);
}
$fullName = trim(($userRecord['firstname'] ?? '') . ' ' . ($userRecord['lastname'] ?? ''));
$supportRequest = $this->supportRequest->create([
'user_id' => $user->id,
'subject' => $data['subject'],
'message' => $data['message'],
'status' => 'open',
]);
$emailSent = $this->sendSupportEmail(
$userRecord['email'] ?? null,
$fullName,
$data['subject'],
$data['message']
);
return $this->success([
'request' => $supportRequest->toArray(),
'email_sent' => $emailSent,
], 'Support request submitted successfully', Response::HTTP_CREATED);
} catch (\Throwable $e) {
log_message('error', 'Support request error: ' . $e->getMessage());
return $this->respondError('Failed to submit support request', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function requests()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage= min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$status = $this->request->getGet('status');
$query = $this->supportRequest->newQuery()
->where('user_id', $user->id)
->orderByDesc('created_at');
if (!empty($status)) {
$query->where('status', $status);
}
$result = $this->paginate($query, $page, $perPage);
return $this->success($result, 'Support requests retrieved successfully');
}
public function show($id = null)
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$request = $this->supportRequest->find($id);
if (!$request) {
return $this->respondError('Support request not found', Response::HTTP_NOT_FOUND);
}
if ((int) $request['user_id'] !== $user->id && !$this->isAdmin($user->id)) {
return $this->respondError('Access denied', Response::HTTP_FORBIDDEN);
}
return $this->success($request, 'Support request retrieved successfully');
}
protected function sendSupportEmail(?string $userEmail, string $fullName, string $subject, string $message): bool
{
if (empty($userEmail)) {
return false;
}
try {
Mail::raw($message, function ($mail) use ($userEmail, $fullName, $subject) {
$mail->from($userEmail, $fullName)
->to('support@alrahmaisgl.org')
->subject($subject);
});
return true;
} catch (\Throwable $e) {
log_message('error', 'Support email error: ' . $e->getMessage());
return false;
}
}
protected function isAdmin(int $userId): bool
{
try {
return DB::table('user_roles as ur')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->where('ur.user_id', $userId)
->whereRaw('LOWER(r.name) = ?', ['admin'])
->exists();
} catch (\Throwable $e) {
log_message('error', 'Failed to determine admin role: ' . $e->getMessage());
return false;
}
}
}
@@ -1,748 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\TeacherClass;
use App\Models\User;
use App\Models\Teacher;
use App\Models\Student;
use App\Models\ClassSection;
use App\Models\StudentClass;
use App\Models\StudentMedicalCondition;
use App\Models\StudentAllergy;
use App\Models\StaffAttendance;
use App\Services\EmailService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class TeacherController extends BaseApiController
{
protected User $user;
protected TeacherClass $teacherClass;
protected Configuration $config;
protected Teacher $teacher;
protected Student $student;
protected ClassSection $classSection;
protected StudentClass $studentClass;
protected StudentMedicalCondition $medical;
protected StudentAllergy $allergy;
protected StaffAttendance $staffAttendance;
protected EmailService $emailService;
protected string $semester;
protected string $schoolYear;
public function __construct()
{
parent::__construct();
$this->user = model(User::class);
$this->teacherClass = model(TeacherClass::class);
$this->config = model(Configuration::class);
$this->teacher = model(Teacher::class);
$this->student = model(Student::class);
$this->classSection = model(ClassSection::class);
$this->studentClass = model(StudentClass::class);
$this->medical = model(StudentMedicalCondition::class);
$this->allergy = model(StudentAllergy::class);
$this->staffAttendance = model(StaffAttendance::class);
$this->emailService = app(EmailService::class);
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$query = $this->user->newQuery()
->select('users.*')
->join('user_roles', 'user_roles.user_id', '=', 'users.id')
->join('roles', 'roles.id', '=', 'user_roles.role_id')
->whereIn('LOWER(roles.name)', ['teacher', 'teacher_assistant'])
->groupBy('users.id')
->orderBy('users.lastname', 'ASC');
$result = $this->paginate($query, $page, $perPage);
foreach ($result['data'] as &$teacher) {
unset($teacher['password'], $teacher['token']);
$assignments = $this->teacherClass->newQuery()
->where('teacher_id', $teacher['id'])
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->get()
->toArray();
$teacher['class_assignments'] = $assignments;
}
return $this->success($result, 'Teachers retrieved successfully');
}
public function show($id = null)
{
$teacher = $this->user->newQuery()
->select('users.*')
->join('user_roles', 'user_roles.user_id', '=', 'users.id')
->join('roles', 'roles.id', '=', 'user_roles.role_id')
->whereIn('LOWER(roles.name)', ['teacher', 'teacher_assistant'])
->where('users.id', $id)
->first();
if (!$teacher) {
return $this->respondError('Teacher not found', Response::HTTP_NOT_FOUND);
}
$teacherArray = $teacher->toArray();
unset($teacherArray['password'], $teacherArray['token']);
$assignments = $this->teacherClass->newQuery()
->where('teacher_id', $id)
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->get()
->toArray();
$teacherArray['class_assignments'] = $assignments;
return $this->success($teacherArray, 'Teacher retrieved successfully');
}
public function getClasses($id = null)
{
$assignments = $this->teacherClass->newQuery()
->select([
'tc.*',
'cs.class_section_name',
'c.name as class_name',
])
->from('teacher_class as tc')
->leftJoin('class_sections as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
->leftJoin('classes as c', 'c.id', '=', 'cs.class_id')
->where('tc.teacher_id', $id)
->where('tc.school_year', $this->schoolYear)
->where('tc.semester', $this->semester)
->orderBy('cs.class_section_name', 'ASC')
->get()
->toArray();
return $this->success($assignments, 'Teacher classes retrieved successfully');
}
/**
* GET /api/v1/teachers/class-view
* Get class view data for the logged-in teacher
*/
public function getClassView()
{
try {
$userId = $this->getCurrentUserId();
if (!$userId) {
return $this->respondError('User not logged in', Response::HTTP_UNAUTHORIZED);
}
// Fetch all assignments (as main or TA)
$classes = $this->teacherClass->getClassAssignmentsByUserId($userId, $this->schoolYear);
if (empty($classes)) {
return $this->success([
'has_classes' => false,
'message' => 'You do not have an assigned class yet. Please contact the administration.',
], 'No classes found');
}
// Identify user and their role
$user = $this->user->find($userId);
if (!$user) {
return $this->respondError('User not found', Response::HTTP_NOT_FOUND);
}
$roles = DB::table('user_roles ur')
->select('r.name')
->join('roles r', 'r.id', '=', 'ur.role_id')
->where('ur.user_id', $userId)
->get()
->map(fn($r) => strtolower($r->name))
->toArray();
$roleName = 'teacher';
if (in_array('teacher', $roles)) {
$roleName = 'teacher';
} elseif (in_array('teacher_assistant', $roles)) {
$roleName = 'teacher_assistant';
} else {
return $this->respondError('Access denied', Response::HTTP_FORBIDDEN);
}
// Collect class section IDs
$classSectionIds = array_column($classes, 'class_section_id');
$studentsData = [];
if (!empty($classSectionIds)) {
// Fetch all students for the selected class sections
$students = $this->studentClass->getStudentsByClassSectionIds($classSectionIds);
if (!empty($students)) {
// Collect unique student IDs
$studentIds = array_values(array_unique(array_map(
static fn(array $row) => (int) ($row['student_id'] ?? 0),
$students
)));
$studentIds = array_values(array_filter($studentIds));
// Bulk fetch medical conditions & allergies grouped by student_id
$conditionsByStudent = !empty($studentIds)
? $this->medical->getMedicalByStudentIds($studentIds)
: [];
$allergiesByStudent = !empty($studentIds)
? $this->allergy->getAllergiesByStudentIds($studentIds)
: [];
// Attach medical/allergy data to each student
foreach ($students as $student) {
$sid = (int) ($student['student_id'] ?? 0);
$cid = (int) ($student['class_section_id'] ?? 0);
$medList = array_column($conditionsByStudent[$sid] ?? [], 'condition_name');
$algList = array_column($allergiesByStudent[$sid] ?? [], 'allergy');
$student['medical_conditions'] = $medList;
$student['allergies'] = $algList;
$student['medical_conditions_text'] = implode(', ', $medList);
$student['allergies_text'] = implode(', ', $algList);
$studentsData[$cid][] = $student;
}
}
}
// Build assigned staff lists (all Main teachers and all TAs) per class section
$assignedNames = [];
if (!empty($classSectionIds)) {
$rows = DB::table('teacher_class tc')
->select('tc.class_section_id', 'tc.position', 'u.firstname', 'u.lastname')
->join('users u', 'u.id', '=', 'tc.teacher_id')
->whereIn('tc.class_section_id', $classSectionIds)
->where('tc.school_year', $this->schoolYear)
->where('tc.semester', $this->semester)
->orderBy('tc.class_section_id', 'ASC')
->orderByRaw("FIELD(tc.position,'main','ta')")
->orderBy('u.firstname', 'ASC')
->get();
foreach ($rows as $r) {
$sid = (int) ($r->class_section_id ?? 0);
$pos = strtolower((string) ($r->position ?? ''));
$name = trim(($r->firstname ?? '') . ' ' . ($r->lastname ?? ''));
if ($sid === 0 || $name === '') continue;
if (in_array($pos, ['main', 'teacher'], true)) {
$assignedNames[$sid]['mains'][] = $name;
} elseif (in_array($pos, ['ta', 'assistant', 'teacher_assistant'], true)) {
$assignedNames[$sid]['tas'][] = $name;
} else {
$assignedNames[$sid]['others'][] = $name;
}
}
}
$classSectionId = $classSectionIds[0] ?? null;
return $this->success([
'teacher_name' => trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')),
'role_name' => $roleName,
'classes' => $classes,
'students' => $studentsData[$classSectionId] ?? [],
'studentsData' => $studentsData,
'class_section_id' => $classSectionId,
'assignedNames' => $assignedNames,
], 'Class view data retrieved successfully');
} catch (\Exception $e) {
Log::error('Error in TeacherController::getClassView(): ' . $e->getMessage());
return $this->respondError('An error occurred. Please try again later.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* GET /api/v1/teachers/class-assignments
* Get teacher class assignment data for administration
*/
public function getTeacherClassAssignments()
{
$forYear = trim((string) ($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? ''));
$payload = $this->buildTeacherClassAssignmentPayload($forYear !== '' ? $forYear : null);
$payload['ok'] = true;
return $this->success($payload, 'Teacher class assignments retrieved successfully');
}
/**
* POST /api/v1/teachers/class-assignments/assign
* Assign a teacher to a class
*/
public function assignClassTeacher()
{
$data = $this->payloadData();
$result = $this->handleAssignClassTeacher($data);
$statusCode = $result['ok'] ? Response::HTTP_OK : Response::HTTP_UNPROCESSABLE_ENTITY;
return response()->json($result, $statusCode);
}
/**
* DELETE /api/v1/teachers/class-assignments
* Delete a teacher class assignment
*/
public function deleteAssignment()
{
$data = $this->payloadData();
$result = $this->handleDeleteAssignment($data);
$statusCode = $result['ok'] ? Response::HTTP_OK : Response::HTTP_UNPROCESSABLE_ENTITY;
return response()->json($result, $statusCode);
}
/**
* GET /api/v1/teachers/absence-form
* Get absence form data for the logged-in teacher
*/
public function getAbsenceForm()
{
$userId = $this->getCurrentUserId();
if (!$userId) {
return $this->respondError('Please log in first.', Response::HTTP_UNAUTHORIZED);
}
$teacher = $this->user->find($userId);
$displayName = $teacher ? trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')) : 'Unknown';
// Fetch existing self-reported staff attendance rows for this user (current term)
$existing = $this->staffAttendance
->where('user_id', $userId)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->orderBy('date', 'DESC')
->findAll();
return $this->success([
'teacher_name' => $displayName,
'semester' => $this->semester,
'schoolYear' => $this->schoolYear,
'existing' => $existing,
'availableDates' => $this->allowedAbsenceDates(),
], 'Absence form data retrieved successfully');
}
/**
* POST /api/v1/teachers/absence
* Submit absence/vacation request
*/
public function submitAbsence()
{
$userId = $this->getCurrentUserId();
if (!$userId) {
return $this->respondError('Please log in first.', Response::HTTP_UNAUTHORIZED);
}
if ($this->semester === '' || $this->schoolYear === '') {
return $this->respondError('Semester or school year not configured.', Response::HTTP_BAD_REQUEST);
}
$data = $this->payloadData();
$dates = (array) ($data['dates'] ?? []);
$reasonType = trim((string) ($data['reason_type'] ?? ''));
$reasonText = trim((string) ($data['reason'] ?? ''));
if ($reasonText === '') {
return $this->respondValidationError(['reason' => ['Reason is required.']]);
}
// Build reason string
$reasonBase = ($reasonType !== '') ? strtolower($reasonType) : '';
$reason = $reasonBase !== '' ? ($reasonBase . ': ' . $reasonText) : $reasonText;
// Enforce allowed Sundays list
$allowedDates = $this->allowedAbsenceDates();
$allowedSet = array_fill_keys($allowedDates, true);
$roleName = $this->user->getUserRole($userId) ?: null;
// Validate dates and save
$saved = 0;
$invalid = [];
$savedDates = [];
$dates = array_values(array_unique(array_map('strval', $dates)));
foreach ($dates as $d) {
$d = trim((string) $d);
if ($d === '') continue;
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $d);
if (!$dt || $dt->format('Y-m-d') !== $d || empty($allowedSet[$d])) {
$invalid[] = $d;
continue;
}
$ok = $this->staffAttendance->upsertOne(
userId: $userId,
roleName: $roleName,
date: $d,
semester: $this->semester,
schoolYear: $this->schoolYear,
status: StaffAttendance::STATUS_ABSENT,
reason: $reason,
editorId: $userId
);
if ($ok) {
$saved++;
$savedDates[] = $d;
}
}
if (!empty($invalid)) {
return $this->respondError('Invalid dates: ' . implode(', ', $invalid), Response::HTTP_BAD_REQUEST);
}
// Send notification email to principal
try {
$user = $this->user->find($userId) ?: [];
$fullName = trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')) ?: 'Unknown';
$userEmail = $user['email'] ?? '';
$role = $roleName ?: 'staff';
$dateList = !empty($savedDates) ? implode(', ', $savedDates) : implode(', ', $dates);
// If teacher/TA, include assigned class(es) for current term
$assignedText = '-';
if (in_array(strtolower((string) $roleName), ['teacher', 'teacher_assistant'], true)) {
try {
$assignments = $this->teacherClass->getClassAssignmentsByUserId($userId, $this->schoolYear, $this->semester);
if (!empty($assignments)) {
$parts = [];
foreach ($assignments as $as) {
$name = (string) ($as['class_section_name'] ?? '');
$r = (string) ($as['teacher_role'] ?? '');
if ($name !== '') {
$parts[] = $r ? ($name . ' (' . $r . ')') : $name;
}
}
if (!empty($parts)) {
$assignedText = implode(', ', $parts);
} else {
$assignedText = 'No assigned class';
}
} else {
$assignedText = 'No assigned class';
}
} catch (\Throwable $e) {
$assignedText = 'N/A';
}
}
$subject = sprintf('TimeOff Request: %s (%s) — %s', $fullName, ucfirst((string) $role), $dateList ?: 'No dates');
$submittedAt = utc_now();
$body = '<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">'
. '<h3 style="margin:0 0 8px;">New TimeOff Request</h3>'
. '<p style="margin:0 0 12px;">A staff time-off request was submitted from the teacher portal.</p>'
. '<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;">'
. '<tr><td><strong>Name</strong></td><td>' . esc($fullName) . '</td></tr>'
. '<tr><td><strong>Email</strong></td><td>' . esc($userEmail) . '</td></tr>'
. '<tr><td><strong>Role</strong></td><td>' . esc((string) $role) . '</td></tr>'
. '<tr><td><strong>Semester</strong></td><td>' . esc($this->semester) . '</td></tr>'
. '<tr><td><strong>School Year</strong></td><td>' . esc($this->schoolYear) . '</td></tr>'
. '<tr><td><strong>Reason Type</strong></td><td>' . esc($reasonType ?: '-') . '</td></tr>'
. '<tr><td><strong>Reason</strong></td><td>' . esc($reasonText) . '</td></tr>'
. '<tr><td><strong>Dates</strong></td><td>' . esc($dateList ?: '-') . '</td></tr>'
. (in_array(strtolower((string) $roleName), ['teacher', 'teacher_assistant'], true)
? '<tr><td><strong>Assigned Class(es)</strong></td><td>' . esc($assignedText) . '</td></tr>'
: '')
. '<tr><td><strong>Submitted At</strong></td><td>' . esc($submittedAt) . '</td></tr>'
. '</table>'
. '</div>';
// Note: StaffTimeOffLinkService not found, skipping link generation
$principalEmail = env('PRINCIPAL_EMAIL') ?: 'principal@alrahmaisgl.org';
$this->emailService->send($principalEmail, $subject, $body);
} catch (\Throwable $e) {
Log::error('Failed to send TimeOff email: ' . $e->getMessage());
}
return $this->success([
'saved' => $saved,
'savedDates' => $savedDates,
], $saved . ' day(s) saved as absent.');
}
/**
* Build teacher class assignment payload
*/
private function buildTeacherClassAssignmentPayload(?string $forYear = null): array
{
$schoolYear = $forYear ?: $this->schoolYear;
$teachers = $this->teacher->getTeachersAndTAs();
// Prefer class sections for the selected year, fallback to all if none
$classSections = $this->classSection
->where('school_year', $schoolYear)
->orderBy('class_section_name', 'ASC')
->findAll();
if (empty($classSections)) {
$classSections = $this->classSection->orderBy('class_section_name', 'ASC')->findAll();
}
$classNames = [];
foreach ($classSections as $section) {
$sectionId = (int) ($section['class_section_id'] ?? $section['id'] ?? 0);
if ($sectionId <= 0) continue;
$classNames[$sectionId] = $section['class_section_name'] ?? ($section['name'] ?? 'N/A');
}
$assignments = $this->teacherClass
->where('school_year', $schoolYear)
->findAll();
$assignmentMap = [];
foreach ($assignments as $assign) {
$teacherId = (int) ($assign['teacher_id'] ?? 0);
$sectionId = (int) ($assign['class_section_id'] ?? 0);
if ($teacherId <= 0 || $sectionId <= 0) continue;
$role = strtolower((string) ($assign['position'] ?? ''));
if (!in_array($role, ['main', 'ta'], true)) continue;
if (!isset($assignmentMap[$teacherId])) {
$assignmentMap[$teacherId] = ['main' => [], 'ta' => []];
}
$assignmentMap[$teacherId][$role][] = [
'section_id' => $sectionId,
'class_section_name' => $classNames[$sectionId] ?? 'N/A',
'role' => $role,
'semester' => (string) ($assign['semester'] ?? $this->semester ?? ''),
'school_year' => (string) ($assign['school_year'] ?? $schoolYear),
];
}
$teacherData = [];
foreach ($teachers as $teacher) {
$tid = (int) ($teacher['id'] ?? 0);
if ($tid <= 0) continue;
$assigned = $assignmentMap[$tid] ?? ['main' => [], 'ta' => []];
$firstName = $teacher['firstname'] ?? '';
$lastName = $teacher['lastname'] ?? '';
$name = trim($firstName . ' ' . $lastName);
if ($name === '') {
$name = 'User#' . $tid;
}
$teacherData[] = [
'teacher_id' => $tid,
'firstname' => $firstName,
'lastname' => $lastName,
'name' => $name,
'email' => $teacher['email'] ?? '',
'cellphone' => $teacher['cellphone'] ?? '',
'role' => $teacher['role'] ?? '',
'school_year' => $this->schoolYear,
'main_assignments' => $assigned['main'],
'ta_assignments' => $assigned['ta'],
];
}
$classesPayload = [];
foreach ($classSections as $section) {
$sectionId = (int) ($section['class_section_id'] ?? $section['id'] ?? 0);
if ($sectionId <= 0) continue;
$classesPayload[] = [
'class_section_id' => $sectionId,
'class_section_name' => $section['class_section_name'] ?? ($section['name'] ?? 'N/A'),
];
}
return [
'school_year' => $schoolYear,
'teachers' => $teacherData,
'classes' => $classesPayload,
];
}
/**
* Handle assign class teacher logic
*/
private function handleAssignClassTeacher(array $input): array
{
$teacherId = (int) ($input['teacher_id'] ?? 0);
$classSectionId = (int) ($input['class_section_id'] ?? 0);
$teacherRole = $input['teacher_role'] ?? null;
$loggedInUserId = $this->getCurrentUserId() ?? 0;
$currentDateTime = utc_now();
if ($teacherId <= 0 || $classSectionId <= 0) {
return ['ok' => false, 'status' => 'error', 'message' => 'Missing required parameters for assignment.'];
}
if (empty($this->semester) || empty($this->schoolYear)) {
return ['ok' => false, 'status' => 'error', 'message' => 'Semester or school year not configured.'];
}
if ($teacherRole === null || $teacherRole === '') {
$teacherRole = $this->user->getUserRole($teacherId);
}
$isAssistant = strtolower((string) $teacherRole) === 'teacher_assistant';
$position = $isAssistant ? 'ta' : 'main';
$alreadyAssigned = $this->teacherClass->where([
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'position' => $position,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
])->first();
if ($alreadyAssigned) {
return ['ok' => false, 'status' => 'info', 'message' => 'This teacher is already assigned to this class with the same role.'];
}
$insertData = [
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'position' => $position,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'created_at' => $currentDateTime,
'updated_at' => $currentDateTime,
'updated_by' => $loggedInUserId,
];
try {
$this->teacherClass->insert($insertData);
} catch (\Throwable $e) {
Log::error('assignClassTeacher failed: ' . $e->getMessage());
return ['ok' => false, 'status' => 'error', 'message' => 'Unable to assign class. Please try again.'];
}
return [
'ok' => true,
'status' => 'success',
'message' => 'Class assigned to ' . ($isAssistant ? 'Teacher Assistant' : 'Main Teacher') . ' successfully.',
'position' => $position,
];
}
/**
* Handle delete assignment logic
*/
private function handleDeleteAssignment(array $input): array
{
$teacherId = (int) ($input['teacher_id'] ?? 0);
$classSectionId = (int) ($input['class_section_id'] ?? 0);
$position = strtolower((string) ($input['position'] ?? ''));
if ($teacherId <= 0 || $classSectionId <= 0 || !in_array($position, ['main', 'ta'], true)) {
return ['ok' => false, 'status' => 'error', 'message' => 'Missing or invalid data for deletion.'];
}
if (empty($this->semester) || empty($this->schoolYear)) {
return ['ok' => false, 'status' => 'error', 'message' => 'Semester or school year not configured.'];
}
$requestedSemester = trim((string) ($input['semester'] ?? ''));
$requestedSchoolYear = trim((string) ($input['school_year'] ?? ''));
$semester = $requestedSemester !== '' ? $requestedSemester : $this->semester;
$schoolYear = $requestedSchoolYear !== '' ? $requestedSchoolYear : $this->schoolYear;
if ($semester === '' || $schoolYear === '') {
return ['ok' => false, 'status' => 'error', 'message' => 'Semester or school year not configured.'];
}
$assignment = $this->teacherClass
->where('teacher_id', $teacherId)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('position', $position)
->first();
if (!$assignment) {
return ['ok' => false, 'status' => 'error', 'message' => 'Assignment not found.'];
}
try {
$this->teacherClass->delete($assignment['id']);
} catch (\Throwable $e) {
Log::error('deleteAssignment failed: ' . $e->getMessage());
return ['ok' => false, 'status' => 'error', 'message' => 'Unable to remove assignment. Please try again.'];
}
return [
'ok' => true,
'status' => 'success',
'message' => ucfirst($position) . ' assignment removed successfully.',
];
}
/**
* Compute all allowed absence dates (Sundays) for the configured school year
*/
private function allowedAbsenceDates(): array
{
$todayStr = local_date(utc_now(), 'Y-m-d');
try {
$today = new \DateTimeImmutable($todayStr);
} catch (\Throwable $e) {
$today = new \DateTimeImmutable();
}
$sy = $this->schoolYear;
$startYear = null;
$endYear = null;
if (preg_match('/^(\d{4})\D+(\d{4})$/', $sy, $m)) {
$startYear = (int) $m[1];
$endYear = (int) $m[2];
} else {
// Fallback based on current date
$cy = (int) date('Y');
$cm = (int) date('n');
if ($cm >= 9) { // Sep-Dec
$startYear = $cy;
$endYear = $cy + 1;
} else { // Jan-Aug
$startYear = $cy - 1;
$endYear = $cy;
}
}
try {
$start = new \DateTimeImmutable(sprintf('%04d-09-01', $startYear));
$end = new \DateTimeImmutable(sprintf('%04d-05-31', $endYear));
} catch (\Throwable $e) {
return [];
}
// Clamp start to today if in range
if ($start < $today) {
$start = $today;
}
// Iterate and collect Sundays in range [start..end]
$dates = [];
for ($cursor = $start; $cursor <= $end; $cursor = $cursor->modify('+1 day')) {
if ((int) $cursor->format('w') === 0) { // Sunday
$dates[] = $cursor->format('Y-m-d');
}
}
return $dates;
}
}
@@ -1,27 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use Symfony\Component\HttpFoundation\Response;
class TestDBController extends BaseApiController
{
public function index()
{
try {
$result = DB::select('SELECT 1 AS ok');
if (!empty($result)) {
return $this->success(['message' => 'Database connection is successful.'], 'Database check completed');
}
return $this->respondError('Database connection failed', Response::HTTP_INTERNAL_SERVER_ERROR);
} catch (QueryException $e) {
return $this->respondError(
'Database connection failed: ' . $e->getMessage(),
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
}
}

Some files were not shown because too many files have changed in this diff Show More