attendanceTracking = model(AttendanceTracking::class); $this->attendanceData = model(AttendanceData::class); $this->notification = model(ParentNotification::class); $this->student = model(Student::class); $this->studentClass = model(StudentClass::class); $this->config = model(Configuration::class); $this->classSection = model(ClassSection::class); $this->user = model(User::class); $this->calendar = model(Calendar::class); $this->db = CiDatabase::instance(); $this->semester = (string) ($this->config->getConfig('semester') ?? ''); $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); } public function pendingViolations(): JsonResponse { try { $schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear); $semester = (string) ($this->request->getGet('semester') ?? $this->semester); $classStudents = $this->studentClass ->where('school_year', $schoolYear) ->findAll(); if (empty($classStudents)) { return $this->success([ 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, ], 'No students for the selected school year.'); } $studentIds = array_values(array_unique(array_map('intval', array_column($classStudents, 'student_id')))); if (empty($studentIds)) { return $this->success([ 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, ], 'No student IDs found for the selected school year.'); } $students = $this->student->whereIn('id', $studentIds)->findAll(); $today = new \DateTime('today'); $startDateStr = (clone $today)->modify('-5 weeks')->format('Y-m-d'); $endDateStr = $today->format('Y-m-d'); $start = new \DateTime($startDateStr . ' 00:00:00'); $endEx = (new \DateTime($endDateStr . ' 00:00:00'))->modify('+1 day'); $qb = $this->db->table('attendance_data'); $qb->select('id, student_id, class_id, class_section_id, date, status, reason, school_year, semester, is_reported, is_notified') ->where('school_year', $schoolYear); if (!empty($semester)) { $qb->where('semester', $semester); } $qb->whereIn('student_id', $studentIds) ->groupStart() ->where('is_reported', 'no') ->orWhere('is_reported', 0) ->orWhere('is_reported', '0') ->orWhere('is_reported', null) ->groupEnd() ->groupStart() ->where('is_notified', 'no') ->orWhere('is_notified', 0) ->orWhere('is_notified', '0') ->orWhere('is_notified', null) ->groupEnd() ->groupStart() ->where("UPPER(status) IN ('A','L','ABS','ABSENT','LATE')", null, false) ->orWhere("UPPER(status) LIKE 'ABS%'", null, false) ->orWhere("UPPER(status) LIKE 'LATE%'", null, false) ->groupEnd() ->where('date >=', $start->format('Y-m-d')) ->where('date <', $endEx->format('Y-m-d')) ->orderBy('date', 'DESC'); $termRows = $qb->get()->getResultArray(); if (empty($termRows)) { return $this->success([ 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, ], 'No pending attendance violations.'); } $attendanceRows = []; foreach ($termRows as $row) { $dStr = $row['date'] ?? null; if (!$dStr) { continue; } try { $d = new \DateTime($dStr); } catch (\Throwable $e) { continue; } if ($d < $start || $d >= $endEx) { continue; } $row['status'] = strtolower((string) ($row['status'] ?? '')); $row['date'] = substr($d->format('Y-m-d H:i:s'), 0, 10); $attendanceRows[] = $row; } if (empty($attendanceRows)) { return $this->success([ 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, ], 'No pending attendance violations.'); } $violations = $this->computeViolations($students, $attendanceRows); return $this->success([ 'students' => $violations, 'school_year' => $schoolYear, 'semester' => $semester, ], 'Pending attendance violations retrieved'); } catch (\Throwable $e) { Log::error('pendingViolations error: ' . $e->getMessage()); return $this->respondError('Unable to fetch pending violations', Response::HTTP_INTERNAL_SERVER_ERROR); } } public function notifiedViolations(): JsonResponse { try { $schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear); $semester = (string) ($this->request->getGet('semester') ?? $this->semester); $trackingData = $this->attendanceTracking ->where('school_year', $schoolYear) ->where('semester', $semester) ->where('is_notified', '>', 0) ->orderBy('date', 'DESC') ->findAll(); if (empty($trackingData)) { return $this->success([ 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, ], 'No notified violations.'); } $studentIds = array_unique(array_column($trackingData, 'student_id')); $students = $this->student->whereIn('id', $studentIds)->findAll(); $studentMap = []; foreach ($students as $stu) { $studentMap[$stu['id']] = $stu; } $classByStudent = $this->fetchClassInfoByStudent($studentIds, $schoolYear); $mergedData = []; foreach ($trackingData as $row) { $sid = (int) ($row['student_id'] ?? 0); $merged = $row; if (isset($studentMap[$sid])) { $merged = array_merge($studentMap[$sid], $row); } if (isset($classByStudent[$sid])) { $merged['class_section_name'] = (string) ($classByStudent[$sid]['class_section_name'] ?? ''); $merged['class_name'] = (string) ($classByStudent[$sid]['class_name'] ?? ''); } $parent = $this->getPrimaryParentForStudent($sid); if ($parent) { $merged['parent_name'] = $parent['parent_name'] ?? ''; $merged['parent_email'] = $parent['email'] ?? ''; $merged['parent_phone'] = $parent['phone'] ?? ''; } $mergedData[] = $merged; } return $this->success([ 'students' => $mergedData, 'school_year' => $schoolYear, 'semester' => $semester, ], 'Notified attendance violations retrieved'); } catch (\Throwable $e) { Log::error('notifiedViolations error: ' . $e->getMessage()); return $this->respondError('Unable to fetch notified violations', Response::HTTP_INTERNAL_SERVER_ERROR); } } public function recordNotification(): JsonResponse { $rules = [ 'student_id' => 'required|integer', 'date' => 'required|valid_date[Y-m-d]', 'parent_email' => 'permit_empty|valid_email', 'parent_name' => 'permit_empty|string', 'subject_type' => 'permit_empty|string', 'semester' => 'permit_empty|string', 'school_year' => 'permit_empty|string', ]; $payload = $this->payloadData(); $errors = $this->validateRequest($payload, $rules); if (!empty($errors)) { return $this->respondValidationError($errors); } try { $studentId = (int) $payload['student_id']; $ymd = substr((string) $payload['date'], 0, 10); $semester = $payload['semester'] ?? $this->semester; $schoolYear= $payload['school_year'] ?? $this->schoolYear; $subject = $payload['subject_type'] ?? 'Absent'; $student = $this->student->find($studentId) ?? []; $parentEmail = $payload['parent_email'] ?? ''; $parentName = $payload['parent_name'] ?? ''; if ($parentEmail === '') { $primary = $this->getPrimaryParentForStudent($studentId) ?? []; $parentEmail = $primary['email'] ?? ''; $parentName = $parentName ?: ($primary['parent_name'] ?? ''); } if ($parentEmail === '') { return $this->respondError('No parent email found for this student.', Response::HTTP_BAD_REQUEST); } $tracking = $this->attendanceTracking; [$start, $end] = $this->dayBounds($ymd); $row = $tracking->where('student_id', $studentId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->where('date', '>=', $start) ->where('date', '<', $end) ->orderBy('date', 'DESC') ->first(); $consequence = $this->determineConsequence($row['reason'] ?? ''); $class = $this->studentClass->getClassSectionsByStudentId($studentId, $schoolYear); $payloadEvent = [ 'student_id' => $studentId, 'student' => $student, 'parent' => ['email' => $parentEmail, 'name' => $parentName], 'semester' => $semester, 'school_year' => $schoolYear, 'class' => $class, 'now' => utc_now(), 'date' => $ymd, 'subject' => $subject, ]; $eventName = match ($consequence) { 'dismissal' => 'attendance.dismissal', 'final_warning' => 'attendance.final_warning', default => 'attendance.follow_up', }; Event::dispatch($eventName, [$payloadEvent]); if ($row && !empty($row['id'])) { $tracking->markAsNotified((int) $row['id']); } $this->attendanceData->markRuleAsNotifiedData($studentId, $ymd, $semester, $schoolYear); return $this->success(['message' => 'Notification queued.']); } catch (\Throwable $e) { Log::error('recordNotification error: ' . $e->getMessage()); return $this->respondError('Failed to queue notification.', Response::HTTP_INTERNAL_SERVER_ERROR); } } public function sendAutoEmails(): JsonResponse { if (!$this->request->is('post')) { return $this->respondError('Method not allowed', Response::HTTP_METHOD_NOT_ALLOWED); } try { $classStudents = $this->studentClass ->where('school_year', $this->schoolYear) ->findAll(); if (empty($classStudents)) { return $this->success([ 'sent' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [], ], 'No students in current school year.'); } $studentIds = array_column($classStudents, 'student_id'); $students = $this->student->whereIn('id', $studentIds)->findAll(); $attendanceData = $this->attendanceData ->whereIn('student_id', $studentIds) ->where('school_year', $this->schoolYear) ->where('semester', $this->semester) ->groupStart() ->where('is_reported', 'no') ->orWhere('is_reported', 0) ->orWhere('is_reported', '0') ->orWhere('is_reported', null) ->groupEnd() ->where("LOWER(status) IN ('absent','late')", null, false) ->orderBy('date', 'DESC') ->findAll(); $violations = $this->computeViolations($students, $attendanceData); $toSend = array_values(array_filter($violations, fn($v) => ($v['action'] ?? '') === 'auto_email')); $summary = $this->processEmailQueue($toSend, 'default'); return $this->success($summary, 'Auto emails processed'); } catch (\Throwable $e) { Log::error('sendAutoEmails error: ' . $e->getMessage()); return $this->respondError('Failed to send auto emails', Response::HTTP_INTERNAL_SERVER_ERROR); } } public function composeEmail(): JsonResponse { try { $studentId = (int) ($this->request->get('student_id') ?? 0); $code = (string) ($this->request->get('code') ?? 'ABS_1'); $variant = (string) ($this->request->get('variant') ?? 'default'); $incident = (string) ($this->request->get('incident_date') ?? ''); if ($studentId <= 0) { return $this->respondError('Missing student_id', Response::HTTP_BAD_REQUEST); } $student = $this->student->find($studentId) ?? []; $parent = $this->getPrimaryParentForStudent($studentId); $secondary = $this->getSecondaryParentForStudent($studentId); $lastDate = preg_match('/^\d{4}-\d{2}-\d{2}$/', $incident) ? $incident : local_date(utc_now(), 'Y-m-d'); $absDates = str_starts_with($code, 'ABS_') ? $this->getRecentAbsenceDatesForStudent($studentId, $lastDate, 5) : []; $violation = [ 'id' => $studentId, 'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), 'last_date' => $lastDate, 'violation' => $code, 'violation_code' => $code, 'absences' => $absDates, ]; $ctx = $this->buildTemplateContext($violation, $parent); [$subject, $body] = $this->renderTemplate($code, $variant, $ctx) ?? $this->renderFallbackTemplate($code, $ctx, $violation['name'], $lastDate, $parent['parent_name'] ?? 'Parent/Guardian'); return $this->success([ 'student_id' => $studentId, 'student_name' => $violation['name'], 'primary_parent' => $parent, 'secondary_parent'=> $secondary, 'code' => $code, 'variant' => $variant, 'incident_date' => $lastDate, 'subject' => $subject, 'body_html' => $body, ], 'Email template composed'); } catch (\Throwable $e) { Log::error('composeEmail error: ' . $e->getMessage()); return $this->respondError('Failed to compose email', Response::HTTP_INTERNAL_SERVER_ERROR); } } public function sendEmailManual(): JsonResponse { if (!$this->request->is('post')) { return $this->respondError('Method not allowed', Response::HTTP_METHOD_NOT_ALLOWED); } $rules = [ 'student_id' => 'required|integer', 'to' => 'required|valid_email', 'subject' => 'required|string', 'body_html' => 'required|string', 'code' => 'required|string', 'variant' => 'permit_empty|string', 'incident_date' => 'permit_empty|valid_date[Y-m-d]', ]; $payload = $this->payloadData(); $errors = $this->validateRequest($payload, $rules); if (!empty($errors)) { return $this->respondValidationError($errors); } try { $studentId = (int) $payload['student_id']; $to = trim((string) $payload['to']); $subject = (string) $payload['subject']; $plainBody = (string) $payload['body_html']; $code = (string) $payload['code']; $variant = (string) ($payload['variant'] ?? 'default'); $ymd = isset($payload['incident_date']) ? substr((string) $payload['incident_date'], 0, 10) : ''; $safeBody = $this->normalizeBodyHtml($plainBody); $wrapped = view('emails/_wrap_layout', [ 'title' => $subject, 'body_html' => $safeBody, ], ['saveData' => false]); $mailer = new EmailController(); $anyOk = false; $ok = $mailer->sendEmail($to, $subject, $wrapped); $this->logNotification($studentId, $code, $ymd, 'email', $to, $subject, $ok ? 'sent' : 'failed', $ok ? 'OK' : 'sendEmail returned false'); if ($ok) { $anyOk = true; } $secondary = $this->getSecondaryParentForStudent($studentId); $to2 = trim((string) ($secondary['email'] ?? '')); if ($to2 !== '' && strcasecmp($to2, $to) !== 0 && !$this->notificationAlreadySent($studentId, $code, $ymd, 'email', $to2)) { $ok2 = $mailer->sendEmail($to2, $subject, $wrapped); $this->logNotification($studentId, $code, $ymd, 'email', $to2, $subject, $ok2 ? 'sent' : 'failed', $ok2 ? 'OK' : 'sendEmail returned false'); if ($ok2) { $anyOk = true; } } if (!$anyOk) { return $this->respondError('Failed to send email', Response::HTTP_INTERNAL_SERVER_ERROR); } $incidentDate = $ymd !== '' ? $ymd : local_date(utc_now(), 'Y-m-d'); $this->attendanceTracking->markRuleAsNotified($studentId, $code, $incidentDate, $this->semester, $this->schoolYear); if ($ymd !== '') { $this->attendanceData->markRuleAsNotifiedData($studentId, $ymd, $this->semester, $this->schoolYear); } return $this->success(['message' => 'Email sent']); } catch (\Throwable $e) { Log::error('sendEmailManual error: ' . $e->getMessage()); return $this->respondError('Failed to send email', Response::HTTP_INTERNAL_SERVER_ERROR); } } public function parentsInfo(): JsonResponse { $studentId = (int) ($this->request->getGet('student_id') ?? 0); if ($studentId <= 0) { return $this->respondError('Missing student_id', Response::HTTP_BAD_REQUEST); } try { $primary = $this->getPrimaryParentForStudent($studentId); $secondary = $this->getSecondaryParentForStudent($studentId); return $this->success([ 'primary' => $primary, 'secondary' => $secondary, ]); } catch (\Throwable $e) { Log::error('parentsInfo error: ' . $e->getMessage()); return $this->respondError('Unable to fetch parents info', Response::HTTP_INTERNAL_SERVER_ERROR); } } public function saveNotificationNote(): JsonResponse { if (!$this->request->is('post')) { return $this->respondError('Method not allowed', Response::HTTP_METHOD_NOT_ALLOWED); } $rules = [ 'student_id' => 'required|integer', 'code' => 'required|string', 'note' => 'required|string', 'incident_date' => 'permit_empty|valid_date[Y-m-d]', ]; $payload = $this->payloadData(); $errors = $this->validateRequest($payload, $rules); if (!empty($errors)) { return $this->respondValidationError($errors); } try { $studentId = (int) $payload['student_id']; $code = (string) $payload['code']; $note = trim((string) $payload['note']); $ymd = isset($payload['incident_date']) ? substr((string) $payload['incident_date'], 0, 10) : ''; $day = $ymd !== '' ? $ymd : local_date(utc_now(), 'Y-m-d'); [$start, $end] = $this->dayBounds($day); $row = $this->attendanceTracking ->where('student_id', $studentId) ->where('semester', $this->semester) ->where('school_year', $this->schoolYear) ->where('date', '>=', $start) ->where('date', '<', $end) ->where('reason', 'like', "%{$code}%") ->orderBy('date', 'DESC') ->first(); if ($row && !empty($row['id'])) { $this->attendanceTracking->update((int) $row['id'], [ 'note' => $note, 'updated_at' => utc_now(), ]); } else { $this->attendanceTracking->insert([ 'student_id' => $studentId, 'date' => $start, 'is_reported' => 0, 'reason' => $code, 'is_notified' => 0, 'notif_counter' => 0, 'semester' => $this->semester, 'school_year' => $this->schoolYear, 'note' => $note, 'created_at' => utc_now(), 'updated_at' => utc_now(), ]); } return $this->success(['message' => 'Note saved']); } catch (\Throwable $e) { Log::error('saveNotificationNote error: ' . $e->getMessage()); return $this->respondError('Failed to save note', Response::HTTP_INTERNAL_SERVER_ERROR); } } // ----------------- Helper methods copied/adapted from legacy controller ----------------- private function notificationAlreadySent(int $studentId, string $code, string $ymd, string $channel = 'email', ?string $to = null): bool { try { return $this->notification->hasSent($studentId, $code, $ymd, $channel, $to); } catch (\Throwable $e) { Log::debug('notificationAlreadySent(): ' . $e->getMessage()); return false; } } private function logNotification(int $studentId, string $code, string $ymd, string $channel, ?string $to, string $subject, string $status, string $response = ''): void { try { $where = [ 'student_id' => $studentId, 'code' => $code, 'incident_date' => $ymd, 'channel' => $channel, ]; if (!empty($to)) { $where['to_address'] = $to; } $existing = $this->notification->where($where)->orderBy('id', 'DESC')->first(); if ($existing && !empty($existing['id'])) { $this->notification->update((int) $existing['id'], [ 'subject' => $subject, 'status' => $status, 'response' => $response, 'semester' => $this->semester, 'school_year' => $this->schoolYear, 'updated_at' => utc_now(), ]); } else { $this->notification->insert([ 'student_id' => $studentId, 'code' => $code, 'incident_date' => $ymd, 'channel' => $channel, 'to_address' => $to, 'subject' => $subject, 'status' => $status, 'response' => $response, 'semester' => $this->semester, 'school_year' => $this->schoolYear, ]); } } catch (\Throwable $e) { Log::debug('logNotification(): ' . $e->getMessage()); } } private function determineConsequence(?string $reason): ?string { if (!$reason) { return null; } $reason = strtolower($reason); return match (true) { str_contains($reason, 'abs_4') || str_contains($reason, 'dismissal') => 'dismissal', str_contains($reason, 'abs_3') || str_contains($reason, 'final_warning') => 'final_warning', str_contains($reason, 'abs_2') || str_contains($reason, 'follow_up') => 'follow_up', default => null, }; } private function processEmailQueue(array $violations, string $variant): array { $sent = 0; $skipped = 0; $errors = 0; $details = []; $mailer = new EmailController(); foreach ($violations as $v) { $studentId = (int) ($v['id'] ?? 0); $code = (string) ($v['violation_code'] ?? $v['code'] ?? ''); $date = (string) ($v['last_date'] ?? ''); $to = trim((string) ($v['parent_email'] ?? '')); if ($studentId <= 0 || !$code || !$date || !$to) { $errors++; $details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'missing data']; continue; } if ($this->notificationAlreadySent($studentId, $code, $date, 'email', $to)) { $skipped++; $details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'duplicate']; continue; } $parent = $this->getPrimaryParentForStudent($studentId); $context = $this->buildTemplateContext($v, $parent); $tpl = $this->renderTemplate($code, $variant, $context); if (!$tpl) { [$subject, $body] = $this->renderFallbackTemplate($code, $context, $v['name'] ?? '', $date, $parent['parent_name'] ?? 'Parent/Guardian'); } else { [$subject, $body] = $tpl; } $wrapped = view('emails/_wrap_layout', [ 'title' => $subject, 'body_html' => $body, ], ['saveData' => false]); try { if ($mailer->sendEmail($to, $subject, $wrapped)) { $sent++; $this->logNotification($studentId, $code, $date, 'email', $to, $subject, 'sent', 'OK'); $details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'sent']; $this->attendanceTracking->markRuleAsNotified($studentId, $code, $date, $this->semester, $this->schoolYear); $this->attendanceData->markRuleAsNotifiedData($studentId, $date, $this->semester, $this->schoolYear); } else { $errors++; $this->logNotification($studentId, $code, $date, 'email', $to, $subject, 'failed', 'sendEmail returned false'); $details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'failed']; } } catch (\Throwable $e) { $errors++; $this->logNotification($studentId, $code, $date, 'email', $to, $subject, 'failed', $e->getMessage()); $details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'error: ' . $e->getMessage()]; } } return compact('sent', 'skipped', 'errors', 'details'); } private function renderFallbackTemplate(string $code, array $context, string $studentName, string $incident, string $parentName): array { $subject = match ($code) { 'ABS_4' => 'Attendance Notice: Excessive Absences', 'ABS_3' => 'Attendance Notice: Continued Absences', 'ABS_2' => 'Attendance Notice: Repeated Absences', 'ABS_1' => 'Attendance Notice: Unreported Absence', 'LATE_4'=> 'Attendance Notice: Excessive Lateness', 'LATE_3'=> 'Attendance Notice: Continued Lateness', 'LATE_2'=> 'Attendance Notice: Repeated Lateness', default => 'Attendance Notice', }; $datesList = $context['{{absence_dates_list_bold}}'] ?? ''; $body = '

Dear ' . esc($parentName) . ',

' . '

We would like to inform you about a recent attendance concern for your student.

' . '' . ($datesList !== '' ? ('

Absence dates:

' . $datesList) : '') . '

Please contact the school office if you have any questions.

'; return [$subject, $body]; } private function normalizeBodyHtml(string $body): string { if ($body === '') { return '

'; } if (preg_match('/<[^>]+>/', $body)) { return $body; } $decoded = html_entity_decode($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); if (preg_match('/<[^>]+>/', $decoded)) { return $decoded; } return nl2br(esc($body), false); } private function fetchClassInfoByStudent(array $studentIds, string $schoolYear): array { if (empty($studentIds)) { return []; } $rows = $this->db->table('student_class sc') ->select('sc.student_id, sc.class_section_id, cs.class_section_name, cs.class_id, c.class_name') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') ->join('classes c', 'c.id = cs.class_id', 'left') ->whereIn('sc.student_id', $studentIds) ->where('sc.school_year', $schoolYear) ->orderBy('sc.id', 'DESC') ->get()->getResultArray(); $map = []; foreach ($rows as $row) { $sid = (int) ($row['student_id'] ?? 0); if ($sid > 0 && !isset($map[$sid])) { $map[$sid] = $row; } } return $map; } private function dayBounds(string $ymd): array { $d = substr($ymd, 0, 10); return [ $d . ' 00:00:00', date('Y-m-d', strtotime($d . ' +1 day')) . ' 00:00:00', ]; } // The remainder of this class consists of helper methods copied (with minor adjustments) // from the legacy AttendanceTrackingController to compute violations and build contexts. // --- BEGIN legacy helpers --- private function computeViolations(array $students, array $attendanceData): array { $byStudent = []; foreach ($attendanceData as $r) { $sid = (int) $r['student_id']; $ymd = substr((string) ($r['date'] ?? ''), 0, 10); $stat = strtolower((string) $r['status']); if (!in_array($stat, ['absent', 'late'], true)) { continue; } $byStudent[$sid][$stat][] = $ymd; } [$syStart, $syEnd] = $this->deriveSchoolYearBounds($this->schoolYear); $activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $syEnd); if (empty($activeWeekKeys)) { return []; } $weekIndex = array_flip($activeWeekKeys); $currentWeekIdx = count($activeWeekKeys) - 1; $windowStartIdx = max(0, $currentWeekIdx - 3); $requiredWeeksCount = $currentWeekIdx - $windowStartIdx + 1; $keepLastWeeks = function (array $dates) use ($weekIndex, $windowStartIdx, $currentWeekIdx) { $out = []; foreach ($dates as $d) { $key = date('o-\WW', strtotime($d)); if (isset($weekIndex[$key])) { $idx = $weekIndex[$key]; if ($idx >= $windowStartIdx && $idx <= $currentWeekIdx) { $out[] = substr((string) $d, 0, 10); } } } $out = array_values(array_unique($out)); rsort($out); return $out; }; $classByStudent = $this->fetchClassInfoByStudent(array_column($students, 'id'), $this->schoolYear); $out = []; foreach ($students as $stu) { $sid = (int) ($stu['id'] ?? 0); $name = trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? '')); $absDatesAll = array_values(array_unique($byStudent[$sid]['absent'] ?? [])); $lateDatesAll = array_values(array_unique($byStudent[$sid]['late'] ?? [])); $absDates = $keepLastWeeks($absDatesAll); $lateDates = $keepLastWeeks($lateDatesAll); if (empty($absDates) && empty($lateDates)) { continue; } $absWeekIdx = $this->datesToWeekIndices($absDates, $weekIndex); $lateWeekIdx = $this->datesToWeekIndices($lateDates, $weekIndex); $absenceViolation = $this->buildAbsenceViolation($absDates, $absWeekIdx, $currentWeekIdx); $lateViolation = $this->buildLateViolation($lateDates, $lateWeekIdx, $absWeekIdx, $currentWeekIdx, $requiredWeeksCount); $chosen = $this->chooseHigherSeverity($absenceViolation, $lateViolation); if (!$chosen && count($absDates) === 1) { $chosen = [ 'type' => 'absence', 'code' => 'ABS_1', 'severity' => 1, 'action' => 'auto_email', 'color' => '#e6f7ff', 'title' => '1 Unreported Absence', 'description' => 'Send automated email to parent.', 'last_date' => $absDates[0], ]; } if (!$chosen) { continue; } $parent = $this->getPrimaryParentForStudent($sid); if ($this->alreadyReported($sid, $chosen['code'], $chosen['last_date'])) { continue; } if ($this->notificationAlreadySent($sid, $chosen['code'], $chosen['last_date'], 'email', $parent['email'] ?? null)) { continue; } $cls = $classByStudent[$sid] ?? []; $classLabel = (string) ($cls['class_section_name'] ?? ''); if ($classLabel === '' && !empty($cls['class_name'])) { $classLabel = (string) $cls['class_name']; } $out[] = [ 'id' => $sid, 'name' => $name, 'class_name' => $classLabel, 'class_section_name' => (string) ($cls['class_section_name'] ?? ''), 'class_id' => $cls['class_id'] ?? null, 'class_section_id' => $cls['class_section_id'] ?? null, 'parent_email' => $parent['email'] ?? '', 'parent_name' => $parent['parent_name'] ?? '', 'parent_phone' => $parent['phone'] ?? null, 'violation' => $chosen['title'], 'violation_code' => $chosen['code'], 'type' => $chosen['type'], 'severity' => $chosen['severity'], 'action' => $chosen['action'], 'color' => $chosen['color'], 'last_absence' => $absDates[0] ?? null, 'last_late' => $lateDates[0] ?? null, 'last_date' => $chosen['last_date'], 'absences' => $absDates, 'lates' => $lateDates, ]; } return $out; } private function buildAbsenceViolation(array $absDates, array $absWeekIdx, int $currentWeekIdx): ?array { if (empty($absDates)) { return null; } $lastAbsDate = $absDates[0]; $A2row = $this->hasNConsecutiveItems($absDates, 2, 7); $A3row = $this->hasNConsecutiveItems($absDates, 3, 7); $A4row = $this->hasNConsecutiveItems($absDates, 4, 7); $A_in3w2 = $this->hasNInWActiveWeeks($absWeekIdx, 2, 3); $A_in4w3 = $this->hasNInWActiveWeeks($absWeekIdx, 3, 4); $A_in4w4 = $this->hasNInWActiveWeeks($absWeekIdx, 4, 4); if ($A4row || $A_in4w4) { return [ 'type' => 'absence', 'code' => 'ABS_4', 'severity' => 4, 'action' => 'expel_notify', 'color' => '#ff4d4f', 'title' => '4 Absences (in a row or within last 4 active weeks)', 'description' => 'Notify team to inform parent about expulsion and send email.', 'last_date' => $lastAbsDate, ]; } if ($A3row || $A_in4w3) { return [ 'type' => 'absence', 'code' => 'ABS_3', 'severity' => 3, 'action' => 'team_notify', 'color' => '#fa8c16', 'title' => '3 Absences (in a row or within last 4 active weeks)', 'description' => 'Team calls parent and sends email.', 'last_date' => $lastAbsDate, ]; } if ($A2row || $A_in3w2) { return [ 'type' => 'absence', 'code' => 'ABS_2', 'severity' => 2, 'action' => 'team_notify', 'color' => '#fadb14', 'title' => '2 Absences (in a row or within last 3 active weeks)', 'description' => 'Team calls parent and sends email.', 'last_date' => $lastAbsDate, ]; } return null; } private function buildLateViolation(array $lateDates, array $lateWeekIdx, array $absWeekIdx, int $currentWeekIdx, int $requiredWeeksCount): ?array { if (empty($lateDates)) { return null; } $lastLateDate = $lateDates[0]; $L2row = $this->hasNConsecutiveItems($lateDates, 2, 7); $L3row = $this->hasNConsecutiveItems($lateDates, 3, 7); $L4row = $this->hasNConsecutiveItems($lateDates, 4, 7); $L_in3w2 = $this->hasNInWActiveWeeks($lateWeekIdx, 2, 3); $L_in4w3 = $this->hasNInWActiveWeeks($lateWeekIdx, 3, 4); $lateWeeksSet = array_flip($lateWeekIdx); $lateCoversAllWindowWeeks = false; if ($requiredWeeksCount >= 4) { $lateCoversAllWindowWeeks = true; for ($i = $currentWeekIdx - 3; $i <= $currentWeekIdx; $i++) { if ($i < 0) { continue; } if (!isset($lateWeeksSet[$i])) { $lateCoversAllWindowWeeks = false; break; } } } if ($L4row || $lateCoversAllWindowWeeks) { return [ 'type' => 'late', 'code' => 'LATE_4', 'severity' => 4, 'action' => 'team_notify', 'color' => '#ff4d4f', 'title' => '4 Lates in a row OR in each of the last 4 active weeks', 'description' => 'Team calls parent and sends email.', 'last_date' => $lastLateDate, ]; } $twoLatesOneAbsWithin4 = $this->twoLatesOneAbsInWWeeks($lateWeekIdx, $absWeekIdx, 4); if ($L3row || $L_in4w3 || $twoLatesOneAbsWithin4) { return [ 'type' => $twoLatesOneAbsWithin4 ? 'mix' : 'late', 'code' => $twoLatesOneAbsWithin4 ? 'MIX_L2A1' : 'LATE_3', 'severity' => 3, 'action' => 'team_notify', 'color' => '#002766', 'title' => $twoLatesOneAbsWithin4 ? '2 Lates + 1 Absence (within last 4 active weeks)' : '3 Lates (in a row or within last 4 active weeks)', 'description' => 'Team calls parent and sends email.', 'last_date' => $lastLateDate, ]; } if ($L2row || $L_in3w2) { return [ 'type' => 'late', 'code' => 'LATE_2', 'severity' => 1, 'action' => 'auto_email', 'color' => '#bae7ff', 'title' => '2 Lates (in a row or within last 3 active weeks)', 'description' => 'Send automated email to parent.', 'last_date' => $lastLateDate, ]; } return null; } private function deriveSchoolYearBounds(string $schoolYear): array { if (!preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m)) { $y = (int) local_now('Y'); $startY = (int) (local_now('n') >= 8 ? $y : $y - 1); return [sprintf('%d-08-01', $startY), sprintf('%d-07-31', $startY + 1)]; } $startY = (int) $m[1]; $endY = (int) $m[2]; return [sprintf('%d-08-01', $startY), sprintf('%d-07-31', $endY)]; } private function getPrimaryParentForStudent(int $studentId): ?array { $row = $this->db->table('students s') ->select('u.id AS user_id, u.email, u.cellphone, CONCAT(u.firstname, " ", u.lastname) AS parent_name', false) ->join('users u', 'u.id = s.parent_id', 'left') ->where('s.id', $studentId) ->get()->getRowArray(); if (!$row || empty($row['user_id'])) { return null; } return [ 'user_id' => (int) $row['user_id'], 'email' => (string) ($row['email'] ?? ''), 'parent_name' => (string) ($row['parent_name'] ?? ''), 'phone' => $row['cellphone'] ?? null, ]; } private function getSecondaryParentForStudent(int $studentId): ?array { try { $stu = $this->db->table('students') ->select('id, parent_id, school_year, semester') ->where('id', $studentId) ->get()->getRowArray(); if (!$stu) { return null; } $primaryId = (int) ($stu['parent_id'] ?? 0); $schoolYear = (string) ($stu['school_year'] ?? ''); $pb = $this->db->table('parents')->where('firstparent_id', $primaryId); if ($schoolYear !== '') { $pb->where('school_year', $schoolYear); } $pRow = $pb->orderBy('updated_at', 'DESC')->limit(1)->get()->getRowArray(); if (!$pRow) { return null; } $secondId = (int) ($pRow['secondparent_id'] ?? 0); if ($secondId > 0) { $u2 = $this->db->table('users') ->select('id, firstname, lastname, email, cellphone') ->where('id', $secondId) ->get()->getRowArray(); if ($u2 && !empty($u2['email'])) { return [ 'user_id' => (int) $u2['id'], 'email' => (string) $u2['email'], 'parent_name' => trim(($u2['firstname'] ?? '') . ' ' . ($u2['lastname'] ?? '')), 'phone' => (string) ($u2['cellphone'] ?? ''), ]; } } $fallbackEmail = (string) ($pRow['secondparent_email'] ?? ''); $fallbackPhone = (string) ($pRow['secondparent_phone'] ?? ''); $fallbackFirst = (string) ($pRow['secondparent_firstname'] ?? ''); $fallbackLast = (string) ($pRow['secondparent_lastname'] ?? ''); if ($fallbackEmail || $fallbackPhone || $fallbackFirst || $fallbackLast) { return [ 'user_id' => $secondId ?: null, 'email' => $fallbackEmail, 'parent_name' => trim($fallbackFirst . ' ' . $fallbackLast) ?: null, 'phone' => $fallbackPhone, ]; } } catch (\Throwable $e) { Log::error('getSecondaryParentForStudent() failed: ' . $e->getMessage()); } return null; } private function alreadyReported(int $studentId, string $code, string $lastDate): bool { try { [$start, $end] = $this->dayBounds($lastDate); $row = $this->attendanceTracking ->where('student_id', $studentId) ->where('semester', $this->semester) ->where('school_year', $this->schoolYear) ->where('date', '>=', $start) ->where('date', '<', $end) ->where('reason', 'like', "%{$code}%") ->orderBy('date', 'DESC') ->first(); if (!$row) { $row = $this->attendanceTracking ->where('student_id', $studentId) ->where('semester', $this->semester) ->where('school_year', $this->schoolYear) ->where('date', '>=', $start) ->where('date', '<', $end) ->orderBy('date', 'DESC') ->first(); } return $row ? (bool) ($row['is_notified'] ?? 0) : false; } catch (\Throwable $e) { Log::debug('alreadyReported() error: ' . $e->getMessage()); return false; } } private function getRecentAbsenceDatesForStudent(int $studentId, string $lastDateYmd, int $weeks = 5): array { if ($studentId <= 0) { return []; } $day = substr($lastDateYmd, 0, 10); $start = date('Y-m-d', strtotime($day . " -{$weeks} weeks")) . ' 00:00:00'; $end = date('Y-m-d', strtotime($day . ' +1 day')) . ' 00:00:00'; $qb = $this->db->table('attendance_data'); $qb->select('date') ->where('student_id', $studentId) ->where('school_year', $this->schoolYear) ->where('semester', $this->semester) ->groupStart() ->where('is_reported', 'no') ->orWhere('is_reported', 0) ->orWhere('is_reported', '0') ->orWhere('is_reported', null) ->groupEnd() ->groupStart() ->where('is_notified', 'no') ->orWhere('is_notified', 0) ->orWhere('is_notified', '0') ->orWhere('is_notified', null) ->groupEnd() ->groupStart() ->where("UPPER(status) IN ('A','ABS','ABSENT')", null, false) ->orWhere("UPPER(status) LIKE 'ABS%'", null, false) ->groupEnd() ->where('date >=', $start) ->where('date <', $end) ->orderBy('date', 'DESC'); $rows = $qb->get()->getResultArray(); $ymds = []; foreach ($rows as $r) { $d = substr((string) ($r['date'] ?? ''), 0, 10); if ($d !== '') { $ymds[$d] = true; } } $out = array_keys($ymds); sort($out); return $out; } private function renderTemplate(string $code, string $variant, array $context): ?array { if (!class_exists(\App\Models\AttendanceEmailTemplate::class)) { return null; } $template = model(\App\Models\AttendanceEmailTemplate::class); $row = $template->getTemplate($code, $variant); if (!$row) { $row = $template->getTemplate($code, 'default'); } if (!$row) { return null; } $subject = strtr($row['subject'], $context); $body = strtr($row['body_html'], $context); return [$subject, $body]; } private function buildTemplateContext(array $v, ?array $parent = null): array { $studentName = (string) ($v['name'] ?? ''); $incident = (string) ($v['last_date'] ?? local_date(utc_now(), 'Y-m-d')); $p = $parent ?: $this->getPrimaryParentForStudent((int) ($v['id'] ?? 0)) ?? []; $absences = []; if (!empty($v['absences']) && is_array($v['absences'])) { $absences = array_values(array_unique(array_map(static fn($d) => substr((string) $d, 0, 10), $v['absences']))); } $absencesAsc = $absences; sort($absencesAsc); $absenceCount = count($absencesAsc); $absenceDatesCsv = $absenceCount ? implode(', ', $absencesAsc) : ''; $absenceDatesLines = $absenceCount ? implode('
', $absencesAsc) : ''; $absenceDatesList = $absenceCount ? ('') : ''; return [ '{{parent_name}}' => (string) ($p['parent_name'] ?? 'Parent/Guardian'), '{{student_name}}' => $studentName, '{{student_name_bold}}' => '' . esc($studentName) . '', '{{incident_date}}' => $incident, '{{school_phone}}' => '978-364-0219', '{{voicemail_phone}}' => (string) ($p['phone'] ?? '—'), '{{class_time}}' => '10:00 AM', '{{absence_count}}' => (string) $absenceCount, '{{absence_dates}}' => $absenceDatesCsv, '{{absence_dates_lines}}' => $absenceDatesLines, '{{absence_dates_list}}' => $absenceDatesList, '{{absence_dates_bold}}' => $absenceCount ? implode(', ', array_map(static fn($d) => '' . esc($d) . '', $absencesAsc)) : '', '{{absence_dates_lines_bold}}' => $absenceCount ? implode('
', array_map(static fn($d) => '' . esc($d) . '', $absencesAsc)) : '', '{{absence_dates_list_bold}}' => $absenceCount ? ('') : '', ]; } // --- END legacy helpers --- }