Files
alrahma_sunday_school_api/app/Services/Admin/TeacherSubmissionService.php
T
2026-03-05 12:29:37 -05:00

606 lines
22 KiB
PHP

<?php
namespace App\Services\Admin;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Throwable;
class TeacherSubmissionService
{
public function __construct(
private AdminContextService $context
) {}
public function report(): array
{
$cfg = $this->context->currentConfig();
$semester = (string) ($cfg['semester'] ?? '');
$schoolYear = (string) ($cfg['school_year'] ?? '');
$assignmentRows = DB::table('teacher_class as tc')
->select([
'tc.class_section_id',
'cs.class_section_name',
'tc.teacher_id',
'u.firstname',
'u.lastname',
'tc.position',
])
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
->leftJoin('users as u', 'u.id', '=', 'tc.teacher_id')
->where('tc.school_year', $schoolYear)
->where('tc.semester', $semester)
->orderBy('cs.class_section_name')
->get();
$teachersBySection = [];
foreach ($assignmentRows as $assignment) {
$sectionId = (int) ($assignment->class_section_id ?? 0);
if ($sectionId <= 0) {
continue;
}
$positionKey = strtolower(trim((string) ($assignment->position ?? '')));
$roleKey = $positionKey !== '' ? $positionKey : 'teacher';
$positionLabel = match ($roleKey) {
'ta' => 'TA',
'main' => 'Main',
default => $roleKey !== '' ? ucfirst($roleKey) : 'Teacher',
};
$teacherFullName = trim(
(string)($assignment->firstname ?? '') . ' ' . (string)($assignment->lastname ?? '')
);
$teacherId = (int) ($assignment->teacher_id ?? 0);
if ($teacherId <= 0 || $teacherFullName === '') {
continue;
}
if (!isset($teachersBySection[$sectionId])) {
$teachersBySection[$sectionId] = [
'class_section' => (string) ($assignment->class_section_name ?? "Section {$sectionId}"),
'teachers' => [],
];
}
$teachersBySection[$sectionId]['teachers'][] = [
'id' => $teacherId,
'label' => "{$positionLabel}: {$teacherFullName}",
'role_key' => $roleKey,
];
}
$today = Carbon::now()->format('Y-m-d');
$rows = [];
$totalStatuses = 0;
$missingItemCount = 0;
$allTeacherIds = [];
$allClassSectionIds = [];
foreach ($teachersBySection as $classSectionId => $section) {
$classSectionId = (int) $classSectionId;
if ($classSectionId <= 0) {
continue;
}
$studentIds = DB::table('student_class')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->pluck('student_id')
->map(fn ($id) => (int) $id)
->filter(fn ($id) => $id > 0)
->values()
->all();
$expected = count($studentIds);
// semester_scores
$midtermStudents = [];
$participationStudents = [];
$scoreRecords = DB::table('semester_scores')
->select('student_id', 'midterm_exam_score', 'participation_score')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
foreach ($scoreRecords as $score) {
$sid = (int) ($score->student_id ?? 0);
if ($sid <= 0 || ($expected > 0 && !in_array($sid, $studentIds, true))) {
continue;
}
$midtermValue = trim((string) ($score->midterm_exam_score ?? ''));
if ($midtermValue !== '') {
$midtermStudents[$sid] = true;
}
$participationValue = trim((string) ($score->participation_score ?? ''));
if ($participationValue !== '') {
$participationStudents[$sid] = true;
}
}
// score_comments
$midtermCommentStudents = [];
$ptapCommentStudents = [];
if (!empty($studentIds)) {
$comments = DB::table('score_comments')
->select('student_id', 'score_type', 'comment')
->whereIn('student_id', $studentIds)
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('score_type', ['midterm', 'ptap'])
->get();
foreach ($comments as $comment) {
$sid = (int) ($comment->student_id ?? 0);
if ($sid <= 0) {
continue;
}
$text = trim((string) ($comment->comment ?? ''));
if ($text === '') {
continue;
}
$type = strtolower(trim((string) ($comment->score_type ?? '')));
if ($type === 'midterm') {
$midtermCommentStudents[$sid] = true;
}
if ($type === 'ptap') {
$ptapCommentStudents[$sid] = true;
}
}
}
// attendance_days (today)
$attendanceRow = DB::table('attendance_days')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('date', $today)
->first();
$attendanceSubmitted = $attendanceRow
&& in_array(
strtolower((string) ($attendanceRow->status ?? '')),
['submitted', 'published', 'finalized'],
true
);
$teacherList = $section['teachers'] ?? [];
if (!empty($teacherList)) {
usort($teacherList, function (array $a, array $b) {
return $this->teacherRolePriority($a['role_key'] ?? 'teacher')
<=> $this->teacherRolePriority($b['role_key'] ?? 'teacher');
});
$teacherList = array_values($teacherList);
}
foreach ($teacherList as $teacherEntry) {
if (!empty($teacherEntry['id'])) {
$allTeacherIds[] = (int) $teacherEntry['id'];
}
}
$allClassSectionIds[] = $classSectionId;
$midtermScoreStatus = $this->submissionStatus(count($midtermStudents), $expected);
$midtermCommentStatus = $this->submissionStatus(count($midtermCommentStudents), $expected);
$participationStatus = $this->submissionStatus(count($participationStudents), $expected);
$ptapCommentStatus = $this->submissionStatus(count($ptapCommentStudents), $expected);
$attendanceStatus = $this->attendanceStatus($attendanceSubmitted);
// CI original intentionally only counted score/comment statuses in summary (not attendance)
$statusDetails = [
'midterm_score_status' => $midtermScoreStatus,
'midterm_comment_status' => $midtermCommentStatus,
'participation_status' => $participationStatus,
'ptap_comment_status' => $ptapCommentStatus,
];
$missingItemsForSection = $this->buildMissingItems($statusDetails);
$missingItemCount += count($missingItemsForSection);
$totalStatuses += count($statusDetails);
$rows[] = [
'class_section' => $section['class_section'] ?? "Section {$classSectionId}",
'class_section_id' => $classSectionId,
'teachers' => $teacherList,
'midterm_score_status' => $midtermScoreStatus,
'midterm_comment_status' => $midtermCommentStatus,
'participation_status' => $participationStatus,
'ptap_comment_status' => $ptapCommentStatus,
'attendance_status' => $attendanceStatus,
'missing_items' => $missingItemsForSection,
'student_count' => $expected,
];
}
$historyMap = [];
$teacherIds = array_values(array_unique($allTeacherIds));
$classSectionIds = array_values(array_unique($allClassSectionIds));
if (!empty($teacherIds) && !empty($classSectionIds) && $this->hasTable('teacher_submission_notification_history')) {
$historyRecords = DB::table('teacher_submission_notification_history as h')
->select([
'h.*',
'u.firstname',
'u.lastname',
])
->leftJoin('users as u', 'u.id', '=', 'h.admin_id')
->where('h.notification_category', 'teacher_submissions')
->whereIn('h.teacher_id', $teacherIds)
->whereIn('h.class_section_id', $classSectionIds)
->orderByDesc('h.sent_at')
->get();
foreach ($historyRecords as $record) {
$sectionId = (int) ($record->class_section_id ?? 0);
$teacherId = (int) ($record->teacher_id ?? 0);
if ($sectionId <= 0 || $teacherId <= 0) {
continue;
}
$sentAt = $record->sent_at ?? null;
$sentAtText = $sentAt ? Carbon::parse($sentAt)->format('M j, Y g:i A') : '';
$adminName = trim((string)($record->firstname ?? '') . ' ' . (string)($record->lastname ?? ''));
if ($adminName === '') {
$adminName = 'Administrator';
}
$historyMap[$sectionId][$teacherId][] = [
'sent_at_text' => $sentAtText,
'admin_name' => $adminName,
'status' => strtolower((string) ($record->status ?? 'sent')),
];
}
// keep latest 3 per section/teacher, same as CI
foreach ($historyMap as &$teachersHistory) {
foreach ($teachersHistory as &$entries) {
$entries = array_slice($entries, 0, 3);
}
unset($entries);
}
unset($teachersHistory);
}
$summary = [
'total_items' => $totalStatuses,
'missing_items' => $missingItemCount,
'submitted_items' => max(0, $totalStatuses - $missingItemCount),
'submission_percentage' => $totalStatuses > 0
? (int) round((($totalStatuses - $missingItemCount) / $totalStatuses) * 100)
: 100,
];
return [
'semester' => $semester,
'school_year' => $schoolYear,
'rows' => $rows,
'notification_history' => $historyMap,
'summary' => $summary,
];
}
/**
* API payload format expected:
* [
* 'notify' => [sectionId => [teacherId => 1, ...], ...],
* 'missing_items' => [sectionId => [teacherId => base64(json(items))]]
* ]
*/
public function sendNotifications(int $adminId, array $payload): array
{
$notify = $payload['notify'] ?? null;
if (!is_array($notify)) {
return [
'message' => 'Select at least one teacher to notify.',
'sent' => 0,
'failed' => 0,
'results' => [],
];
}
$missingItemsPayload = is_array($payload['missing_items'] ?? null) ? $payload['missing_items'] : [];
$targets = [];
foreach ($notify as $sectionIdRaw => $teachers) {
$sectionId = (int) $sectionIdRaw;
if ($sectionId <= 0 || !is_array($teachers)) {
continue;
}
foreach ($teachers as $teacherIdRaw => $value) {
$teacherId = (int) $teacherIdRaw;
if ($teacherId <= 0 || $value === null || $value === '') {
continue;
}
$targets["{$sectionId}_{$teacherId}"] = [
'class_section_id' => $sectionId,
'teacher_id' => $teacherId,
];
}
}
if (empty($targets)) {
return [
'message' => 'Select at least one teacher to notify.',
'sent' => 0,
'failed' => 0,
'results' => [],
];
}
$targets = array_values($targets);
$teacherIds = array_values(array_unique(array_map(
fn ($t) => (int) $t['teacher_id'],
$targets
)));
$classSectionIds = array_values(array_unique(array_map(
fn ($t) => (int) $t['class_section_id'],
$targets
)));
$classSectionMap = DB::table('classSection')
->select('class_section_id', 'class_section_name')
->whereIn('class_section_id', $classSectionIds)
->get()
->mapWithKeys(fn ($row) => [(int)$row->class_section_id => (string)($row->class_section_name ?? '')])
->all();
$teacherLookup = DB::table('users')
->select('id', 'firstname', 'lastname', 'email')
->whereIn('id', $teacherIds)
->get()
->keyBy(fn ($row) => (int) $row->id);
$adminUser = DB::table('users')->select('id', 'firstname', 'lastname')->where('id', $adminId)->first();
$adminName = $adminUser
? trim((string)($adminUser->firstname ?? '') . ' ' . (string)($adminUser->lastname ?? ''))
: '';
if ($adminName === '') {
$adminName = 'Administrator';
}
$cfg = $this->context->currentConfig();
$scoreUrl = rtrim((string) config('app.url'), '/') . '/';
$sentCount = 0;
$failCount = 0;
$results = [];
foreach ($targets as $target) {
$classSectionId = (int) $target['class_section_id'];
$teacherId = (int) $target['teacher_id'];
$teacher = $teacherLookup->get($teacherId);
$sectionName = $classSectionMap[$classSectionId] ?? "Section {$classSectionId}";
$teacherName = $teacher
? trim((string)($teacher->firstname ?? '') . ' ' . (string)($teacher->lastname ?? ''))
: '';
if ($teacherName === '') {
$teacherName = 'Teacher';
}
$subject = "Reminder: Complete submissions for {$sectionName}";
$missingPayload = $missingItemsPayload[$classSectionId][$teacherId] ?? '';
$missingItems = $this->parseMissingItemsPayload((string) $missingPayload);
if (!empty($missingItems)) {
$missingText = e($this->formatMissingItemsText($missingItems));
$missingNote = "<p>Outstanding items: {$missingText}.</p>";
} else {
$missingNote = "<p>Our records show no outstanding submissions for this section, but please verify if anything still needs attention.</p>";
}
$body = "<p>Dear {$teacherName},</p>"
. "<p>Administration is gently reminding you to wrap up any remaining score submissions and/or comments for {$sectionName}.</p>"
. $missingNote
. "<p>Visit <a href=\"{$scoreUrl}\">Teacher Score Submission</a> to address any remaining items.</p>"
. "<p>Thank you,<br>Al Rahma Administration</p>";
$email = (string) ($teacher->email ?? '');
$status = 'failed';
$error = null;
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
try {
Mail::html($body, function ($message) use ($email, $subject) {
$message->to($email)->subject($subject);
});
$status = 'sent';
} catch (Throwable $e) {
$status = 'failed';
$error = $e->getMessage();
}
} else {
$error = 'Missing or invalid email';
}
if ($status === 'sent') {
$sentCount++;
} else {
$failCount++;
}
if ($this->hasTable('teacher_submission_notification_history')) {
DB::table('teacher_submission_notification_history')->insert([
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'admin_id' => $adminId,
'notification_category' => 'teacher_submissions',
'message' => $this->truncateNotificationMessage($body),
'status' => $status,
'school_year' => $cfg['school_year'] ?? null,
'semester' => $cfg['semester'] ?? null,
'sent_at' => Carbon::now('UTC'),
]);
}
$results[] = [
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'teacher_name' => $teacherName,
'section_name' => $sectionName,
'email' => $email,
'status' => $status,
'missing_items' => $missingItems,
'error' => $error,
];
}
$parts = [];
if ($sentCount > 0) {
$parts[] = $sentCount . ' reminder' . ($sentCount === 1 ? '' : 's') . ' sent';
}
if ($failCount > 0) {
$parts[] = $failCount . ' reminder' . ($failCount === 1 ? '' : 's') . ' failed';
}
return [
'message' => !empty($parts) ? implode(' and ', $parts) : 'No notifications were sent.',
'sent' => $sentCount,
'failed' => $failCount,
'results' => $results,
];
}
private function submissionStatus(int $filled, int $expected): array
{
if ($expected <= 0) {
return [
'label' => 'No students',
'badge' => 'bg-secondary',
'detail' => '',
'completed' => true,
];
}
$completed = $filled >= $expected;
return [
'label' => $completed ? 'Submitted' : 'Missing',
'badge' => $completed ? 'bg-success' : 'bg-danger',
'detail' => "{$filled}/{$expected}",
'completed' => $completed,
];
}
private function attendanceStatus(bool $submitted): array
{
return [
'label' => $submitted ? 'Submitted' : 'Missing',
'badge' => $submitted ? 'bg-success' : 'bg-danger',
'completed' => $submitted,
];
}
private function buildMissingItems(array $statusMap): array
{
$labels = [
'midterm_score_status' => 'midterm scores',
'midterm_comment_status' => 'midterm comments',
'participation_status' => 'participation',
'ptap_comment_status' => 'PTAP comments',
'attendance_status' => 'attendance',
];
$items = [];
foreach ($statusMap as $key => $status) {
$completed = (bool) ($status['completed'] ?? true);
if (!$completed && isset($labels[$key])) {
$items[] = $labels[$key];
}
}
return array_values($items);
}
private function formatMissingItemsText(array $items): string
{
$items = array_values(array_unique(array_filter(array_map(
fn ($v) => trim((string)$v),
$items
))));
$count = count($items);
if ($count === 0) return '';
if ($count === 1) return $items[0];
if ($count === 2) return $items[0] . ' and ' . $items[1];
$last = array_pop($items);
return implode(', ', $items) . ' and ' . $last;
}
private function teacherRolePriority(string $roleKey): int
{
return match (strtolower($roleKey)) {
'main' => 1,
'ta' => 2,
default => 3,
};
}
private function truncateNotificationMessage(string $html, int $limit = 1000): string
{
$text = trim(strip_tags($html));
if ($text === '') {
return '';
}
return mb_strlen($text) <= $limit
? $text
: mb_substr($text, 0, $limit) . '…';
}
private function parseMissingItemsPayload(string $payload): array
{
if ($payload === '') {
return [];
}
$decodedRaw = base64_decode($payload, true);
if ($decodedRaw === false) {
return [];
}
$decoded = json_decode($decodedRaw, true);
if (!is_array($decoded)) {
return [];
}
$items = [];
foreach ($decoded as $item) {
$item = trim((string) $item);
if ($item !== '') {
$items[] = $item;
}
}
return array_values(array_unique($items));
}
private function hasTable(string $table): bool
{
try {
return DB::getSchemaBuilder()->hasTable($table);
} catch (Throwable) {
return false;
}
}
}