Files
alrahma_sunday_school/app/Controllers/View/AttendanceTrackingController.php
T
2026-05-09 15:51:34 -04:00

2542 lines
106 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\AttendanceTrackingModel;
use App\Models\StudentClassModel;
use App\Models\StudentModel;
use App\Models\ConfigurationModel;
use App\Models\AttendanceDataModel;
use CodeIgniter\Events\Events;
use App\Models\ParentNotificationModel;
use App\Models\AttendanceEmailTemplateModel;
class AttendanceTrackingController extends BaseController
{
protected $attendanceEmailTemplateModel;
protected $attendanceDataModel;
protected $notificationModel;
protected $studentModel;
protected $semester;
protected $schoolYear;
protected $studentClassModel;
protected $attendanceTrackingModel;
protected $configModel;
protected $email;
protected $db;
protected array $debugCompute = [];
protected ?array $attendanceReportedColumns = null;
public function __construct()
{
$this->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 = "<p>Dear " . ($pname ?: 'Parent/Guardian') . "</p>"
. "<p>We'd like to inform you about your student's recent attendance:</p>"
. "<ul>"
. "<li><strong>Student:</strong> " . $studentName . "</li>"
. "<li><strong>Issue:</strong> " . (string)($v['violation'] ?? '') . "</li>"
. "<li><strong>As of:</strong> " . $date . "</li>"
. "</ul>"
. "<p>If you have any questions, please contact the school office.</p>";
} 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; // dont 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 = "<p>Insha Allah this email finds you well";
if ($variant === 'answered') {
$intro .= ", <strong>{$parentName}</strong>. Jazakum Allahu khayran for taking the time to speak with us today.</p>";
} elseif ($variant === 'no_answer') {
$intro .= ".</p><p>We tried to reach you but could not connect and left a voice message at <strong>{$voicemail}</strong>.</p>";
} else {
$intro .= ".</p>";
}
$details = match (true) {
str_starts_with($code, 'ABS_1')
=> "<p>This is to let you know that <strong>{$studentName}</strong> was absent on <strong>{$incident}</strong> without prior notice.</p>",
str_starts_with($code, 'ABS_2')
=> "<p>This is a reminder that <strong>{$studentName}</strong> has had repeated absences, most recently on <strong>{$incident}</strong>, without prior notice.</p>",
str_starts_with($code, 'ABS_3')
=> "<p>This is a reminder that <strong>{$studentName}</strong> has been absent three times, most recently on <strong>{$incident}</strong>, without prior notice.</p>",
str_starts_with($code, 'LATE_2')
=> "<p>This is a reminder that <strong>{$studentName}</strong> has been late multiple times, most recently on <strong>{$incident}</strong>.</p>",
str_starts_with($code, 'LATE_3'),
str_starts_with($code, 'LATE_4')
=> "<p>This is a follow-up that <strong>{$studentName}</strong> has had repeated lateness concerns, most recently on <strong>{$incident}</strong>.</p>",
str_starts_with($code, 'MIX')
=> "<p>This is a follow-up that <strong>{$studentName}</strong> has had a mix of repeated lateness and absence concerns, most recently on <strong>{$incident}</strong>.</p>",
default
=> "<p>We'd like to inform you about <strong>{$studentName}</strong>'s recent attendance concern dated <strong>{$incident}</strong>.</p>",
};
$closing = "<p>If your child will be absent or late due to illness or family commitments, please let us know ahead of time.</p>"
. "<p>You can email/call/text us at <strong>{$phone}</strong>.</p>";
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, '&lt;') !== false) {
// Hidden field might contain escaped HTML if JS didnt 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 <p> paragraphs
* - Converts single newlines to <br>
* - 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></p>';
}
// Escape everything first
$escaped = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// Auto-link URLs (http/https)
$escaped = preg_replace_callback(
'#\bhttps?://[^\s<]+#i',
static function ($m) {
$url = $m[0];
$urlAttr = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
return '<a href="' . $urlAttr . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
},
$escaped
);
// Split into paragraphs by blank lines
$paragraphs = preg_split("/\n{2,}/", $escaped);
// Turn single newlines into <br> inside each paragraph
$paragraphs = array_map(static function ($p) {
$p = preg_replace("/\n/", "<br>", $p);
return '<p>' . $p . '</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 = "<p>Dear " . esc($p['parent_name'] ?? 'Parent/Guardian') . ",</p>"
. "<p>We'd like to inform you about your student's recent attendance:</p>"
. "<ul>"
. "<li><strong>Student:</strong> " . esc((string)($v['name'] ?? '')) . "</li>"
. "<li><strong>Issue:</strong> " . esc((string)($v['violation'] ?? '')) . "</li>"
. "<li><strong>As of:</strong> " . esc((string)($v['last_date'] ?? '')) . "</li>"
. "</ul>"
. "<p>If you have any questions, please contact the school office.</p>";
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());
}
}
}