Files
alrahma_sunday_school_api/app/Http/Controllers/Api/AttendanceTrackingController.php
T
2026-03-05 12:29:37 -05:00

1283 lines
52 KiB
PHP
Executable File

<?php
namespace App\Http\Controllers\Api;
use App\Controllers\View\EmailController;
use App\Models\AttendanceData as AttendanceData;
use App\Models\AttendanceTracking;
use App\Models\Calendar;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\ParentNotification;
use App\Models\StudentClass;
use App\Models\Student;
use App\Models\User;
use App\Models\CiDatabase;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
class AttendanceTrackingController extends BaseApiController
{
protected AttendanceTracking $attendanceTracking;
protected AttendanceData $attendanceData;
protected ParentNotification $notification;
protected Student $student;
protected StudentClass $studentClass;
protected Configuration $config;
protected ClassSection $classSection;
protected User $user;
protected Calendar $calendar;
protected CiDatabase $db;
protected string $semester;
protected string $schoolYear;
public function __construct()
{
parent::__construct();
$this->attendanceTracking = model(AttendanceTracking::class);
$this->attendanceData = model(AttendanceData::class);
$this->notification = model(ParentNotification::class);
$this->student = model(Student::class);
$this->studentClass = model(StudentClass::class);
$this->config = model(Configuration::class);
$this->classSection = model(ClassSection::class);
$this->user = model(User::class);
$this->calendar = model(Calendar::class);
$this->db = CiDatabase::instance();
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
}
public function pendingViolations(): JsonResponse
{
try {
$schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear);
$semester = (string) ($this->request->getGet('semester') ?? $this->semester);
$classStudents = $this->studentClass
->where('school_year', $schoolYear)
->findAll();
if (empty($classStudents)) {
return $this->success([
'students' => [],
'school_year' => $schoolYear,
'semester' => $semester,
], 'No students for the selected school year.');
}
$studentIds = array_values(array_unique(array_map('intval', array_column($classStudents, 'student_id'))));
if (empty($studentIds)) {
return $this->success([
'students' => [],
'school_year' => $schoolYear,
'semester' => $semester,
], 'No student IDs found for the selected school year.');
}
$students = $this->student->whereIn('id', $studentIds)->findAll();
$today = new \DateTime('today');
$startDateStr = (clone $today)->modify('-5 weeks')->format('Y-m-d');
$endDateStr = $today->format('Y-m-d');
$start = new \DateTime($startDateStr . ' 00:00:00');
$endEx = (new \DateTime($endDateStr . ' 00:00:00'))->modify('+1 day');
$qb = $this->db->table('attendance_data');
$qb->select('id, student_id, class_id, class_section_id, date, status, reason, school_year, semester, is_reported, is_notified')
->where('school_year', $schoolYear);
if (!empty($semester)) {
$qb->where('semester', $semester);
}
$qb->whereIn('student_id', $studentIds)
->groupStart()
->where('is_reported', 'no')
->orWhere('is_reported', 0)
->orWhere('is_reported', '0')
->orWhere('is_reported', null)
->groupEnd()
->groupStart()
->where('is_notified', 'no')
->orWhere('is_notified', 0)
->orWhere('is_notified', '0')
->orWhere('is_notified', null)
->groupEnd()
->groupStart()
->where("UPPER(status) IN ('A','L','ABS','ABSENT','LATE')", null, false)
->orWhere("UPPER(status) LIKE 'ABS%'", null, false)
->orWhere("UPPER(status) LIKE 'LATE%'", null, false)
->groupEnd()
->where('date >=', $start->format('Y-m-d'))
->where('date <', $endEx->format('Y-m-d'))
->orderBy('date', 'DESC');
$termRows = $qb->get()->getResultArray();
if (empty($termRows)) {
return $this->success([
'students' => [],
'school_year' => $schoolYear,
'semester' => $semester,
], 'No pending attendance violations.');
}
$attendanceRows = [];
foreach ($termRows as $row) {
$dStr = $row['date'] ?? null;
if (!$dStr) {
continue;
}
try {
$d = new \DateTime($dStr);
} catch (\Throwable $e) {
continue;
}
if ($d < $start || $d >= $endEx) {
continue;
}
$row['status'] = strtolower((string) ($row['status'] ?? ''));
$row['date'] = substr($d->format('Y-m-d H:i:s'), 0, 10);
$attendanceRows[] = $row;
}
if (empty($attendanceRows)) {
return $this->success([
'students' => [],
'school_year' => $schoolYear,
'semester' => $semester,
], 'No pending attendance violations.');
}
$violations = $this->computeViolations($students, $attendanceRows);
return $this->success([
'students' => $violations,
'school_year' => $schoolYear,
'semester' => $semester,
], 'Pending attendance violations retrieved');
} catch (\Throwable $e) {
Log::error('pendingViolations error: ' . $e->getMessage());
return $this->respondError('Unable to fetch pending violations', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function notifiedViolations(): JsonResponse
{
try {
$schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear);
$semester = (string) ($this->request->getGet('semester') ?? $this->semester);
$trackingData = $this->attendanceTracking
->where('school_year', $schoolYear)
->where('semester', $semester)
->where('is_notified', '>', 0)
->orderBy('date', 'DESC')
->findAll();
if (empty($trackingData)) {
return $this->success([
'students' => [],
'school_year' => $schoolYear,
'semester' => $semester,
], 'No notified violations.');
}
$studentIds = array_unique(array_column($trackingData, 'student_id'));
$students = $this->student->whereIn('id', $studentIds)->findAll();
$studentMap = [];
foreach ($students as $stu) {
$studentMap[$stu['id']] = $stu;
}
$classByStudent = $this->fetchClassInfoByStudent($studentIds, $schoolYear);
$mergedData = [];
foreach ($trackingData as $row) {
$sid = (int) ($row['student_id'] ?? 0);
$merged = $row;
if (isset($studentMap[$sid])) {
$merged = array_merge($studentMap[$sid], $row);
}
if (isset($classByStudent[$sid])) {
$merged['class_section_name'] = (string) ($classByStudent[$sid]['class_section_name'] ?? '');
$merged['class_name'] = (string) ($classByStudent[$sid]['class_name'] ?? '');
}
$parent = $this->getPrimaryParentForStudent($sid);
if ($parent) {
$merged['parent_name'] = $parent['parent_name'] ?? '';
$merged['parent_email'] = $parent['email'] ?? '';
$merged['parent_phone'] = $parent['phone'] ?? '';
}
$mergedData[] = $merged;
}
return $this->success([
'students' => $mergedData,
'school_year' => $schoolYear,
'semester' => $semester,
], 'Notified attendance violations retrieved');
} catch (\Throwable $e) {
Log::error('notifiedViolations error: ' . $e->getMessage());
return $this->respondError('Unable to fetch notified violations', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function recordNotification(): JsonResponse
{
$rules = [
'student_id' => 'required|integer',
'date' => 'required|valid_date[Y-m-d]',
'parent_email' => 'permit_empty|valid_email',
'parent_name' => 'permit_empty|string',
'subject_type' => 'permit_empty|string',
'semester' => 'permit_empty|string',
'school_year' => 'permit_empty|string',
];
$payload = $this->payloadData();
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
try {
$studentId = (int) $payload['student_id'];
$ymd = substr((string) $payload['date'], 0, 10);
$semester = $payload['semester'] ?? $this->semester;
$schoolYear= $payload['school_year'] ?? $this->schoolYear;
$subject = $payload['subject_type'] ?? 'Absent';
$student = $this->student->find($studentId) ?? [];
$parentEmail = $payload['parent_email'] ?? '';
$parentName = $payload['parent_name'] ?? '';
if ($parentEmail === '') {
$primary = $this->getPrimaryParentForStudent($studentId) ?? [];
$parentEmail = $primary['email'] ?? '';
$parentName = $parentName ?: ($primary['parent_name'] ?? '');
}
if ($parentEmail === '') {
return $this->respondError('No parent email found for this student.', Response::HTTP_BAD_REQUEST);
}
$tracking = $this->attendanceTracking;
[$start, $end] = $this->dayBounds($ymd);
$row = $tracking->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('date', '>=', $start)
->where('date', '<', $end)
->orderBy('date', 'DESC')
->first();
$consequence = $this->determineConsequence($row['reason'] ?? '');
$class = $this->studentClass->getClassSectionsByStudentId($studentId, $schoolYear);
$payloadEvent = [
'student_id' => $studentId,
'student' => $student,
'parent' => ['email' => $parentEmail, 'name' => $parentName],
'semester' => $semester,
'school_year' => $schoolYear,
'class' => $class,
'now' => utc_now(),
'date' => $ymd,
'subject' => $subject,
];
$eventName = match ($consequence) {
'dismissal' => 'attendance.dismissal',
'final_warning' => 'attendance.final_warning',
default => 'attendance.follow_up',
};
Event::dispatch($eventName, [$payloadEvent]);
if ($row && !empty($row['id'])) {
$tracking->markAsNotified((int) $row['id']);
}
$this->attendanceData->markRuleAsNotifiedData($studentId, $ymd, $semester, $schoolYear);
return $this->success(['message' => 'Notification queued.']);
} catch (\Throwable $e) {
Log::error('recordNotification error: ' . $e->getMessage());
return $this->respondError('Failed to queue notification.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function sendAutoEmails(): JsonResponse
{
if (!$this->request->is('post')) {
return $this->respondError('Method not allowed', Response::HTTP_METHOD_NOT_ALLOWED);
}
try {
$classStudents = $this->studentClass
->where('school_year', $this->schoolYear)
->findAll();
if (empty($classStudents)) {
return $this->success([
'sent' => 0,
'skipped' => 0,
'errors' => 0,
'details' => [],
], 'No students in current school year.');
}
$studentIds = array_column($classStudents, 'student_id');
$students = $this->student->whereIn('id', $studentIds)->findAll();
$attendanceData = $this->attendanceData
->whereIn('student_id', $studentIds)
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->groupStart()
->where('is_reported', 'no')
->orWhere('is_reported', 0)
->orWhere('is_reported', '0')
->orWhere('is_reported', null)
->groupEnd()
->where("LOWER(status) IN ('absent','late')", null, false)
->orderBy('date', 'DESC')
->findAll();
$violations = $this->computeViolations($students, $attendanceData);
$toSend = array_values(array_filter($violations, fn($v) => ($v['action'] ?? '') === 'auto_email'));
$summary = $this->processEmailQueue($toSend, 'default');
return $this->success($summary, 'Auto emails processed');
} catch (\Throwable $e) {
Log::error('sendAutoEmails error: ' . $e->getMessage());
return $this->respondError('Failed to send auto emails', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function composeEmail(): JsonResponse
{
try {
$studentId = (int) ($this->request->get('student_id') ?? 0);
$code = (string) ($this->request->get('code') ?? 'ABS_1');
$variant = (string) ($this->request->get('variant') ?? 'default');
$incident = (string) ($this->request->get('incident_date') ?? '');
if ($studentId <= 0) {
return $this->respondError('Missing student_id', Response::HTTP_BAD_REQUEST);
}
$student = $this->student->find($studentId) ?? [];
$parent = $this->getPrimaryParentForStudent($studentId);
$secondary = $this->getSecondaryParentForStudent($studentId);
$lastDate = preg_match('/^\d{4}-\d{2}-\d{2}$/', $incident) ? $incident : local_date(utc_now(), 'Y-m-d');
$absDates = str_starts_with($code, 'ABS_') ? $this->getRecentAbsenceDatesForStudent($studentId, $lastDate, 5) : [];
$violation = [
'id' => $studentId,
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
'last_date' => $lastDate,
'violation' => $code,
'violation_code' => $code,
'absences' => $absDates,
];
$ctx = $this->buildTemplateContext($violation, $parent);
[$subject, $body] = $this->renderTemplate($code, $variant, $ctx)
?? $this->renderFallbackTemplate($code, $ctx, $violation['name'], $lastDate, $parent['parent_name'] ?? 'Parent/Guardian');
return $this->success([
'student_id' => $studentId,
'student_name' => $violation['name'],
'primary_parent' => $parent,
'secondary_parent'=> $secondary,
'code' => $code,
'variant' => $variant,
'incident_date' => $lastDate,
'subject' => $subject,
'body_html' => $body,
], 'Email template composed');
} catch (\Throwable $e) {
Log::error('composeEmail error: ' . $e->getMessage());
return $this->respondError('Failed to compose email', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function sendEmailManual(): JsonResponse
{
if (!$this->request->is('post')) {
return $this->respondError('Method not allowed', Response::HTTP_METHOD_NOT_ALLOWED);
}
$rules = [
'student_id' => 'required|integer',
'to' => 'required|valid_email',
'subject' => 'required|string',
'body_html' => 'required|string',
'code' => 'required|string',
'variant' => 'permit_empty|string',
'incident_date' => 'permit_empty|valid_date[Y-m-d]',
];
$payload = $this->payloadData();
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
try {
$studentId = (int) $payload['student_id'];
$to = trim((string) $payload['to']);
$subject = (string) $payload['subject'];
$plainBody = (string) $payload['body_html'];
$code = (string) $payload['code'];
$variant = (string) ($payload['variant'] ?? 'default');
$ymd = isset($payload['incident_date']) ? substr((string) $payload['incident_date'], 0, 10) : '';
$safeBody = $this->normalizeBodyHtml($plainBody);
$wrapped = view('emails/_wrap_layout', [
'title' => $subject,
'body_html' => $safeBody,
], ['saveData' => false]);
$mailer = new EmailController();
$anyOk = false;
$ok = $mailer->sendEmail($to, $subject, $wrapped);
$this->logNotification($studentId, $code, $ymd, 'email', $to, $subject, $ok ? 'sent' : 'failed', $ok ? 'OK' : 'sendEmail returned false');
if ($ok) {
$anyOk = true;
}
$secondary = $this->getSecondaryParentForStudent($studentId);
$to2 = trim((string) ($secondary['email'] ?? ''));
if ($to2 !== '' && strcasecmp($to2, $to) !== 0 && !$this->notificationAlreadySent($studentId, $code, $ymd, 'email', $to2)) {
$ok2 = $mailer->sendEmail($to2, $subject, $wrapped);
$this->logNotification($studentId, $code, $ymd, 'email', $to2, $subject, $ok2 ? 'sent' : 'failed', $ok2 ? 'OK' : 'sendEmail returned false');
if ($ok2) {
$anyOk = true;
}
}
if (!$anyOk) {
return $this->respondError('Failed to send email', Response::HTTP_INTERNAL_SERVER_ERROR);
}
$incidentDate = $ymd !== '' ? $ymd : local_date(utc_now(), 'Y-m-d');
$this->attendanceTracking->markRuleAsNotified($studentId, $code, $incidentDate, $this->semester, $this->schoolYear);
if ($ymd !== '') {
$this->attendanceData->markRuleAsNotifiedData($studentId, $ymd, $this->semester, $this->schoolYear);
}
return $this->success(['message' => 'Email sent']);
} catch (\Throwable $e) {
Log::error('sendEmailManual error: ' . $e->getMessage());
return $this->respondError('Failed to send email', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function parentsInfo(): JsonResponse
{
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
if ($studentId <= 0) {
return $this->respondError('Missing student_id', Response::HTTP_BAD_REQUEST);
}
try {
$primary = $this->getPrimaryParentForStudent($studentId);
$secondary = $this->getSecondaryParentForStudent($studentId);
return $this->success([
'primary' => $primary,
'secondary' => $secondary,
]);
} catch (\Throwable $e) {
Log::error('parentsInfo error: ' . $e->getMessage());
return $this->respondError('Unable to fetch parents info', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function saveNotificationNote(): JsonResponse
{
if (!$this->request->is('post')) {
return $this->respondError('Method not allowed', Response::HTTP_METHOD_NOT_ALLOWED);
}
$rules = [
'student_id' => 'required|integer',
'code' => 'required|string',
'note' => 'required|string',
'incident_date' => 'permit_empty|valid_date[Y-m-d]',
];
$payload = $this->payloadData();
$errors = $this->validateRequest($payload, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
try {
$studentId = (int) $payload['student_id'];
$code = (string) $payload['code'];
$note = trim((string) $payload['note']);
$ymd = isset($payload['incident_date']) ? substr((string) $payload['incident_date'], 0, 10) : '';
$day = $ymd !== '' ? $ymd : local_date(utc_now(), 'Y-m-d');
[$start, $end] = $this->dayBounds($day);
$row = $this->attendanceTracking
->where('student_id', $studentId)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->where('date', '>=', $start)
->where('date', '<', $end)
->where('reason', 'like', "%{$code}%")
->orderBy('date', 'DESC')
->first();
if ($row && !empty($row['id'])) {
$this->attendanceTracking->update((int) $row['id'], [
'note' => $note,
'updated_at' => utc_now(),
]);
} else {
$this->attendanceTracking->insert([
'student_id' => $studentId,
'date' => $start,
'is_reported' => 0,
'reason' => $code,
'is_notified' => 0,
'notif_counter' => 0,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'note' => $note,
'created_at' => utc_now(),
'updated_at' => utc_now(),
]);
}
return $this->success(['message' => 'Note saved']);
} catch (\Throwable $e) {
Log::error('saveNotificationNote error: ' . $e->getMessage());
return $this->respondError('Failed to save note', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
// ----------------- Helper methods copied/adapted from legacy controller -----------------
private function notificationAlreadySent(int $studentId, string $code, string $ymd, string $channel = 'email', ?string $to = null): bool
{
try {
return $this->notification->hasSent($studentId, $code, $ymd, $channel, $to);
} catch (\Throwable $e) {
Log::debug('notificationAlreadySent(): ' . $e->getMessage());
return false;
}
}
private function logNotification(int $studentId, string $code, string $ymd, string $channel, ?string $to, string $subject, string $status, string $response = ''): void
{
try {
$where = [
'student_id' => $studentId,
'code' => $code,
'incident_date' => $ymd,
'channel' => $channel,
];
if (!empty($to)) {
$where['to_address'] = $to;
}
$existing = $this->notification->where($where)->orderBy('id', 'DESC')->first();
if ($existing && !empty($existing['id'])) {
$this->notification->update((int) $existing['id'], [
'subject' => $subject,
'status' => $status,
'response' => $response,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'updated_at' => utc_now(),
]);
} else {
$this->notification->insert([
'student_id' => $studentId,
'code' => $code,
'incident_date' => $ymd,
'channel' => $channel,
'to_address' => $to,
'subject' => $subject,
'status' => $status,
'response' => $response,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
]);
}
} catch (\Throwable $e) {
Log::debug('logNotification(): ' . $e->getMessage());
}
}
private function determineConsequence(?string $reason): ?string
{
if (!$reason) {
return null;
}
$reason = strtolower($reason);
return match (true) {
str_contains($reason, 'abs_4') || str_contains($reason, 'dismissal') => 'dismissal',
str_contains($reason, 'abs_3') || str_contains($reason, 'final_warning') => 'final_warning',
str_contains($reason, 'abs_2') || str_contains($reason, 'follow_up') => 'follow_up',
default => null,
};
}
private function processEmailQueue(array $violations, string $variant): array
{
$sent = 0;
$skipped = 0;
$errors = 0;
$details = [];
$mailer = new EmailController();
foreach ($violations as $v) {
$studentId = (int) ($v['id'] ?? 0);
$code = (string) ($v['violation_code'] ?? $v['code'] ?? '');
$date = (string) ($v['last_date'] ?? '');
$to = trim((string) ($v['parent_email'] ?? ''));
if ($studentId <= 0 || !$code || !$date || !$to) {
$errors++;
$details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'missing data'];
continue;
}
if ($this->notificationAlreadySent($studentId, $code, $date, 'email', $to)) {
$skipped++;
$details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'duplicate'];
continue;
}
$parent = $this->getPrimaryParentForStudent($studentId);
$context = $this->buildTemplateContext($v, $parent);
$tpl = $this->renderTemplate($code, $variant, $context);
if (!$tpl) {
[$subject, $body] = $this->renderFallbackTemplate($code, $context, $v['name'] ?? '', $date, $parent['parent_name'] ?? 'Parent/Guardian');
} else {
[$subject, $body] = $tpl;
}
$wrapped = view('emails/_wrap_layout', [
'title' => $subject,
'body_html' => $body,
], ['saveData' => false]);
try {
if ($mailer->sendEmail($to, $subject, $wrapped)) {
$sent++;
$this->logNotification($studentId, $code, $date, 'email', $to, $subject, 'sent', 'OK');
$details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'sent'];
$this->attendanceTracking->markRuleAsNotified($studentId, $code, $date, $this->semester, $this->schoolYear);
$this->attendanceData->markRuleAsNotifiedData($studentId, $date, $this->semester, $this->schoolYear);
} else {
$errors++;
$this->logNotification($studentId, $code, $date, 'email', $to, $subject, 'failed', 'sendEmail returned false');
$details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'failed'];
}
} catch (\Throwable $e) {
$errors++;
$this->logNotification($studentId, $code, $date, 'email', $to, $subject, 'failed', $e->getMessage());
$details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'error: ' . $e->getMessage()];
}
}
return compact('sent', 'skipped', 'errors', 'details');
}
private function renderFallbackTemplate(string $code, array $context, string $studentName, string $incident, string $parentName): array
{
$subject = match ($code) {
'ABS_4' => 'Attendance Notice: Excessive Absences',
'ABS_3' => 'Attendance Notice: Continued Absences',
'ABS_2' => 'Attendance Notice: Repeated Absences',
'ABS_1' => 'Attendance Notice: Unreported Absence',
'LATE_4'=> 'Attendance Notice: Excessive Lateness',
'LATE_3'=> 'Attendance Notice: Continued Lateness',
'LATE_2'=> 'Attendance Notice: Repeated Lateness',
default => 'Attendance Notice',
};
$datesList = $context['{{absence_dates_list_bold}}'] ?? '';
$body = '<p>Dear ' . esc($parentName) . ',</p>'
. '<p>We would like to inform you about a recent attendance concern for your student.</p>'
. '<ul>'
. '<li><strong>Student:</strong> <strong>' . esc($studentName) . '</strong></li>'
. '<li><strong>Issue:</strong> ' . esc($code) . '</li>'
. '<li><strong>As of:</strong> ' . esc($incident) . '</li>'
. '</ul>'
. ($datesList !== '' ? ('<p><strong>Absence dates:</strong></p>' . $datesList) : '')
. '<p>Please contact the school office if you have any questions.</p>';
return [$subject, $body];
}
private function normalizeBodyHtml(string $body): string
{
if ($body === '') {
return '<p></p>';
}
if (preg_match('/<[^>]+>/', $body)) {
return $body;
}
$decoded = html_entity_decode($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
if (preg_match('/<[^>]+>/', $decoded)) {
return $decoded;
}
return nl2br(esc($body), false);
}
private function fetchClassInfoByStudent(array $studentIds, string $schoolYear): array
{
if (empty($studentIds)) {
return [];
}
$rows = $this->db->table('student_class sc')
->select('sc.student_id, sc.class_section_id, cs.class_section_name, cs.class_id, c.class_name')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('classes c', 'c.id = cs.class_id', 'left')
->whereIn('sc.student_id', $studentIds)
->where('sc.school_year', $schoolYear)
->orderBy('sc.id', 'DESC')
->get()->getResultArray();
$map = [];
foreach ($rows as $row) {
$sid = (int) ($row['student_id'] ?? 0);
if ($sid > 0 && !isset($map[$sid])) {
$map[$sid] = $row;
}
}
return $map;
}
private function dayBounds(string $ymd): array
{
$d = substr($ymd, 0, 10);
return [
$d . ' 00:00:00',
date('Y-m-d', strtotime($d . ' +1 day')) . ' 00:00:00',
];
}
// The remainder of this class consists of helper methods copied (with minor adjustments)
// from the legacy AttendanceTrackingController to compute violations and build contexts.
// --- BEGIN legacy helpers ---
private function computeViolations(array $students, array $attendanceData): array
{
$byStudent = [];
foreach ($attendanceData as $r) {
$sid = (int) $r['student_id'];
$ymd = substr((string) ($r['date'] ?? ''), 0, 10);
$stat = strtolower((string) $r['status']);
if (!in_array($stat, ['absent', 'late'], true)) {
continue;
}
$byStudent[$sid][$stat][] = $ymd;
}
[$syStart, $syEnd] = $this->deriveSchoolYearBounds($this->schoolYear);
$activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $syEnd);
if (empty($activeWeekKeys)) {
return [];
}
$weekIndex = array_flip($activeWeekKeys);
$currentWeekIdx = count($activeWeekKeys) - 1;
$windowStartIdx = max(0, $currentWeekIdx - 3);
$requiredWeeksCount = $currentWeekIdx - $windowStartIdx + 1;
$keepLastWeeks = function (array $dates) use ($weekIndex, $windowStartIdx, $currentWeekIdx) {
$out = [];
foreach ($dates as $d) {
$key = date('o-\WW', strtotime($d));
if (isset($weekIndex[$key])) {
$idx = $weekIndex[$key];
if ($idx >= $windowStartIdx && $idx <= $currentWeekIdx) {
$out[] = substr((string) $d, 0, 10);
}
}
}
$out = array_values(array_unique($out));
rsort($out);
return $out;
};
$classByStudent = $this->fetchClassInfoByStudent(array_column($students, 'id'), $this->schoolYear);
$out = [];
foreach ($students as $stu) {
$sid = (int) ($stu['id'] ?? 0);
$name = trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? ''));
$absDatesAll = array_values(array_unique($byStudent[$sid]['absent'] ?? []));
$lateDatesAll = array_values(array_unique($byStudent[$sid]['late'] ?? []));
$absDates = $keepLastWeeks($absDatesAll);
$lateDates = $keepLastWeeks($lateDatesAll);
if (empty($absDates) && empty($lateDates)) {
continue;
}
$absWeekIdx = $this->datesToWeekIndices($absDates, $weekIndex);
$lateWeekIdx = $this->datesToWeekIndices($lateDates, $weekIndex);
$absenceViolation = $this->buildAbsenceViolation($absDates, $absWeekIdx, $currentWeekIdx);
$lateViolation = $this->buildLateViolation($lateDates, $lateWeekIdx, $absWeekIdx, $currentWeekIdx, $requiredWeeksCount);
$chosen = $this->chooseHigherSeverity($absenceViolation, $lateViolation);
if (!$chosen && count($absDates) === 1) {
$chosen = [
'type' => 'absence',
'code' => 'ABS_1',
'severity' => 1,
'action' => 'auto_email',
'color' => '#e6f7ff',
'title' => '1 Unreported Absence',
'description' => 'Send automated email to parent.',
'last_date' => $absDates[0],
];
}
if (!$chosen) {
continue;
}
$parent = $this->getPrimaryParentForStudent($sid);
if ($this->alreadyReported($sid, $chosen['code'], $chosen['last_date'])) {
continue;
}
if ($this->notificationAlreadySent($sid, $chosen['code'], $chosen['last_date'], 'email', $parent['email'] ?? null)) {
continue;
}
$cls = $classByStudent[$sid] ?? [];
$classLabel = (string) ($cls['class_section_name'] ?? '');
if ($classLabel === '' && !empty($cls['class_name'])) {
$classLabel = (string) $cls['class_name'];
}
$out[] = [
'id' => $sid,
'name' => $name,
'class_name' => $classLabel,
'class_section_name' => (string) ($cls['class_section_name'] ?? ''),
'class_id' => $cls['class_id'] ?? null,
'class_section_id' => $cls['class_section_id'] ?? null,
'parent_email' => $parent['email'] ?? '',
'parent_name' => $parent['parent_name'] ?? '',
'parent_phone' => $parent['phone'] ?? null,
'violation' => $chosen['title'],
'violation_code' => $chosen['code'],
'type' => $chosen['type'],
'severity' => $chosen['severity'],
'action' => $chosen['action'],
'color' => $chosen['color'],
'last_absence' => $absDates[0] ?? null,
'last_late' => $lateDates[0] ?? null,
'last_date' => $chosen['last_date'],
'absences' => $absDates,
'lates' => $lateDates,
];
}
return $out;
}
private function buildAbsenceViolation(array $absDates, array $absWeekIdx, int $currentWeekIdx): ?array
{
if (empty($absDates)) {
return null;
}
$lastAbsDate = $absDates[0];
$A2row = $this->hasNConsecutiveItems($absDates, 2, 7);
$A3row = $this->hasNConsecutiveItems($absDates, 3, 7);
$A4row = $this->hasNConsecutiveItems($absDates, 4, 7);
$A_in3w2 = $this->hasNInWActiveWeeks($absWeekIdx, 2, 3);
$A_in4w3 = $this->hasNInWActiveWeeks($absWeekIdx, 3, 4);
$A_in4w4 = $this->hasNInWActiveWeeks($absWeekIdx, 4, 4);
if ($A4row || $A_in4w4) {
return [
'type' => 'absence',
'code' => 'ABS_4',
'severity' => 4,
'action' => 'expel_notify',
'color' => '#ff4d4f',
'title' => '4 Absences (in a row or within last 4 active weeks)',
'description' => 'Notify team to inform parent about expulsion and send email.',
'last_date' => $lastAbsDate,
];
}
if ($A3row || $A_in4w3) {
return [
'type' => 'absence',
'code' => 'ABS_3',
'severity' => 3,
'action' => 'team_notify',
'color' => '#fa8c16',
'title' => '3 Absences (in a row or within last 4 active weeks)',
'description' => 'Team calls parent and sends email.',
'last_date' => $lastAbsDate,
];
}
if ($A2row || $A_in3w2) {
return [
'type' => 'absence',
'code' => 'ABS_2',
'severity' => 2,
'action' => 'team_notify',
'color' => '#fadb14',
'title' => '2 Absences (in a row or within last 3 active weeks)',
'description' => 'Team calls parent and sends email.',
'last_date' => $lastAbsDate,
];
}
return null;
}
private function buildLateViolation(array $lateDates, array $lateWeekIdx, array $absWeekIdx, int $currentWeekIdx, int $requiredWeeksCount): ?array
{
if (empty($lateDates)) {
return null;
}
$lastLateDate = $lateDates[0];
$L2row = $this->hasNConsecutiveItems($lateDates, 2, 7);
$L3row = $this->hasNConsecutiveItems($lateDates, 3, 7);
$L4row = $this->hasNConsecutiveItems($lateDates, 4, 7);
$L_in3w2 = $this->hasNInWActiveWeeks($lateWeekIdx, 2, 3);
$L_in4w3 = $this->hasNInWActiveWeeks($lateWeekIdx, 3, 4);
$lateWeeksSet = array_flip($lateWeekIdx);
$lateCoversAllWindowWeeks = false;
if ($requiredWeeksCount >= 4) {
$lateCoversAllWindowWeeks = true;
for ($i = $currentWeekIdx - 3; $i <= $currentWeekIdx; $i++) {
if ($i < 0) {
continue;
}
if (!isset($lateWeeksSet[$i])) {
$lateCoversAllWindowWeeks = false;
break;
}
}
}
if ($L4row || $lateCoversAllWindowWeeks) {
return [
'type' => 'late',
'code' => 'LATE_4',
'severity' => 4,
'action' => 'team_notify',
'color' => '#ff4d4f',
'title' => '4 Lates in a row OR in each of the last 4 active weeks',
'description' => 'Team calls parent and sends email.',
'last_date' => $lastLateDate,
];
}
$twoLatesOneAbsWithin4 = $this->twoLatesOneAbsInWWeeks($lateWeekIdx, $absWeekIdx, 4);
if ($L3row || $L_in4w3 || $twoLatesOneAbsWithin4) {
return [
'type' => $twoLatesOneAbsWithin4 ? 'mix' : 'late',
'code' => $twoLatesOneAbsWithin4 ? 'MIX_L2A1' : 'LATE_3',
'severity' => 3,
'action' => 'team_notify',
'color' => '#002766',
'title' => $twoLatesOneAbsWithin4
? '2 Lates + 1 Absence (within last 4 active weeks)'
: '3 Lates (in a row or within last 4 active weeks)',
'description' => 'Team calls parent and sends email.',
'last_date' => $lastLateDate,
];
}
if ($L2row || $L_in3w2) {
return [
'type' => 'late',
'code' => 'LATE_2',
'severity' => 1,
'action' => 'auto_email',
'color' => '#bae7ff',
'title' => '2 Lates (in a row or within last 3 active weeks)',
'description' => 'Send automated email to parent.',
'last_date' => $lastLateDate,
];
}
return null;
}
private function deriveSchoolYearBounds(string $schoolYear): array
{
if (!preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m)) {
$y = (int) local_now('Y');
$startY = (int) (local_now('n') >= 8 ? $y : $y - 1);
return [sprintf('%d-08-01', $startY), sprintf('%d-07-31', $startY + 1)];
}
$startY = (int) $m[1];
$endY = (int) $m[2];
return [sprintf('%d-08-01', $startY), sprintf('%d-07-31', $endY)];
}
private function getPrimaryParentForStudent(int $studentId): ?array
{
$row = $this->db->table('students s')
->select('u.id AS user_id, u.email, u.cellphone, CONCAT(u.firstname, " ", u.lastname) AS parent_name', false)
->join('users u', 'u.id = s.parent_id', 'left')
->where('s.id', $studentId)
->get()->getRowArray();
if (!$row || empty($row['user_id'])) {
return null;
}
return [
'user_id' => (int) $row['user_id'],
'email' => (string) ($row['email'] ?? ''),
'parent_name' => (string) ($row['parent_name'] ?? ''),
'phone' => $row['cellphone'] ?? null,
];
}
private function getSecondaryParentForStudent(int $studentId): ?array
{
try {
$stu = $this->db->table('students')
->select('id, parent_id, school_year, semester')
->where('id', $studentId)
->get()->getRowArray();
if (!$stu) {
return null;
}
$primaryId = (int) ($stu['parent_id'] ?? 0);
$schoolYear = (string) ($stu['school_year'] ?? '');
$pb = $this->db->table('parents')->where('firstparent_id', $primaryId);
if ($schoolYear !== '') {
$pb->where('school_year', $schoolYear);
}
$pRow = $pb->orderBy('updated_at', 'DESC')->limit(1)->get()->getRowArray();
if (!$pRow) {
return null;
}
$secondId = (int) ($pRow['secondparent_id'] ?? 0);
if ($secondId > 0) {
$u2 = $this->db->table('users')
->select('id, firstname, lastname, email, cellphone')
->where('id', $secondId)
->get()->getRowArray();
if ($u2 && !empty($u2['email'])) {
return [
'user_id' => (int) $u2['id'],
'email' => (string) $u2['email'],
'parent_name' => trim(($u2['firstname'] ?? '') . ' ' . ($u2['lastname'] ?? '')),
'phone' => (string) ($u2['cellphone'] ?? ''),
];
}
}
$fallbackEmail = (string) ($pRow['secondparent_email'] ?? '');
$fallbackPhone = (string) ($pRow['secondparent_phone'] ?? '');
$fallbackFirst = (string) ($pRow['secondparent_firstname'] ?? '');
$fallbackLast = (string) ($pRow['secondparent_lastname'] ?? '');
if ($fallbackEmail || $fallbackPhone || $fallbackFirst || $fallbackLast) {
return [
'user_id' => $secondId ?: null,
'email' => $fallbackEmail,
'parent_name' => trim($fallbackFirst . ' ' . $fallbackLast) ?: null,
'phone' => $fallbackPhone,
];
}
} catch (\Throwable $e) {
Log::error('getSecondaryParentForStudent() failed: ' . $e->getMessage());
}
return null;
}
private function alreadyReported(int $studentId, string $code, string $lastDate): bool
{
try {
[$start, $end] = $this->dayBounds($lastDate);
$row = $this->attendanceTracking
->where('student_id', $studentId)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->where('date', '>=', $start)
->where('date', '<', $end)
->where('reason', 'like', "%{$code}%")
->orderBy('date', 'DESC')
->first();
if (!$row) {
$row = $this->attendanceTracking
->where('student_id', $studentId)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->where('date', '>=', $start)
->where('date', '<', $end)
->orderBy('date', 'DESC')
->first();
}
return $row ? (bool) ($row['is_notified'] ?? 0) : false;
} catch (\Throwable $e) {
Log::debug('alreadyReported() error: ' . $e->getMessage());
return false;
}
}
private function getRecentAbsenceDatesForStudent(int $studentId, string $lastDateYmd, int $weeks = 5): array
{
if ($studentId <= 0) {
return [];
}
$day = substr($lastDateYmd, 0, 10);
$start = date('Y-m-d', strtotime($day . " -{$weeks} weeks")) . ' 00:00:00';
$end = date('Y-m-d', strtotime($day . ' +1 day')) . ' 00:00:00';
$qb = $this->db->table('attendance_data');
$qb->select('date')
->where('student_id', $studentId)
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->groupStart()
->where('is_reported', 'no')
->orWhere('is_reported', 0)
->orWhere('is_reported', '0')
->orWhere('is_reported', null)
->groupEnd()
->groupStart()
->where('is_notified', 'no')
->orWhere('is_notified', 0)
->orWhere('is_notified', '0')
->orWhere('is_notified', null)
->groupEnd()
->groupStart()
->where("UPPER(status) IN ('A','ABS','ABSENT')", null, false)
->orWhere("UPPER(status) LIKE 'ABS%'", null, false)
->groupEnd()
->where('date >=', $start)
->where('date <', $end)
->orderBy('date', 'DESC');
$rows = $qb->get()->getResultArray();
$ymds = [];
foreach ($rows as $r) {
$d = substr((string) ($r['date'] ?? ''), 0, 10);
if ($d !== '') {
$ymds[$d] = true;
}
}
$out = array_keys($ymds);
sort($out);
return $out;
}
private function renderTemplate(string $code, string $variant, array $context): ?array
{
if (!class_exists(\App\Models\AttendanceEmailTemplate::class)) {
return null;
}
$template = model(\App\Models\AttendanceEmailTemplate::class);
$row = $template->getTemplate($code, $variant);
if (!$row) {
$row = $template->getTemplate($code, 'default');
}
if (!$row) {
return null;
}
$subject = strtr($row['subject'], $context);
$body = strtr($row['body_html'], $context);
return [$subject, $body];
}
private function buildTemplateContext(array $v, ?array $parent = null): array
{
$studentName = (string) ($v['name'] ?? '');
$incident = (string) ($v['last_date'] ?? local_date(utc_now(), 'Y-m-d'));
$p = $parent ?: $this->getPrimaryParentForStudent((int) ($v['id'] ?? 0)) ?? [];
$absences = [];
if (!empty($v['absences']) && is_array($v['absences'])) {
$absences = array_values(array_unique(array_map(static fn($d) => substr((string) $d, 0, 10), $v['absences'])));
}
$absencesAsc = $absences;
sort($absencesAsc);
$absenceCount = count($absencesAsc);
$absenceDatesCsv = $absenceCount ? implode(', ', $absencesAsc) : '';
$absenceDatesLines = $absenceCount ? implode('<br>', $absencesAsc) : '';
$absenceDatesList = $absenceCount ? ('<ul><li>' . implode('</li><li>', $absencesAsc) . '</li></ul>') : '';
return [
'{{parent_name}}' => (string) ($p['parent_name'] ?? 'Parent/Guardian'),
'{{student_name}}' => $studentName,
'{{student_name_bold}}' => '<strong>' . esc($studentName) . '</strong>',
'{{incident_date}}' => $incident,
'{{school_phone}}' => '978-364-0219',
'{{voicemail_phone}}' => (string) ($p['phone'] ?? '—'),
'{{class_time}}' => '10:00 AM',
'{{absence_count}}' => (string) $absenceCount,
'{{absence_dates}}' => $absenceDatesCsv,
'{{absence_dates_lines}}' => $absenceDatesLines,
'{{absence_dates_list}}' => $absenceDatesList,
'{{absence_dates_bold}}' => $absenceCount ? implode(', ', array_map(static fn($d) => '<strong>' . esc($d) . '</strong>', $absencesAsc)) : '',
'{{absence_dates_lines_bold}}' => $absenceCount ? implode('<br>', array_map(static fn($d) => '<strong>' . esc($d) . '</strong>', $absencesAsc)) : '',
'{{absence_dates_list_bold}}' => $absenceCount ? ('<ul><li>' . implode('</li><li>', array_map(static fn($d) => '<strong>' . esc($d) . '</strong>', $absencesAsc)) . '</li></ul>') : '',
];
}
// --- END legacy helpers ---
}