studentModel = new StudentModel(); $this->studentClassModel = new StudentClassModel(); $this->attendanceDataModel = new AttendanceDataModel(); $this->attendanceTrackingModel = new AttendanceTrackingModel(); $this->configModel = new ConfigurationModel(); $this->email = \Config\Services::email(); $this->db = \Config\Database::connect(); $this->notificationModel = new ParentNotificationModel(); $this->attendanceEmailTemplateModel = new AttendanceEmailTemplateModel(); $this->semester = (string) $this->configModel->getConfig('semester'); $this->schoolYear = (string) $this->configModel->getConfig('school_year'); } public function pendingViolationsView() { $syParam = $this->request->getGet('school_year'); $semParam = $this->request->getGet('semester'); $schoolYear = (is_string($syParam) && $syParam !== '') ? (string)$syParam : (string)$this->schoolYear; $semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : null; // semester filter disabled $debugInfo = [ 'school_year_param' => $schoolYear, 'semester_param' => $semester, 'class_students' => 0, 'student_ids' => 0, 'attendance_rows' => 0, 'filtered_rows' => 0, 'violations' => 0, 'active_weeks' => 0, 'abs_last5' => 0, 'late_last5' => 0, ]; $classStudents = $this->studentClassModel ->active() ->where('student_class.school_year', $schoolYear) ->findAll(); $debugInfo['class_students'] = count($classStudents); // Fallback: if the configured school_year has no class assignments, look up the latest term if (empty($classStudents)) { $latestTerm = $this->db->table('student_class') ->select('school_year, semester') ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray(); if ($latestTerm && !empty($latestTerm['school_year'])) { $schoolYear = (string)$latestTerm['school_year']; if (!empty($latestTerm['semester'])) { $semester = (string)$latestTerm['semester']; } $classStudents = $this->studentClassModel ->active() ->where('student_class.school_year', $schoolYear) ->findAll(); } } // Gather candidate student identifiers (numeric IDs and code-like IDs) $studentIds = []; $studentCodes = []; foreach ($classStudents as $row) { $raw = $row['student_id'] ?? null; if (is_numeric($raw)) { $studentIds[] = (int)$raw; } elseif (is_string($raw) && $raw !== '') { $studentCodes[] = trim($raw); } } // Fallback #2: if still empty, derive student IDs directly from attendance_data (school year optional) if (empty($studentIds) && empty($studentCodes)) { $builder = $this->db->table('attendance_data') ->select('DISTINCT student_id', false) ->orderBy('student_id', 'ASC'); if (!empty($schoolYear)) { $builder->where('school_year', $schoolYear); } $attendanceStudents = $builder->get()->getResultArray(); foreach ($attendanceStudents as $r) { $raw = $r['student_id'] ?? null; if (is_numeric($raw)) { $studentIds[] = (int)$raw; } elseif (is_string($raw) && $raw !== '') { $studentCodes[] = trim($raw); } } if (!empty($studentIds) || !empty($studentCodes)) { $classStudents = array_map(fn($sid) => ['student_id' => $sid], array_merge($studentIds, $studentCodes)); } } $studentIds = array_values(array_unique(array_filter($studentIds, fn($v) => $v > 0))); $studentCodes = array_values(array_unique(array_filter($studentCodes, fn($v) => $v !== ''))); // Remove inactive students from the candidate list (avoid listing them in tracking views) $inactiveIdSet = []; if (!empty($studentIds)) { $inactiveRows = $this->studentModel ->select('id') ->whereIn('id', $studentIds) ->where('is_active', 0) ->findAll(); $inactiveIdSet = array_flip(array_map( static fn($r) => (int)($r['id'] ?? 0), $inactiveRows )); $studentIds = array_values(array_filter( $studentIds, static fn($id) => !isset($inactiveIdSet[$id]) )); } $inactiveCodeSet = []; if (!empty($studentCodes)) { $inactiveCodeRows = $this->studentModel ->select('school_id') ->whereIn('school_id', $studentCodes) ->where('is_active', 0) ->findAll(); $inactiveCodeSet = array_flip(array_map( static fn($r) => (string)($r['school_id'] ?? ''), $inactiveCodeRows )); $studentCodes = array_values(array_filter( $studentCodes, static fn($code) => $code !== '' && !isset($inactiveCodeSet[$code]) )); } $debugInfo['student_ids'] = count($studentIds) + count($studentCodes); if (empty($studentIds) && empty($studentCodes)) { return view('attendance/violations_pending', [ 'title' => 'Pending Attendance Violations', 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, 'error' => 'No student identifiers found for the current school year.', ]); } $students = []; if (!empty($studentIds)) { $students = $this->studentModel ->whereIn('id', $studentIds) ->where('is_active', 1) ->findAll(); } if (!empty($studentCodes)) { $byCode = $this->studentModel ->whereIn('school_id', $studentCodes) ->where('is_active', 1) ->findAll(); // merge unique by id $seen = array_flip(array_map(fn($s) => (int)($s['id'] ?? 0), $students)); foreach ($byCode as $s) { $sid = (int)($s['id'] ?? 0); if ($sid > 0 && !isset($seen[$sid])) { $students[] = $s; $seen[$sid] = true; } } } // If some codes were not found in students table, create lightweight placeholder entries $knownCodes = []; foreach ($students as $s) { $code = trim((string)($s['school_id'] ?? '')); if ($code !== '') { $knownCodes[$code] = true; } } foreach ($studentCodes as $code) { if (!isset($knownCodes[$code])) { $virtualId = abs(crc32($code)) ?: mt_rand(1000000, 1999999); $students[] = [ 'id' => $virtualId, 'school_id' => $code, 'firstname' => $code, 'lastname' => '', 'parent_id' => 0, 'class_name' => '', ]; } } if (empty($students)) { return view('attendance/violations_pending', [ 'title' => 'Pending Attendance Violations', 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, 'error' => 'No students found matching attendance records.', 'debug' => $debugInfo, ]); } // Ensure every attendance student (numeric or code) has a student entry $studentCodeToId = []; foreach ($students as $stu) { $code = trim((string)($stu['student_code'] ?? $stu['school_id'] ?? '')); if ($code !== '') { $studentCodeToId[$code] = (int)($stu['id'] ?? 0); } } $existingIds = array_flip(array_map(fn($s) => (int)($s['id'] ?? 0), $students)); foreach (array_merge($studentIds, $studentCodes) as $sidOrCode) { $sid = null; $code = null; if (is_numeric($sidOrCode)) { $sid = (int)$sidOrCode; } else { $code = (string)$sidOrCode; } if ($sid !== null && $sid > 0) { if (!isset($existingIds[$sid])) { $students[] = [ 'id' => $sid, 'school_id' => $code ?? (string)$sid, 'firstname' => (string)$sid, 'lastname' => '', 'parent_id' => 0, 'class_name' => '', ]; $existingIds[$sid] = true; } } elseif ($code !== null && $code !== '') { if (!isset($studentCodeToId[$code])) { $virtualId = abs(crc32($code)) ?: mt_rand(2000000, 2999999); $students[] = [ 'id' => $virtualId, 'school_id' => $code, 'firstname' => $code, 'lastname' => '', 'parent_id' => 0, 'class_name' => '', ]; $studentCodeToId[$code] = $virtualId; $existingIds[$virtualId] = true; } } } // Map code-like identifiers to numeric IDs (attendance_data may store codes in student_id) $studentCodeToId = []; foreach ($students as $stu) { $code = trim((string)($stu['student_code'] ?? $stu['school_id'] ?? '')); if ($code !== '') { $studentCodeToId[$code] = (int)($stu['id'] ?? 0); } } $existingIds = array_flip(array_map(fn($s) => (int)($s['id'] ?? 0), $students)); $today = new \DateTime('today'); [$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear); // Use the whole school year window (capped at today) so older absences are considered $start = new \DateTime($syStart . ' 00:00:00'); $rangeEnd = new \DateTime(min($syEnd, $today->format('Y-m-d')) . ' 00:00:00'); $endEx = (clone $rangeEnd)->modify('+1 day'); // not notified yet (tolerant: != 'yes', 0, '0', null) $qb = $this->attendanceDataModel->builder(); $qb->select('id, student_id, class_id, class_section_id, date, status, reason, school_year, semester, is_reported, is_notified'); if (!empty($schoolYear)) { $qb->where('school_year', $schoolYear); } if (!empty($semester)) { $qb->where('semester', $semester); } // Support attendance_data that stores student codes instead of numeric IDs $qb->groupStart(); $studentCodes = array_keys($studentCodeToId); $hasClause = false; if (!empty($studentIds)) { $qb->whereIn('student_id', $studentIds); $hasClause = true; } if (!empty($studentCodes)) { if ($hasClause) { $qb->orWhereIn('student_id', $studentCodes); } else { $qb->whereIn('student_id', $studentCodes); } $hasClause = true; } $qb->groupEnd(); // ✅ unreported records only (honor legacy columns) $this->applyUnreportedAttendanceFilter($qb); // ✅ not yet notified records (treat both string 'yes' and numeric 1 as notified) $qb->groupStart() ->where("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))", null, false) ->groupEnd(); // ✅ only absents/lates // Normalize status with TRIM/LOWER to catch trailing spaces/case differences $qb->where("LOWER(TRIM(status)) IN ('absent','late')", null, false); // School-year date window (inclusive start, exclusive end) $qb->where('date >=', $start->format('Y-m-d')) ->where('date <', $endEx->format('Y-m-d')); $termRows = $qb->orderBy('date', 'DESC')->get()->getResultArray(); if (empty($termRows)) { log_message('debug', 'pendingViolationsView: no attendance rows found', [ 'school_year' => $schoolYear, 'semester' => $semester, 'student_ids' => $studentIds, ]); // Second chance: detect the latest attendance_data term for these students $latestAttendanceTerm = $this->db->table('attendance_data') ->select('school_year, semester, date') ->whereIn('student_id', $studentIds) ->orderBy('date', 'DESC') ->limit(1) ->get() ->getRowArray(); if ($latestAttendanceTerm) { $schoolYear = !empty($latestAttendanceTerm['school_year']) ? (string)$latestAttendanceTerm['school_year'] : $schoolYear; $semester = !empty($latestAttendanceTerm['semester']) ? (string)$latestAttendanceTerm['semester'] : $semester; [$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear ?: $this->schoolYear); $start = new \DateTime($syStart . ' 00:00:00'); $rangeEnd = new \DateTime(min($syEnd, $today->format('Y-m-d')) . ' 00:00:00'); $endEx = (clone $rangeEnd)->modify('+1 day'); $qb = $this->attendanceDataModel->builder(); $qb->select('id, student_id, class_id, class_section_id, date, status, reason, school_year, semester, is_reported, is_notified'); if (!empty($schoolYear)) { $qb->where('school_year', $schoolYear); } if (!empty($semester)) { $qb->where('semester', $semester); } $qb->groupStart(); $hasClause = false; if (!empty($studentIds)) { $qb->whereIn('student_id', $studentIds); $hasClause = true; } if (!empty($studentCodes)) { if ($hasClause) { $qb->orWhereIn('student_id', $studentCodes); } else { $qb->whereIn('student_id', $studentCodes); } } $qb->groupEnd(); $this->applyUnreportedAttendanceFilter($qb); $qb->groupStart() ->where("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))", null, false) ->groupEnd(); $qb->where("LOWER(TRIM(status)) IN ('absent','late')", null, false); $qb->where('date >=', $start->format('Y-m-d')) ->where('date <', $endEx->format('Y-m-d')); $termRows = $qb->orderBy('date', 'DESC')->get()->getResultArray(); } if (empty($termRows)) { log_message('debug', 'pendingViolationsView: still no rows after fallback', [ 'school_year' => $schoolYear, 'semester' => $semester, 'student_ids' => $studentIds, ]); return view('attendance/violations_pending', [ 'title' => 'Pending Attendance Violations', 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, ]); } } $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; // Normalize student_id: some rows store student_code instead of numeric id $rawSid = $row['student_id'] ?? null; $sid = null; if (is_numeric($rawSid)) { $sid = (int)$rawSid; } elseif (is_string($rawSid)) { $code = trim($rawSid); $sid = $studentCodeToId[$code] ?? null; if ($sid === null) { // Create a placeholder student for unseen code $sid = abs(crc32($code)) ?: mt_rand(3000000, 3999999); $studentCodeToId[$code] = $sid; if (!isset($existingIds[$sid])) { $students[] = [ 'id' => $sid, 'school_id' => $code, 'firstname' => $code, 'lastname' => '', 'parent_id' => 0, ]; $existingIds[$sid] = true; } } } if ($sid === null || $sid <= 0) continue; $row['student_id'] = $sid; $row['status'] = strtolower(trim((string)($row['status'] ?? ''))); $row['date'] = substr($d->format('Y-m-d H:i:s'), 0, 10); $attendanceRows[] = $row; } // Ensure every attendance student ID has a student entry foreach ($attendanceRows as $arow) { $sid = (int)($arow['student_id'] ?? 0); if ($sid > 0 && !isset($existingIds[$sid])) { $students[] = [ 'id' => $sid, 'school_id' => (string)($arow['student_code'] ?? $arow['student_id']), 'firstname' => (string)($arow['student_code'] ?? $arow['student_id']), 'lastname' => '', 'parent_id' => 0, ]; $existingIds[$sid] = true; } } if (empty($attendanceRows)) { log_message('debug', 'pendingViolationsView: filtered attendance rows empty', [ 'raw_rows' => count($termRows), 'school_year' => $schoolYear, 'semester' => $semester, ]); return view('attendance/violations_pending', [ 'title' => 'Pending Attendance Violations', 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, 'debug' => $debugInfo, ]); } $debugInfo['attendance_rows'] = count($termRows); $debugInfo['filtered_rows'] = count($attendanceRows); log_message('debug', 'pendingViolationsView: attendance rows ready', [ 'school_year' => $schoolYear, 'semester' => $semester, 'rows' => count($attendanceRows), 'students' => count($students), ]); $violations = $this->computeViolations($students, $attendanceRows, $schoolYear, null, $attendanceRows); $debugInfo['violations'] = count($violations); $debugInfo['active_weeks'] = $this->debugCompute['active_weeks'] ?? 0; $debugInfo['by_student'] = $this->debugCompute['by_student'] ?? 0; $debugInfo['abs_last5'] = $this->debugCompute['abs_last5'] ?? 0; $debugInfo['late_last5'] = $this->debugCompute['late_last5'] ?? 0; return view('attendance/violations_pending', [ 'title' => 'Pending Attendance Violations', 'students' => $violations, 'school_year' => $schoolYear, 'semester' => $semester, 'debug' => $debugInfo, ]); } private function notificationAlreadySent(int $studentId, string $code, string $ymd, string $channel = 'email', ?string $to = null): bool { try { return $this->notificationModel->hasSent($studentId, $code, $ymd, $channel, $to); } catch (\Throwable $e) { log_message('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 { // Upsert by unique key (student_id, code, incident_date, channel, to_address) $where = [ 'student_id' => $studentId, 'code' => $code, 'incident_date' => $ymd, 'channel' => $channel, ]; if (!empty($to)) { $where['to_address'] = $to; } $existing = $this->notificationModel->where($where)->orderBy('id', 'DESC')->first(); if ($existing && !empty($existing['id'])) { // Update status/response/subject to reflect latest attempt $this->notificationModel->update((int) $existing['id'], [ 'subject' => $subject, 'status' => $status, 'response' => $response, 'semester' => $this->semester, 'school_year' => $this->schoolYear, 'updated_at' => date('Y-m-d H:i:s'), ]); } else { // Insert a new log row $this->notificationModel->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_message('debug', 'logNotification(): ' . $e->getMessage()); } } public function record() { if (strtolower($this->request->getMethod()) !== 'post') { return redirect()->to(site_url('attendance/violations')); } $post = $this->request->getPost(); $rules = [ 'student_id' => 'required|is_natural_no_zero', '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', 'return_url' => 'permit_empty|string', ]; if (! $this->validate($rules)) { return redirect()->back()->withInput()->with('error', implode(' ', $this->validator->getErrors())); } $sid = (int) $post['student_id']; $ymd = substr((string) $post['date'], 0, 10); $semester = $post['semester'] ?? $this->semester; $schoolYear = $post['school_year'] ?? $this->schoolYear; $returnUrl = $post['return_url'] ?? site_url('attendance/violations'); $subject = $post['subject_type'] ?? 'Absent'; $student = $this->studentModel->find($sid) ?? []; $parentEmail = $post['parent_email'] ?? ''; $parentName = $post['parent_name'] ?? ''; if (!$parentEmail) { $parent = $this->getPrimaryParentForStudent($sid) ?? []; $parentEmail = $parent['email'] ?? ''; $parentName = $parent['parent_name'] ?? ''; } if (!$parentEmail) { return redirect()->to($returnUrl)->with('error', 'No parent email found for this student.'); } $tracking = $this->attendanceTrackingModel; [$start, $end] = $this->dayBounds($ymd); $row = $tracking->builder() ->where('student_id', $sid) ->where('semester', $semester) ->where('school_year', $schoolYear) ->where('date >=', $start) ->where('date <', $end) ->orderBy('date', 'DESC') ->get()->getFirstRow('array'); $consequence = null; $reasonTxt = (string) ($row['reason'] ?? ''); if (preg_match('/\bABS_4\b|dismissal/i', $reasonTxt)) $consequence = 'dismissal'; elseif (preg_match('/\bABS_3\b|final_warning/i', $reasonTxt)) $consequence = 'final_warning'; elseif (preg_match('/\bABS_2\b|follow_up/i', $reasonTxt)) $consequence = 'follow_up'; $class = $this->studentClassModel->getClassSectionsByStudentId($sid, $this->schoolYear); $payload = [ 'student_id' => $sid, 'student' => $student, 'parent' => ['email' => $parentEmail, 'name' => $parentName], 'semester' => $semester, 'school_year' => $schoolYear, 'class' => $class, 'now' => date('Y-m-d H:i:s'), 'date' => $ymd, 'subject' => $subject, ]; switch ($consequence) { case 'dismissal': Events::trigger('attendance.dismissal', $payload); break; case 'final_warning': Events::trigger('attendance.final_warning', $payload); break; case 'follow_up': default: Events::trigger('attendance.follow_up', $payload); break; } try { if ($row && !empty($row['id'])) { $tracking->markAsNotified((int) $row['id']); } } catch (\Throwable $e) { log_message('debug', 'Could not update notif_counter: ' . $e->getMessage()); } return redirect()->to($returnUrl)->with('message', 'Notification queued.'); } public function viewStudentCase($studentId) { $student = $this->studentModel->find($studentId); if (!$student) { return redirect()->to('/attendance')->with('error', 'Student not found'); } $student['name'] = trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')); // Optional filters passed from violations list $codeParam = strtoupper((string)($this->request->getGet('code') ?? '')); $incidentDate = (string)($this->request->getGet('incident_date') ?? ''); $incidentDate = preg_match('/^\d{4}-\d{2}-\d{2}$/', $incidentDate) ? $incidentDate : ''; $includeNotified = filter_var($this->request->getGet('include_notified'), FILTER_VALIDATE_BOOL) === true; $pendingOnly = filter_var($this->request->getGet('pending_only'), FILTER_VALIDATE_BOOL) === true; // Map code -> statuses $statusFilter = ['absent', 'late']; if (str_starts_with($codeParam, 'ABS')) { $statusFilter = ['absent']; } elseif (str_starts_with($codeParam, 'LATE')) { $statusFilter = ['late']; } elseif (str_starts_with($codeParam, 'MIX')) { $statusFilter = ['absent', 'late']; } $syParam = $this->request->getGet('school_year'); $semParam = $this->request->getGet('semester'); $schoolYearFilter = (is_string($syParam) && $syParam !== '') ? (string)$syParam : null; $semesterFilter = (is_string($semParam) && $semParam !== '') ? (string)$semParam : null; $schoolYear = $schoolYearFilter ?? ($incidentDate !== '' ? $this->schoolYearForDate($incidentDate) : (string)$this->schoolYear); $semester = $semesterFilter; // For non-violation detail pages, default to current semester when no filter provided. if ($semester === null && $codeParam === '' && $incidentDate === '') { $semester = (string)$this->semester; } [$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear ?: date('Y')); $today = new \DateTime('today'); $start = new \DateTime($syStart . ' 00:00:00'); $rangeEnd = new \DateTime(min($syEnd, $today->format('Y-m-d')) . ' 00:00:00'); $endEx = (clone $rangeEnd)->modify('+1 day'); // Date window: use the last N active weeks ending at incidentDate (or today) $windowWeeks = $this->windowWeeksForViolationCode($codeParam); $endYmd = $incidentDate !== '' ? $incidentDate : date('Y-m-d'); $startYmd = $this->activeWeekWindowStartYmd($endYmd, $windowWeeks, $schoolYear, $semester); $studentIdValues = [(int)$studentId]; $studentCodeRaw = trim((string)($student['student_code'] ?? $student['school_id'] ?? '')); $studentCodeValues = []; if ($studentCodeRaw !== '') { if (is_numeric($studentCodeRaw)) { $studentCodeNum = (int)$studentCodeRaw; if ($studentCodeNum > 0 && $studentCodeNum !== (int)$studentId) { $studentIdValues[] = $studentCodeNum; } } else { $studentCodeValues[] = $studentCodeRaw; } } $studentIdValues = array_values(array_unique(array_filter($studentIdValues, fn($v) => $v > 0))); $studentCodeValues = array_values(array_unique(array_filter($studentCodeValues, fn($v) => $v !== ''))); $qb = $this->attendanceDataModel->builder(); $qb->select('id, student_id, class_id, class_section_id, date, status, reason, school_year, semester, is_reported, is_notified') ->groupStart() ->whereIn('student_id', $studentIdValues); if (!empty($studentCodeValues)) { $qb->orWhereIn('student_id', $studentCodeValues); } $qb->groupEnd() ->where('date >=', max($start->format('Y-m-d'), $startYmd)) ->where('date <', $this->dayBounds($endYmd)[1]); // Status filter (tolerate legacy codes like ABS/A/LATE/L) if ($statusFilter === ['absent']) { $qb->where("(LOWER(TRIM(status)) LIKE 'abs%' OR LOWER(TRIM(status)) = 'a')", null, false); } elseif ($statusFilter === ['late']) { $qb->where("(LOWER(TRIM(status)) LIKE 'late%' OR LOWER(TRIM(status)) = 'l')", null, false); } else { $qb->where("(LOWER(TRIM(status)) LIKE 'abs%' OR LOWER(TRIM(status)) LIKE 'late%' OR LOWER(TRIM(status)) IN ('a','l'))", null, false); } $qb ->orderBy('date', 'DESC'); // Pending-only filter (optional) if ($pendingOnly) { $this->applyUnreportedAttendanceFilter($qb); if (!$includeNotified) { $qb->groupStart() ->where("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))", null, false) ->groupEnd(); } } if ($schoolYearFilter !== null) { $qb->where('school_year', $schoolYearFilter); } if ($semesterFilter !== null) { $qb->where('semester', $semesterFilter); } $attendanceRows = $qb->get()->getResultArray(); $data = [ 'title' => 'Attendance History - ' . $student['name'], 'student' => $student, 'attendance' => $attendanceRows, 'violation_code' => $codeParam !== '' ? $codeParam : null, // Prefer the incident date passed from the violation list; otherwise fall back to // the newest attendance row in the window so the details page always shows a date. 'violation_date' => $incidentDate, 'violation_notified' => null, 'show_violation_summary' => true, ]; if ($data['violation_date'] === '' && !empty($data['attendance'][0]['date'])) { $data['violation_date'] = substr((string)$data['attendance'][0]['date'], 0, 10); } if (!empty($data['attendance'][0]['is_notified'])) { $data['violation_notified'] = $data['attendance'][0]['is_notified']; } // Avoid duplicating the violation summary row when the attendance list already // contains the same incident date. if ($data['violation_date'] !== '') { foreach ($attendanceRows as $row) { $ymd = substr((string)($row['date'] ?? ''), 0, 10); if ($ymd === $data['violation_date']) { $data['show_violation_summary'] = false; break; } } } return view('attendance/view', $data); } 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' ]; } private function schoolYearForDate(string $ymd): string { $ts = strtotime($ymd); if ($ts === false) { return (string) $this->schoolYear; } $y = (int) date('Y', $ts); $m = (int) date('n', $ts); $startY = ($m >= 8) ? $y : $y - 1; return sprintf('%d-%d', $startY, $startY + 1); } protected 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, ]; } protected 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'] ?? ''), ]; } } // Fallback to plain fields on parents row $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_message('error', 'getSecondaryParentForStudent() failed: ' . $e->getMessage()); } return null; } // ------------------------------------------------------------ // RULES ENGINE (Absence/Late) — uses ONLY non-reported rows // ------------------------------------------------------------ private function computeViolations( array $students, array $attendanceData, ?string $schoolYear = null, ?string $semester = null, ?array $weekRowsFallback = null ): array { $this->debugCompute = ['active_weeks' => 0, 'by_student' => 0]; $schoolYear = $schoolYear ?: $this->schoolYear; $semester = ($semester !== null && $semester !== '') ? (string)$semester : null; $studentCodeToId = []; foreach ($students as $stu) { $code = trim((string)($stu['student_code'] ?? $stu['school_id'] ?? '')); if ($code !== '') { $studentCodeToId[$code] = (int)($stu['id'] ?? 0); } } $byStudent = []; foreach ($attendanceData as $r) { $rawSid = $r['student_id'] ?? null; if (is_numeric($rawSid)) { $sid = (int)$rawSid; } else { $sid = $studentCodeToId[trim((string)$rawSid)] ?? 0; } if ($sid <= 0) continue; $ymd = substr((string)($r['date'] ?? ''), 0, 10); $stat = strtolower(trim((string)$r['status'])); if (!in_array($stat, ['absent', 'late'], true)) continue; $byStudent[$sid][$stat][] = $ymd; } $this->debugCompute['by_student'] = count($byStudent); [$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear); // Only trust fallback rows for "active weeks" if they include non-violation statuses. $weekRowsSource = null; if (!empty($weekRowsFallback)) { foreach ($weekRowsFallback as $r) { $st = strtolower(trim((string)($r['status'] ?? ''))); if ($st !== '' && !in_array($st, ['absent', 'late'], true)) { $weekRowsSource = $weekRowsFallback; break; } } } $activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $syEnd, $schoolYear, $semester, $weekRowsSource); $this->debugCompute['active_weeks'] = count($activeWeekKeys); if (empty($activeWeekKeys)) { log_message('debug', 'computeViolations: no active weeks', [ 'school_year' => $schoolYear, 'semester' => $semester, 'rows' => count($attendanceData), ]); return []; } $weekIndex = array_flip($activeWeekKeys); $currentWeekIdx = count($activeWeekKeys) - 1; // Use only the last 5 active weeks for violation calculations $windowStartIdx = max(0, $currentWeekIdx - 4); $requiredWeeksCount = $currentWeekIdx - $windowStartIdx + 1; // Keep dates that fall within the last 5 active weeks $keepLastWeeks = function (array $dates) use ($weekIndex, $windowStartIdx, $currentWeekIdx): array { $out = []; foreach ($dates as $d) { $key = date('o-\WW', strtotime($d)); if (isset($weekIndex[$key])) { $idx = $weekIndex[$key]; $ymd = substr((string)$d, 0, 10); if ($idx >= $windowStartIdx && $idx <= $currentWeekIdx) { $out[] = $ymd; } } } $out = array_values(array_unique($out)); rsort($out); return $out; }; $out = []; // Prefetch class/grade (class section) labels for all students to avoid N+1 queries $classByStudent = []; try { $studentIdsForClass = array_values(array_unique(array_map( static fn($s) => (int)($s['id'] ?? 0), $students ))); if (!empty($studentIdsForClass)) { $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', $studentIdsForClass) ->where('sc.school_year', $schoolYear) ->orderBy('sc.id', 'DESC') // latest assignment first ->get()->getResultArray(); // Keep the latest row per student_id foreach ($rows as $row) { $sid = (int)($row['student_id'] ?? 0); if ($sid <= 0) continue; if (!isset($classByStudent[$sid])) { $classByStudent[$sid] = [ 'class_section_id' => $row['class_section_id'] ?? null, 'class_section_name' => (string)($row['class_section_name'] ?? ''), 'class_id' => $row['class_id'] ?? null, 'class_name' => (string)($row['class_name'] ?? ''), ]; } } } } catch (\Throwable $e) { // If anything goes wrong, fail gracefully without class labels log_message('debug', 'computeViolations(): class prefetch failed: ' . $e->getMessage()); } $absCountLast5 = 0; $lateCountLast5 = 0; 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); $absCountLast5 += count($absDates); $lateCountLast5 += count($lateDates); $absenceViolation = null; if (!empty($absDates)) { $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_in5w4 = $this->hasNInWActiveWeeks($absWeekIdx, 4, 5); if ($A4row || $A_in5w4) { $absenceViolation = [ 'type' => 'absence', 'code' => 'ABS_4', 'severity' => 4, 'action' => 'expel_notify', 'color' => '#ff4d4f', 'title' => '4 Absences (in a row or within last 5 active weeks)', 'description' => 'Notify team to inform parent about expulsion and send email.', 'last_date' => $lastAbsDate, ]; } elseif ($A3row || $A_in4w3) { $absenceViolation = [ '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, ]; } elseif ($A2row || $A_in3w2) { $absenceViolation = [ '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, ]; } } $lateViolation = null; if (!empty($lateDates)) { $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); $lateCoversAllWindowWeeks = false; if ($requiredWeeksCount >= 4) { $lateWeeksSet = array_flip($lateWeekIdx); $lateCoversAllWindowWeeks = true; for ($i = $windowStartIdx; $i <= $currentWeekIdx; $i++) { if (!isset($lateWeeksSet[$i])) { $lateCoversAllWindowWeeks = false; break; } } } if ($L4row || $lateCoversAllWindowWeeks) { $lateViolation = [ '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, ]; } else { $twoLatesOneAbsWithin4 = $this->twoLatesOneAbsInWWeeks($lateWeekIdx, $absWeekIdx, 4); if ($L3row || $L_in4w3 || $twoLatesOneAbsWithin4) { $lateViolation = [ '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, ]; } elseif ($L2row || $L_in3w2) { $lateViolation = [ '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, ]; } } } $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); // Do not skip pending violations even if a previous tracking/notification exists; // the view should surface all current infractions. [$start, $end] = $this->dayBounds($chosen['last_date']); $trackingQ = $this->attendanceTrackingModel ->where('student_id', $sid) ->where('school_year', $schoolYear) ->where('date >=', $start) ->where('date <', $end) ->like('reason', $chosen['code'], 'both'); if ($semester !== null) { $trackingQ->where('semester', $semester); } $tracking = $trackingQ->orderBy('date', 'DESC')->first(); $isNotified = (bool)($tracking['is_notified'] ?? 0); $notifCnt = (int)($tracking['notif_counter'] ?? 0); // Resolve class/grade label: prefer class_section_name (e.g., "3-A"), // fall back to classes.class_name if available. $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, // Provide multiple common keys so views can pick any 'class_name' => $classLabel, 'class_section_name' => (string)($cls['class_section_name'] ?? ''), 'className' => (string)($cls['class_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'], 'is_notified' => $isNotified, 'notif_counter' => $notifCnt, 'absences' => $absDates, 'lates' => $lateDates, ]; } $this->debugCompute['abs_last5'] = $absCountLast5; $this->debugCompute['late_last5'] = $lateCountLast5; return $out; } public function sendAutoEmails() { if (!$this->request->is('post')) { return $this->response->setStatusCode(405)->setJSON([ 'success' => false, 'message' => 'Method Not Allowed' ]); } $globalVariantOverride = (string)($this->request->getVar('variant') ?? ''); $classStudents = $this->studentClassModel ->active() ->where('school_year', $this->schoolYear) ->findAll(); if (empty($classStudents)) { return $this->response->setJSON([ 'success' => true, 'sent' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [] ]); } $studentIds = array_column($classStudents, 'student_id'); $students = $this->studentModel ->whereIn('id', $studentIds) ->where('is_active', 1) ->findAll(); $attendanceQuery = $this->attendanceDataModel ->whereIn('student_id', $studentIds) ->where('school_year', $this->schoolYear) ->where('date >=', (new \DateTime('today'))->modify('-5 weeks')->format('Y-m-d')); $this->applyUnreportedAttendanceFilter($attendanceQuery); $attendanceData = $attendanceQuery ->where("LOWER(TRIM(status)) IN ('absent','late')", null, false) ->orderBy('date', 'DESC') ->findAll(); $violations = $this->computeViolations($students, $attendanceData, $this->schoolYear, null, $attendanceData); $toSend = array_values(array_filter($violations, fn($v) => ($v['action'] ?? '') === 'auto_email')); $sent = 0; $skipped = 0; $errors = 0; $details = []; $getPrimaryParent = function (int $sid): array { return $this->getPrimaryParentForStudent($sid) ?? []; }; $buildContext = function (array $v, array $parent): array { return [ '{{parent_name}}' => (string)($parent['parent_name'] ?? 'Parent/Guardian'), '{{student_name}}' => (string)($v['name'] ?? ''), '{{incident_date}}' => (string)($v['last_date'] ?? date('Y-m-d')), '{{school_phone}}' => '978-364-0219', '{{voicemail_phone}}' => (string)($parent['phone'] ?? '—'), '{{class_time}}' => '10:00 AM', ]; }; $pickVariant = function (string $code) use ($globalVariantOverride): string { if ($globalVariantOverride !== '') return $globalVariantOverride; return match ($code) { 'ABS_2', 'ABS_2_IN3W' => 'no_answer', default => 'default', }; }; $renderFromTemplate = function (string $code, array $context, string $variant = 'default') { $templateModel = $this->attendanceEmailTemplateModel ?? new \App\Models\AttendanceEmailTemplateModel(); $row = $templateModel->getTemplate($code, $variant) ?: $templateModel->getTemplate($code, 'default'); if (!$row) return null; return [strtr($row['subject'], $context), strtr($row['body_html'], $context)]; }; foreach ($toSend as $v) { $sid = (int)($v['id'] ?? 0); $code = (string)($v['violation_code'] ?? $v['code'] ?? ''); $date = (string)($v['last_date'] ?? ''); $to = trim((string)($v['parent_email'] ?? '')); $pname = (string)($v['parent_name'] ?? ''); if ($sid <= 0 || !$code || !$date || !$to) { $errors++; $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'missing data']; continue; } if ($this->notificationAlreadySent($sid, $code, $date, 'email', $to)) { $skipped++; $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'duplicate']; continue; } $parent = $getPrimaryParent($sid); $context = $buildContext($v, $parent); $variant = $pickVariant($code); $tpl = $renderFromTemplate($code, $context, $variant); if (!$tpl) { $subject = match ($code) { 'ABS_1' => 'Attendance Notice: Unreported Absence', 'LATE_2' => 'Attendance Notice: Repeated Lateness', default => 'Attendance Notice', }; $studentName = (string)($v['name'] ?? ''); $body = "
Dear " . ($pname ?: 'Parent/Guardian') . "
" . "We'd like to inform you about your student's recent attendance:
" . "If you have any questions, please contact the school office.
"; } else { [$subject, $body] = $tpl; } $wrapped = $this->renderWithEmailLayout($subject, $body); $anyOk = false; $mailer = new EmailController(); // Primary send try { $ok = $mailer->sendEmail($to, $subject, $wrapped); if ($ok) { $anyOk = true; $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'sent']; $this->logNotification($sid, $code, $date, 'email', $to, $subject, 'sent', 'OK'); } else { $errors++; $this->logNotification($sid, $code, $date, 'email', $to, $subject, 'failed', 'sendEmail returned false'); $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'failed']; } } catch (\Throwable $e) { $errors++; $this->logNotification($sid, $code, $date, 'email', $to, $subject, 'failed', $e->getMessage()); $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'error: ' . $e->getMessage()]; } // Secondary (CC-like) send: send separate email to second parent if available try { $sec = $this->getSecondaryParentForStudent($sid); $to2 = trim((string)($sec['email'] ?? '')); if ($to2 !== '' && strcasecmp($to2, $to) !== 0) { if ($this->notificationAlreadySent($sid, $code, $date, 'email', $to2)) { $skipped++; $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'duplicate_cc']; } else { $ok2 = $mailer->sendEmail($to2, $subject, $wrapped); if ($ok2) { $anyOk = true; $this->logNotification($sid, $code, $date, 'email', $to2, $subject, 'sent', 'OK'); $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_sent']; } else { $errors++; $this->logNotification($sid, $code, $date, 'email', $to2, $subject, 'failed', 'sendEmail returned false'); $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_failed']; } } } } catch (\Throwable $e) { $errors++; $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_error: ' . $e->getMessage()]; } // Mark tracking notified once per student/code/date if any send succeeded if ($anyOk) { $sent++; $this->attendanceTrackingModel->markRuleAsNotified( $sid, $code, $date, $this->semester, $this->schoolYear ); // Mark all dates involved in this violation as reported+notified $allDates = []; $absDates = array_map(fn($d) => substr((string)$d, 0, 10), (array)($v['absences'] ?? [])); $lateDates= array_map(fn($d) => substr((string)$d, 0, 10), (array)($v['lates'] ?? [])); if (!empty($absDates)) $allDates = array_merge($allDates, $absDates); if (!empty($lateDates)) $allDates = array_merge($allDates, $lateDates); if (empty($allDates)) { $allDates[] = substr($date, 0, 10); } $this->attendanceDataModel->markReportedAndNotified($sid, $allDates, $this->semester, $this->schoolYear); } } return $this->response->setJSON([ 'success' => true, 'sent' => $sent, 'skipped' => $skipped, 'errors' => $errors, 'details' => $details, ]); } /** * Wraps an email body with the app/Views/layout/email_layout.php view if present. * Falls back to raw body if the view is missing to avoid crashing. */ private function renderWithEmailLayout(string $subject, string $bodyHtml): string { return view('emails/_wrap_layout', [ 'title' => $subject, 'body_html' => $bodyHtml, ], ['saveData' => false]); } private function chooseHigherSeverity(?array $a, ?array $b): ?array { if ($a && $b) { if ($a['severity'] === $b['severity']) { $prio = ['expel_notify' => 3, 'team_notify' => 2, 'auto_email' => 1]; return ($prio[$a['action']] ?? 0) >= ($prio[$b['action']] ?? 0) ? $a : $b; } return $a['severity'] > $b['severity'] ? $a : $b; } return $a ?: $b; } // -------------- Helpers (dates & weeks) -------------- private function attendanceReportedColumns(): array { if ($this->attendanceReportedColumns !== null) { return $this->attendanceReportedColumns; } $this->attendanceReportedColumns = [ 'is_reported' => $this->db->fieldExists('is_reported', 'attendance_data'), 'reported' => $this->db->fieldExists('reported', 'attendance_data'), ]; return $this->attendanceReportedColumns; } private function applyUnreportedAttendanceFilter($qb): void { $cols = $this->attendanceReportedColumns(); if (!empty($cols['is_reported'])) { $qb->where("(LOWER(TRIM(COALESCE(is_reported, ''))) NOT IN ('yes','1','true','y','on'))", null, false); } if (!empty($cols['reported'])) { $qb->where("(LOWER(TRIM(COALESCE(reported, ''))) NOT IN ('yes','1','true','y','on'))", null, false); } // Treat parent-reported rows as reported even if flags weren't updated $qb->where("(LOWER(COALESCE(reason, '')) NOT LIKE '%parent-reported%')", null, false) ->where("(LOWER(COALESCE(reason, '')) NOT LIKE '%parent reported%')", null, false); if ($this->db->tableExists('parent_attendance_reports')) { $qb->where("NOT EXISTS ( SELECT 1 FROM parent_attendance_reports par WHERE par.student_id = attendance_data.student_id AND DATE(par.report_date) = DATE(attendance_data.`date`) AND par.type IN ('absent','late') )", null, false); } } private function getActiveWeeksFromAttendanceData( string $startYmd, string $endYmd, ?string $schoolYear = null, ?string $semester = null, ?array $weekRowsFallback = null ): array { $schoolYear = $schoolYear ?: $this->schoolYear; $semester = ($semester !== null && $semester !== '') ? (string)$semester : null; $weeks = []; // Prefer explicit rows passed in (all statuses) to define active weeks for the window if (!empty($weekRowsFallback)) { foreach ($weekRowsFallback as $r) { $d = $r['date'] ?? null; if (!$d) continue; $ts = strtotime($d); if ($ts === false) continue; $ymd = date('Y-m-d', $ts); if ($ymd < $startYmd || $ymd > $endYmd) continue; $wk = date('o-\WW', $ts); $weeks[$wk] = true; } } // If still empty, fall back to open dates from attendance_data table if (empty($weeks)) { $builder = $this->db->table('attendance_data') ->select('DATE(`date`) AS ymd') ->where('school_year', $schoolYear) ->where('DATE(`date`) >=', $startYmd) ->where('DATE(`date`) <=', $endYmd) ->groupStart() ->where('reason', null) ->orGroupStart() ->notLike('reason', 'break') ->notLike('reason', 'cancel') ->groupEnd() ->groupEnd() ->groupBy('DATE(`date`)') ->orderBy('ymd', 'ASC'); if ($semester !== null) { $builder->where('semester', $semester); } $rows = $builder->get()->getResultArray(); foreach ($rows as $r) { $wk = date('o-\WW', strtotime($r['ymd'])); $weeks[$wk] = true; } } $keys = array_keys($weeks); sort($keys, SORT_NATURAL); return $keys; } private function datesToWeekIndices(array $dates, array $weekIndex): array { $set = []; foreach ($dates as $ymd) { $wk = date('o-\WW', strtotime($ymd)); if (isset($weekIndex[$wk])) { $set[$weekIndex[$wk]] = true; } } $idx = array_keys($set); sort($idx); return $idx; } private function windowWeeksForViolationCode(string $code): int { $code = strtoupper(trim($code)); if ($code === '') { return 5; } if (str_starts_with($code, 'ABS_2') || str_starts_with($code, 'LATE_2')) { return 3; } if (str_starts_with($code, 'ABS_3') || str_starts_with($code, 'LATE_3') || str_starts_with($code, 'MIX')) { return 4; } if (str_starts_with($code, 'LATE_4')) { return 4; } if (str_starts_with($code, 'ABS_4')) { return 5; } return 5; } private function isoWeekStartYmd(string $weekKey): string { if (preg_match('/^(\d{4})-W(\d{2})$/', $weekKey, $m)) { return date('Y-m-d', strtotime($m[1] . '-W' . $m[2] . '-1')); } return date('Y-m-d'); } private function activeWeekWindowStartYmd( string $endYmd, int $windowWeeks, string $schoolYear, ?string $semester ): string { $windowWeeks = max(1, $windowWeeks); [$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear ?: $this->schoolYear); $endBound = $endYmd; if ($syEnd !== '' && $endBound > $syEnd) { $endBound = $syEnd; } $activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $endBound, $schoolYear, $semester, null); if (empty($activeWeekKeys)) { $days = ($windowWeeks * 7) - 1; return date('Y-m-d', strtotime($endBound . ' -' . $days . ' days')); } $activeWeekKeys = array_values($activeWeekKeys); $weekIndex = array_flip($activeWeekKeys); $endWeekKey = date('o-\WW', strtotime($endBound)); if (!isset($weekIndex[$endWeekKey])) { $endWeekKey = null; for ($i = count($activeWeekKeys) - 1; $i >= 0; $i--) { $wkStart = $this->isoWeekStartYmd($activeWeekKeys[$i]); if ($wkStart <= $endBound) { $endWeekKey = $activeWeekKeys[$i]; break; } } if ($endWeekKey === null) { $endWeekKey = end($activeWeekKeys); } } $currentIdx = $weekIndex[$endWeekKey] ?? (count($activeWeekKeys) - 1); $windowStartIdx = max(0, $currentIdx - ($windowWeeks - 1)); $startWeekKey = $activeWeekKeys[$windowStartIdx] ?? $activeWeekKeys[0]; return $this->isoWeekStartYmd($startWeekKey); } private function hasNConsecutiveItems(array $datesDesc, int $n, int $daysApart): bool { $N = count($datesDesc); if ($N < $n) return false; $dates = $datesDesc; sort($dates); // asc $run = 1; for ($i = 1; $i < $N; $i++) { $prev = new \DateTime($dates[$i - 1]); $curr = new \DateTime($dates[$i]); $diff = (int)$prev->diff($curr)->format('%a'); if ($diff === $daysApart) { $run++; if ($run >= $n) return true; } else { $run = 1; } } return false; } private function hasNInWActiveWeeks(array $weekIdx, int $n, int $w): bool { $N = count($weekIdx); if ($N < $n) return false; $l = 0; for ($r = 0; $r < $N; $r++) { while ($weekIdx[$r] - $weekIdx[$l] > ($w - 1)) { $l++; } if (($r - $l + 1) >= $n) return true; } return false; } private function twoLatesOneAbsInWWeeks(array $lateIdx, array $absIdx, int $w): bool { if (count($lateIdx) < 2 || count($absIdx) < 1) return false; $L = $lateIdx; $A = $absIdx; $iL1 = 0; $iL2 = 1; $iA = 0; while ($iL2 < count($L)) { $windowStart = $L[$iL1]; $windowEnd = $windowStart + ($w - 1); if ($L[$iL2] > $windowEnd) { $iL1++; $iL2 = $iL1 + 1; continue; } while ($iA < count($A) && $A[$iA] < $windowStart) $iA++; if ($iA < count($A) && $A[$iA] <= $windowEnd) return true; $iL2++; if ($iL2 >= count($L)) { $iL1++; $iL2 = $iL1 + 1; } } return false; } /** * True only if the student has a late in EVERY active week from week 0 → currentWeekIdx. * Also requires at least 4 active weeks to have passed (guards single-week false positives). */ private function latesCoverAllPassedWeeks(array $lateIdx, int $currentWeekIdx, int $minWeeks = 4): bool { if ($currentWeekIdx < 0) return false; // no active weeks yet $totalWeeksPassed = $currentWeekIdx + 1; // weeks are 0-based if ($totalWeeksPassed < $minWeeks) return false; // don’t escalate before 4 weeks total if (count($lateIdx) < $totalWeeksPassed) return false; // not enough late weeks to cover all $set = array_flip($lateIdx); // fast lookup for ($i = 0; $i <= $currentWeekIdx; $i++) { if (!isset($set[$i])) return false; // any gap breaks the condition } return true; } private function alreadyReported( int $studentId, string $code, string $lastDate, ?string $semester = null, ?string $schoolYear = null ): bool { $semester = ($semester !== null && $semester !== '') ? (string)$semester : null; $schoolYear = $schoolYear ?: $this->schoolYear; try { $bounds = $this->dayBounds($lastDate); $start = $bounds[0]; $end = $bounds[1]; $q = $this->attendanceTrackingModel ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->where('date >=', $start) ->where('date <', $end) ->like('reason', $code, 'both'); if ($semester !== null) { $q->where('semester', $semester); } $row = $q->orderBy('date', 'DESC')->first(); if (!$row) { $q2 = $this->attendanceTrackingModel ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->where('date >=', $start) ->where('date <', $end); if ($semester !== null) { $q2->where('semester', $semester); } $row = $q2->orderBy('date', 'DESC')->first(); } return $row ? (bool) ($row['is_notified'] ?? 0) : false; } catch (\Throwable $e) { log_message('debug', 'alreadyReported() error: ' . $e->getMessage()); return false; } } public function notifiedViolationsView() { $syParam = $this->request->getGet('school_year'); $semParam = $this->request->getGet('semester'); $schoolYear = (is_string($syParam) && $syParam !== '') ? (string)$syParam : (string)$this->schoolYear; $semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester; // 1️⃣ Get all notified records for this term from attendance_tracking $trackingData = $this->attendanceTrackingModel ->where('school_year', $schoolYear) ->where('semester', $semester) ->groupStart() ->where('is_notified', 1) ->orWhere('is_notified', '1') ->orWhere("LOWER(TRIM(COALESCE(is_notified, ''))) =", 'yes') ->groupEnd() ->orderBy('date', 'DESC') ->findAll(); // 🔄 Fallback: if none found for the configured term, auto-switch to the latest notified term if (empty($trackingData)) { $latest = $this->attendanceTrackingModel ->select('school_year, semester') ->groupStart() ->where('is_notified', 1) ->orWhere('is_notified', '1') ->orWhere("LOWER(TRIM(COALESCE(is_notified, ''))) =", 'yes') ->groupEnd() ->orderBy('date', 'DESC') ->orderBy('id', 'DESC') ->first(); if ($latest) { $schoolYear = (string)($latest['school_year'] ?? $schoolYear); $semester = (string)($latest['semester'] ?? $semester); $trackingData = $this->attendanceTrackingModel ->where('school_year', $schoolYear) ->where('semester', $semester) ->groupStart() ->where('is_notified', 1) ->orWhere('is_notified', '1') ->orWhere("LOWER(TRIM(COALESCE(is_notified, ''))) =", 'yes') ->groupEnd() ->orderBy('date', 'DESC') ->findAll(); } } // If no notified entries, show empty view if (empty($trackingData)) { return view('attendance/violations_notified', [ 'title' => 'Notified Attendance Violations', 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, 'show_actions' => false, ]); } // 2️⃣ Collect unique student IDs from tracking table $studentIds = array_unique(array_column($trackingData, 'student_id')); // 3️⃣ Get student records for those IDs $students = $this->studentModel ->whereIn('id', $studentIds) ->where('is_active', 1) ->findAll(); $activeIdSet = array_flip(array_map( static fn($row) => (int)($row['id'] ?? 0), $students )); $trackingData = array_values(array_filter( $trackingData, static fn($row) => isset($activeIdSet[(int)($row['student_id'] ?? 0)]) )); $studentIds = array_keys($activeIdSet); if (empty($trackingData)) { return view('attendance/violations_notified', [ 'title' => 'Notified Attendance Violations', 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, 'show_actions' => false, ]); } // 4️⃣ Build a map for faster merge $studentMap = []; foreach ($students as $s) { $studentMap[$s['id']] = $s; } // 4.5️⃣ Prefetch latest class/grade label per student for the selected school year $classByStudent = []; try { $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') // latest assignment first ->get()->getResultArray(); foreach ($rows as $row) { $sid = (int)($row['student_id'] ?? 0); if ($sid <= 0) continue; if (!isset($classByStudent[$sid])) { $classByStudent[$sid] = [ 'class_section_id' => $row['class_section_id'] ?? null, 'class_section_name' => (string)($row['class_section_name'] ?? ''), 'class_id' => $row['class_id'] ?? null, 'class_name' => (string)($row['class_name'] ?? ''), ]; } } } catch (\Throwable $e) { // ignore enrichment failure } // 5️⃣ Merge tracking info with student info (and class/grade when available) $mergedData = []; foreach ($trackingData as $row) { $sid = (int)($row['student_id'] ?? 0); if (isset($studentMap[$sid])) { $merged = array_merge($studentMap[$sid], $row); // attach grade/class labels for view consumption if (isset($classByStudent[$sid])) { $merged['class_section_name'] = (string)($classByStudent[$sid]['class_section_name'] ?? ''); $merged['class_name'] = (string)($classByStudent[$sid]['class_name'] ?? ''); } else { // fallback to registration_grade if present if (!empty($merged['registration_grade'])) { $merged['grade'] = (string)$merged['registration_grade']; } } $mergedData[] = $merged; } else { // Skip rows with no active student record continue; } } // 6️⃣ Enrich with primary parent contact so the view can display inline info foreach ($mergedData as &$row) { $sid = (int)($row['student_id'] ?? $row['id'] ?? 0); if ($sid > 0) { try { $p = $this->getPrimaryParentForStudent($sid); if ($p) { $row['parent_name'] = $p['parent_name'] ?? ''; $row['parent_email'] = $p['email'] ?? ''; $row['parent_phone'] = $p['phone'] ?? ''; } } catch (\Throwable $e) { // ignore enrichment failure; view will fallback } } } unset($row); // 7️⃣ Send data to view return view('attendance/violations_notified', [ 'title' => 'Notified Attendance Violations', 'students' => $mergedData, // tracking + students + parent info 'school_year' => $schoolYear, 'semester' => $semester, ]); } private function deriveSchoolYearBounds(string $schoolYear): array { if (!preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m)) { $y = (int) date('Y'); $startY = (int) (date('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 buildTemplateContext(array $v, ?array $parent = null): array { $studentName = (string)($v['name'] ?? ''); $incident = (string)($v['last_date'] ?? date('Y-m-d')); $p = $parent ?: $this->getPrimaryParentForStudent((int)($v['id'] ?? 0)) ?? []; return [ '{{parent_name}}' => (string)($p['parent_name'] ?? 'Parent/Guardian'), '{{student_name}}' => $studentName, '{{incident_date}}' => $incident, '{{school_phone}}' => '978-364-0219', '{{voicemail_phone}}' => (string)($p['phone'] ?? '—'), '{{class_time}}' => '10:00 AM', ]; } private function renderTemplate(string $code, string $variant, array $context): ?array { $row = $this->attendanceEmailTemplateModel->getTemplate($code, $variant); if (!$row) { $row = $this->attendanceEmailTemplateModel->getTemplate($code, 'default'); } if (!$row) return null; // CI4 won't interpolate placeholders automatically log_message('debug', 'Email template resolved: code=' . $code . ' variant=' . ($row['variant'] ?? 'unknown')); $subject = strtr($row['subject'], $context); $body = strtr($row['body_html'], $context); return [$subject, $body]; } private function buildFallbackTemplate(string $code, string $variant, array $context): array { $studentName = (string) ($context['{{student_name}}'] ?? 'the student'); $incident = (string) ($context['{{incident_date}}'] ?? date('Y-m-d')); $parentName = (string) ($context['{{parent_name}}'] ?? 'Parent/Guardian'); $phone = (string) ($context['{{school_phone}}'] ?? 'the school office'); $voicemail = (string) ($context['{{voicemail_phone}}'] ?? 'your voicemail'); $subject = match (true) { str_starts_with($code, 'ABS_1') => 'Attendance Notice: Unreported Absence', str_starts_with($code, 'ABS_2') => 'Attendance Follow-Up: Two Consecutive Absences', str_starts_with($code, 'ABS_3') => 'Attendance Follow-Up: Three Absences', str_starts_with($code, 'LATE_2') => 'Attendance Notice: Repeated Lateness', str_starts_with($code, 'LATE_3'), str_starts_with($code, 'LATE_4'), str_starts_with($code, 'MIX') => 'Attendance Follow-Up: Repeated Lateness and Absence', default => 'Attendance Notice', }; $intro = "Insha Allah this email finds you well"; if ($variant === 'answered') { $intro .= ", {$parentName}. Jazakum Allahu khayran for taking the time to speak with us today.
"; } elseif ($variant === 'no_answer') { $intro .= ".We tried to reach you but could not connect and left a voice message at {$voicemail}.
"; } else { $intro .= "."; } $details = match (true) { str_starts_with($code, 'ABS_1') => "This is to let you know that {$studentName} was absent on {$incident} without prior notice.
", str_starts_with($code, 'ABS_2') => "This is a reminder that {$studentName} has had repeated absences, most recently on {$incident}, without prior notice.
", str_starts_with($code, 'ABS_3') => "This is a reminder that {$studentName} has been absent three times, most recently on {$incident}, without prior notice.
", str_starts_with($code, 'LATE_2') => "This is a reminder that {$studentName} has been late multiple times, most recently on {$incident}.
", str_starts_with($code, 'LATE_3'), str_starts_with($code, 'LATE_4') => "This is a follow-up that {$studentName} has had repeated lateness concerns, most recently on {$incident}.
", str_starts_with($code, 'MIX') => "This is a follow-up that {$studentName} has had a mix of repeated lateness and absence concerns, most recently on {$incident}.
", default => "We'd like to inform you about {$studentName}'s recent attendance concern dated {$incident}.
", }; $closing = "If your child will be absent or late due to illness or family commitments, please let us know ahead of time.
" . "You can email/call/text us at {$phone}.
"; return [$subject, $intro . $details . $closing]; } public function compose() { $sid = (int)($this->request->getGet('student_id') ?? 0); $code = (string)($this->request->getGet('code') ?? 'ABS_1'); $variant = (string)($this->request->getGet('variant') ?? 'default'); $incident = (string)($this->request->getGet('incident_date') ?? ''); if ($sid <= 0) { return redirect()->to(site_url('attendance/violations')) ->with('error', 'Missing student_id.'); } $student = $this->studentModel->find($sid) ?? []; $parent = $this->getPrimaryParentForStudent($sid); $secondary = $this->getSecondaryParentForStudent($sid); // Prefer incident date from query if valid (YYYY-MM-DD), else today $lastDate = (preg_match('/^\d{4}-\d{2}-\d{2}$/', $incident)) ? $incident : date('Y-m-d'); $violation = [ 'id' => $sid, 'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), 'last_date' => $lastDate, 'violation' => $code, 'violation_code' => $code, ]; $ctx = $this->buildTemplateContext($violation, $parent); $rendered = $this->renderTemplate($code, $variant, $ctx); if (!$rendered) { log_message('warning', 'Attendance email template missing; using fallback compose body. code=' . $code . ' variant=' . $variant); $rendered = $this->buildFallbackTemplate($code, $variant, $ctx); } [$subject, $body] = $rendered; return view('attendance/compose_email', [ 'title' => 'Compose Attendance Email', 'student_id' => $sid, 'student_name' => $violation['name'], 'parent_email' => (string)($parent['email'] ?? ''), 'parent_name' => (string)($parent['parent_name'] ?? ''), 'secondary_email' => (string)($secondary['email'] ?? ''), 'secondary_name' => (string)($secondary['parent_name'] ?? ''), 'code' => $code, 'variant' => $variant, 'subject' => $subject, 'body_html' => $body, 'semester' => $this->semester, 'school_year' => $this->schoolYear, 'incident_date'=> $lastDate, ]); } public function sendEmailManual() { if (! $this->request->is('post')) { return redirect()->to(site_url('attendance/violations')); } // Validate $rules = [ 'student_id' => 'required|is_natural_no_zero', 'to' => 'required|valid_email', 'subject' => 'required|string', // We are accepting PLAIN TEXT in this field name (kept as body_html for compatibility) 'body_html' => 'required|string', 'code' => 'required|string', 'variant' => 'permit_empty|string', 'incident_date' => 'permit_empty|valid_date[Y-m-d]', 'return_url' => 'permit_empty|string', ]; if (! $this->validate($rules)) { return redirect()->back()->withInput() ->with('error', implode(' ', $this->validator->getErrors())); } // Inputs $sid = (int) $this->request->getPost('student_id'); $to = trim((string) $this->request->getPost('to')); $subject = (string) $this->request->getPost('subject'); $plainBody = (string) $this->request->getPost('body_html'); // plain text by design $code = (string) $this->request->getPost('code'); $variant = (string) ($this->request->getPost('variant') ?? 'default'); $returnUrl = (string) ($this->request->getPost('return_url') ?? site_url('attendance/violations')); // Normalize incident date strictly to YYYY-MM-DD; do NOT invent "today" $ymdRaw = (string) ($this->request->getPost('incident_date') ?? ''); $ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : ''; // '' means unknown // Treat body_html as HTML when it contains tags; otherwise convert plain text to basic HTML // This prevents recipients from seeing raw tags when composing with the rich-text editor. if ($plainBody !== '' && preg_match('/<[^>]+>/', $plainBody)) { $safeHtmlBody = $plainBody; // HTML already } elseif ($plainBody !== '' && stripos($plainBody, '<') !== false) { // Hidden field might contain escaped HTML if JS didn’t run; decode and re-check $decoded = html_entity_decode($plainBody, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $safeHtmlBody = preg_match('/<[^>]+>/', $decoded) ? $decoded : nl2br(esc($plainBody), false); } else { // Plain text fallback: escape + line breaks $safeHtmlBody = nl2br(esc($plainBody), false); } // Wrap with your email layout $wrapped = $this->renderWithEmailLayout($subject, $safeHtmlBody); // Resolve term (prefer controller properties if set) $semester = $this->semester ?? (new \App\Models\ConfigurationModel())->getConfig('semester'); $schoolYear = $this->schoolYear ?? (new \App\Models\ConfigurationModel())->getConfig('school_year'); try { // Send to primary and (if available) to second parent as separate email $mailer = new EmailController(); $anyOk = false; // Primary $ok = $mailer->sendEmail($to, $subject, $wrapped); $this->logNotification( $sid, $code, $ymd, 'email', $to, $subject, $ok ? 'sent' : 'failed', $ok ? 'OK' : 'sendEmail returned false' ); if ($ok) { $anyOk = true; } // Secondary $sec = $this->getSecondaryParentForStudent($sid); $to2 = trim((string)($sec['email'] ?? '')); if ($to2 !== '' && strcasecmp($to2, $to) !== 0) { if (! $this->notificationAlreadySent($sid, $code, $ymd, 'email', $to2)) { $ok2 = $mailer->sendEmail($to2, $subject, $wrapped); $this->logNotification( $sid, $code, $ymd, 'email', $to2, $subject, $ok2 ? 'sent' : 'failed', $ok2 ? 'OK' : 'sendEmail returned false' ); if ($ok2) { $anyOk = true; } } } if (! $anyOk) { return redirect()->back()->withInput()->with('error', 'Failed to send email.'); } // === Post-send bookkeeping === // 1) TRACKING TABLE: mark notified + bump counter $this->attendanceTrackingModel->markRuleAsNotified( $sid, $code, $ymd !== '' ? $ymd : date('Y-m-d'), // if not supplied, use today as a fallback for the tracking row only $semester, $schoolYear ); // 2) ATTENDANCE DATA: mark all dates in the violation as reported + notified $violationDates = $this->getViolationDatesForStudent($sid, $code, $ymd); $this->attendanceDataModel->markReportedAndNotified($sid, $violationDates, $semester, $schoolYear); // Queue comment modal on the violations page try { $stu = $this->studentModel->find($sid) ?: []; $stuName = trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? '')); session()->set('open_comment_modal', [ 'student_id' => $sid, 'student_name' => $stuName, 'code' => $code, 'incident_date'=> $ymd !== '' ? $ymd : date('Y-m-d'), 'to' => $to, 'subject' => $subject, 'variant' => $variant, ]); } catch (\Throwable $e) { log_message('debug', 'Could not set open_comment_modal: ' . $e->getMessage()); } $msg = 'Email sent to ' . esc($to); if (!empty($to2) && strcasecmp($to2, $to) !== 0) { $msg .= ' (CC ' . esc($to2) . ')'; } return redirect()->to($returnUrl)->with('message', $msg); } catch (\Throwable $e) { $this->logNotification($sid, $code, $ymd, 'email', $to, $subject, 'failed', $e->getMessage()); return redirect()->back()->withInput()->with('error', 'Error: ' . $e->getMessage()); } } /** * Convert user-entered plain text to safe HTML. * - Escapes all HTML * - Splits on blank lines into paragraphs
* - Converts single newlines to
* - Auto-links http/https URLs
*/
private function plainTextToHtml(string $text): string
{
// Normalize line endings and trim
$text = preg_replace("/\r\n?/", "\n", trim($text));
if ($text === '') {
return '
' . $p . '
'; }, $paragraphs); return implode("\n", $paragraphs); } private function renderAutoEmailFor(array $v): ?array { // $v is each element in $toSend (from your sendAutoEmails) $code = (string)($v['violation_code'] ?? $v['code'] ?? ''); $sid = (int)($v['id'] ?? 0); $p = $this->getPrimaryParentForStudent($sid); $ctx = $this->buildTemplateContext($v, $p); $tpl = $this->renderTemplate($code, 'default', $ctx); if (!$tpl) { // fallback generic $subject = ($code === 'ABS_1') ? 'Attendance Notice: Unreported Absence' : (($code === 'LATE_2') ? 'Attendance Notice: Repeated Lateness' : 'Attendance Notice'); $html = "Dear " . esc($p['parent_name'] ?? 'Parent/Guardian') . ",
" . "We'd like to inform you about your student's recent attendance:
" . "If you have any questions, please contact the school office.
"; return [$subject, $html]; } return $tpl; } public function parentsInfo() { $studentId = (int)($this->request->getGet('student_id') ?? 0); if ($studentId <= 0) { return $this->response->setStatusCode(422)->setJSON(['error' => 'Missing student_id']); } try { $stu = $this->db->table('students') ->select('id, parent_id, school_year, semester') ->where('id', $studentId) ->get()->getRowArray(); if (!$stu) { return $this->response->setStatusCode(404)->setJSON(['error' => 'Student not found']); } $primaryId = (int)$stu['parent_id']; $schoolYear = $stu['school_year'] ?: ($this->schoolYear ?? null); $u1 = $this->db->table('users') ->select('id, firstname, lastname, email, cellphone') ->where('id', $primaryId) ->get()->getRowArray(); $primary = $u1 ? [ 'id' => (int)$u1['id'], 'firstname' => (string)$u1['firstname'], 'lastname' => (string)$u1['lastname'], 'email' => (string)$u1['email'], 'cellphone' => (string)$u1['cellphone'], ] : null; $pb = $this->db->table('parents') ->where('firstparent_id', $primaryId); if (!empty($schoolYear)) { $pb->where('school_year', $schoolYear); } $pRow = $pb->orderBy('updated_at', 'DESC')->limit(1)->get()->getRowArray(); $secondary = null; if ($pRow) { $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) { $secondary = [ 'id' => (int)$u2['id'], 'firstname' => (string)$u2['firstname'], 'lastname' => (string)$u2['lastname'], 'email' => (string)$u2['email'], 'cellphone' => (string)$u2['cellphone'], ]; } } if (!$secondary) { $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) { $secondary = [ 'id' => $secondId ?: null, 'firstname' => $fallbackFirst, 'lastname' => $fallbackLast, 'email' => $fallbackEmail, 'cellphone' => $fallbackPhone, ]; } } } return $this->response->setJSON([ 'primary' => $primary, 'secondary' => $secondary, ]); } catch (\Throwable $e) { log_message('error', 'parentsInfo() failed: ' . $e->getMessage()); return $this->response->setStatusCode(500)->setJSON(['error' => 'Server error']); } } /** * Collect all dates involved in a violation for a student. * Uses provided incident date and recent attendance rows to gather unreported absences/lates. */ private function getViolationDatesForStudent(int $studentId, string $code, ?string $incidentDate = null): array { $code = strtoupper(trim($code)); $statuses = ['absent', 'late']; if (str_starts_with($code, 'ABS')) { $statuses = ['absent']; } elseif (str_starts_with($code, 'LATE')) { $statuses = ['late']; } elseif (str_starts_with($code, 'MIX')) { $statuses = ['absent', 'late']; } return $this->attendanceDataModel->getRecentUnreportedDates( $studentId, $statuses, $incidentDate, 35, $this->semester, $this->schoolYear ); } /** * Save a comment/note after notifying a parent about an attendance case. * Posts from the modal in violations_pending view. */ public function saveNotificationNote() { if (! $this->request->is('post')) { return redirect()->to(site_url('attendance/violations')); } $rules = [ 'student_id' => 'required|is_natural_no_zero', 'code' => 'required|string', 'note' => 'required|string', 'incident_date' => 'permit_empty|valid_date[Y-m-d]', 'return_url' => 'permit_empty|string', 'to' => 'permit_empty|valid_email', 'subject' => 'permit_empty|string', 'variant' => 'permit_empty|string', ]; if (! $this->validate($rules)) { return redirect()->back()->withInput()->with('error', implode(' ', $this->validator->getErrors())); } $sid = (int) $this->request->getPost('student_id'); $code = (string) $this->request->getPost('code'); $note = trim((string) $this->request->getPost('note')); $ymdRaw = (string) ($this->request->getPost('incident_date') ?? ''); $ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : ''; $returnUrl = (string) ($this->request->getPost('return_url') ?? site_url('attendance/violations')); // Find corresponding tracking row for that student/day (prefer matching reason/code) try { $day = $ymd !== '' ? $ymd : date('Y-m-d'); [$start, $end] = $this->dayBounds($day); $qb = $this->attendanceTrackingModel ->where('student_id', $sid) ->where('semester', $this->semester) ->where('school_year', $this->schoolYear) ->where('date >=', $start) ->where('date <', $end) ->orderBy('date', 'DESC'); // Prefer a row whose reason contains the code $row = $this->attendanceTrackingModel ->where('student_id', $sid) ->where('semester', $this->semester) ->where('school_year', $this->schoolYear) ->where('date >=', $start) ->where('date <', $end) ->like('reason', $code, 'both') ->orderBy('date', 'DESC') ->first(); if (! $row) { $row = $qb->first(); } if ($row && !empty($row['id'])) { $updates = [ 'note' => $note, 'is_notified' => 1, 'updated_at' => date('Y-m-d H:i:s'), ]; // Ensure counter exists $this->attendanceTrackingModel->builder() ->where('id', (int)$row['id']) ->set('notif_counter', 'COALESCE(notif_counter,0)+1', false) ->update(); $this->attendanceTrackingModel->update((int)$row['id'], $updates); } else { // No row exists for that day — create a minimal one to hold the note $this->attendanceTrackingModel->insert([ 'student_id' => $sid, 'date' => $start, 'is_reported' => 0, 'reason' => $code, 'is_notified' => 1, 'notif_counter' => 1, 'semester' => $this->semester, 'school_year' => $this->schoolYear, 'note' => $note, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]); } // One-time modal context — clear so it won't re-open on refresh try { session()->remove('open_comment_modal'); } catch (\Throwable $e) {} return redirect()->to($returnUrl)->with('message', 'Note saved.'); } catch (\Throwable $e) { return redirect()->back()->withInput()->with('error', 'Failed to save note: ' . $e->getMessage()); } } }