TIFNJK_E41221880/app/Http/Controllers/api/AttendanceController.php

1204 lines
46 KiB
PHP

<?php
namespace App\Http\Controllers\api;
use App\Enums\NotificationType;
use App\Http\Controllers\Controller;
use App\Jobs\WriteAttendanceJob;
use App\Models\AttendanceHistory;
use App\Models\AttendanceHistoryDaily;
use App\Models\Classes;
use App\Models\Notification;
use App\Models\Permission;
use App\Models\Report;
use App\Models\Schedule;
use App\Models\Student;
use App\Services\FirebaseMessagingService;
use App\Services\NotificationHelperService;
use App\Services\QrLinkService;
use Carbon\Carbon;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class AttendanceController extends Controller
{
protected FirebaseMessagingService $fcm;
protected NotificationHelperService $notificationHelper;
public $distanceMaximumTolerance; // in meters (loaded from config)
// using lat and lon from config coordinate.php
public $latOfAttendance;
public $lonOfAttendance;
protected QrLinkService $qrLinkService;
public function __construct(FirebaseMessagingService $fcm, NotificationHelperService $notificationHelper, QrLinkService $qrLinkService)
{
$this->fcm = $fcm;
$this->notificationHelper = $notificationHelper;
$this->qrLinkService = $qrLinkService;
$coordinate = config('coordinate.coordinate', '-7.604032330848524, 112.10176449791652');
[$lat, $lon] = array_map('trim', explode(',', $coordinate));
$this->latOfAttendance = (float) $lat;
$this->lonOfAttendance = (float) $lon;
// load maximum distance tolerance from config (meters)
$this->distanceMaximumTolerance = (int) config('coordinate.max_distance_tolerance', 1000);
}
public function qrAttendance(Request $request) {
/// check academic period active or not.
try {
// request input validation
$request->validate([
'id_student' => 'required|integer',
'id_class' => 'required|integer',
'qrcode' => 'required|string',
'latitude' => 'required|numeric',
'longitude' => 'required|numeric',
]);
// request data variable
// $idStudent = $request->id_student;
// $idClass = $request->id_class;
// $rawCode = $request->qrcode;
// get request based on auth user
$user = $request->user();
if (!$user) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
}
// check if user has student profile
$student = $user->student ?? null;
if (!$student) {
return response()->json(['success' => false, 'message' => 'Student profile not found for user'], 403);
}
// // use student id from raw body for testing purpose.
// $idStudent = $request->id_student;
// $student = Student::find($idStudent);
// if (!$student) {
// return response()->json(['success' => false, 'message' => 'Student not found'], 404);
// }
$idStudent = $student->id;
$idClass = $request->id_class;
$rawCode = $request->qrcode;
// Coordinate Location from device client
$lat = $request->latitude;
$lon = $request->longitude;
// Center of School Location
$schoolLat = $this->latOfAttendance;
$schoolLon = $this->lonOfAttendance;
// count distance with helper
$distance = getDistanceInMeters($lat, $lon, $schoolLat, $schoolLon);
if ($distance > $this->distanceMaximumTolerance) {
return response()->json([
'success' => false,
'message' => 'Lokasi di luar jangkauan',
], 403);
}
// log
error_log('distance: ');
error_log($distance);
// request time
// Jika client mengirimkan `date` (format ISO atau yang bisa di-parse oleh Carbon),
// gunakan itu sebagai waktu sekarang untuk keperluan testing/override.
if ($request->filled('date')) {
$now = Carbon::parse($request->input('date'), 'Asia/Jakarta');
} else {
$now = Carbon::now('Asia/Jakarta');
// testing using date: 1 April 2026 07:15 WIB (should be valid for schedule 7-8)
// $now = Carbon::parse('2026-05-18 07:15:00', 'Asia/Jakarta');
// $now = Carbon::parse('Asia/Jakarta');
}
$dayOfWeek = strtolower($now->locale('id')->dayName); // example: "senin"
// schedule search (compare with qrcode) => class of student and schedule search
$schedule = Schedule::query()
->where('id_class', $idClass)
->where('code', $rawCode)
->whereHas('academicPeriods', function ($q) {
$q->where('is_active', 1);
})
->first();
if (!$schedule) {
return response()->json([
'success' => false,
'message' => 'Jadwal tidak ditemukan / QR tidak valid'], 404);
}
// day validation
if ($schedule->day_of_week !== $dayOfWeek) {
return response()->json([
'success' => false,
'message' => 'Absensi tidak sesuai dengan jadwal'], 422);
}
// period validation using configuration that has been set
$periods = config('periods.periods'); // period mapping
$currentTime = $now->format('H:i');
$currentPeriod = null;
$validPeriod = false;
foreach (range($schedule->period_start, $schedule->period_end) as $p) {
if (
isset($periods[$p]) &&
$currentTime >= $periods[$p]['start'] &&
$currentTime < $periods[$p]['end']
) {
$validPeriod = true;
$currentPeriod = $p;
break;
}
}
if (!$validPeriod) {
return response()->json([
'success' => false,
'message' => 'jam pelajaran sudah lewat / belum dimulai'], 422);
}
$lockKey = "attendance:{$idStudent}:{$schedule->id}:" . $now->toDateString();
return Cache::lock($lockKey, 5)->block(3, function () use (
$idStudent,
$idClass,
$schedule,
$lat,
$lon,
$currentPeriod,
$now,
$lockKey
) {
Log::info('Attendance lock acquired', ['key' => $lockKey]);
error_log('Attendance lock acquired: ' . $lockKey);
$alreadyAttendance = AttendanceHistory::where('id_student', $idStudent)
->where('id_schedule', $schedule->id)
->where('attendance_date', $now->toDateString())
->first();
Log::info('Checking for existing attendance record:', [
'id_student' => $idStudent,
'id_schedule' => $schedule->id,
'date_query' => $now->toDateString(),
]);
Log::info('Query result:', [
'attendance_record' => $alreadyAttendance,
]);
if ($alreadyAttendance) {
return response()->json([
'success' => false,
'message' => 'Anda sudah absen untuk mapel ini!'
], 422);
}
// Dispatch background job to write attendance (quick response to client)
$jobData = [
'id_student' => $idStudent,
'id_schedule' => $schedule->id,
'id_class' => $idClass,
'status' => 'hadir',
'coordinate' => "$lat, $lon",
'period_number' => $currentPeriod,
'attendance_date' => $now->toDateString(),
'created_at' => $now,
];
WriteAttendanceJob::dispatch($jobData);
return response()->json([
'success' => true,
'message' => 'Absensi diterima dan sedang diproses',
'data' => [
'schedule' => $schedule,
]
], 202);
});
} catch (\Illuminate\Validation\ValidationException $e) {
// This will catch validation errors and return a 422 status
return response()->json([
'success' => false,
'message' => 'Validasi gagal',
'errors' => $e->errors()
], 422);
} catch (LockTimeoutException $e) {
return response()->json([
'success' => false,
'message' => 'Permintaan sedang diproses, silakan coba lagi sebentar',
], 429);
} catch (\Exception $e) {
// This will catch any other general exceptions
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan pada server',
'error' => $e->getMessage()
], 500);
}
}
// get attendance report from eloquent AttendanceHistoryDaily from face recognition attendance
// based on student and date.
public function dailyAttendanceReport(Request $request)
{
try {
$request->validate([
'date' => 'required|date',
]);
$user = $request->user();
$student = $user->student ?? null;
if (!$student) {
return response()->json(['success' => false, 'message' => 'Student profile not found for user'], 403);
}
$date = Carbon::parse($request->date)->toDateString();
$attendanceRecords = AttendanceHistoryDaily::query()
->where('id_student', $student->id)
->whereDate('created_at', $date)
->get()
->first();
// if record is empty, return message no attendance record for that date
if (!$attendanceRecords) {
return response()->json([
'success' => true,
'message' => 'No attendance record for this date',
'data' => null,
], 200);
}
return response()->json([
'success' => true,
'message' => 'Daily attendance report fetched',
'data' => $attendanceRecords,
], 200);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'success' => false,
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Server error',
'error' => $e->getMessage(),
], 500);
}
}
// get attendance report based on schedule, student, and date.
// expected result: List Schedule [schedule + attendance status (hadir, izin, sakit, alpa), if in schedule hasnt attendance record just return attendance status: null]
// get parameter: id_class, date, id_student (from token)
// this is for personal student attendance report, to show the attendance status for each schedule in that day.
public function report(Request $request)
{
try {
$request->validate([
'id_class' => 'required|integer',
'date' => 'required|date',
]);
$user = $request->user();
$student = $user->student ?? null;
if (!$student) {
return response()->json(['success' => false, 'message' => 'Student profile not found for user'], 403);
}
$idClass = $request->id_class;
$date = Carbon::parse($request->date)->toDateString();
$dayName = strtolower(Carbon::parse($date)->locale('id')->dayName);
$dayIndex = Carbon::parse($date)->dayOfWeekIso; // 1..7
// fetch schedules for class where academic period is active and day matches
$schedules = Schedule::query()
->where('id_class', $idClass)
->whereHas('academicPeriods', function ($q) {
$q->where('is_active', 1);
})
->where(function ($q) use ($dayName, $dayIndex) {
$q->where('day_of_week', $dayName)
->orWhere('day_of_week', (string) $dayIndex);
})
->with(['subject', 'teacher'])
->orderBy('period_start')
->get();
$result = [];
foreach ($schedules as $sch) {
$attendance = AttendanceHistory::where('id_student', $student->id)
->where('id_schedule', $sch->id)
->where('attendance_date', $date)
->first();
$result[] = [
'schedule' => $sch,
'attendance_status' => $attendance ? $attendance->status : null,
'attendance' => $attendance ?? null,
];
}
return response()->json([
'success' => true,
'message' => 'Attendance report fetched',
'data' => $result,
], 200);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'success' => false,
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Server error',
'error' => $e->getMessage(),
], 500);
}
}
// get attendance report history all student in once class filtered by class, and date now.
// expected result: List Schedule [schedule + all user in class + attendance status (hadir, izin, sakit, alpa), if in schedule hasnt attendance record, dont display the student in that schedule]
public function reportClass(Request $request)
{
try {
$request->validate([
'id_class' => 'required|integer',
'date' => 'required|date',
]);
$idClass = $request->id_class;
$date = Carbon::parse($request->date)->toDateString();
$dayName = strtolower(Carbon::parse($date)->locale('id')->dayName);
$dayIndex = Carbon::parse($date)->dayOfWeekIso; // 1..7
// schedules for class on that day and active academic period
$schedules = Schedule::query()
->where('id_class', $idClass)
->whereHas('academicPeriods', function ($q) {
$q->where('is_active', 1);
})
->where(function ($q) use ($dayName, $dayIndex) {
$q->where('day_of_week', $dayName)
->orWhere('day_of_week', (string) $dayIndex);
})
->with(['subject', 'teacher', 'class'])
->orderBy('period_start')
->get();
$data = [];
foreach ($schedules as $sch) {
// get attendance records for that schedule on the date
$attendances = AttendanceHistory::query()
->where('id_schedule', $sch->id)
->where('attendance_date', $date)
->with(['student.user'])
->get();
// if there are no attendance records, still include schedule with empty list (per request could be omitted by client)
$items = [];
foreach ($attendances as $att) {
$items[] = [
'attendance' => $att,
// 'student' => $att->student,
'status' => $att->status,
];
}
$data[] = [
'schedule' => $sch,
'attendances' => $items,
];
}
return response()->json([
'success' => true,
'message' => 'Attendance history for class fetched',
'data' => $data,
], 200);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'success' => false,
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Server error',
'error' => $e->getMessage(),
], 500);
}
}
// post permission from student using data
public function createPermission(Request $request)
{
try {
$request->validate([
'reason' => 'required|string',
'information' => 'nullable|string',
'evidence' => 'nullable|file|mimes:jpg,jpeg,png|max:2048',
'date_permission' => 'required|date',
// count days
'time_period' => 'required|integer|min:1|max:10',
]);
$user = $request->user();
$student = $user->student ?? null;
if (!$student) {
return response()->json(['success' => false, 'message' => 'Student profile not found for user'], 403);
}
$permission = new Permission();
$permission->id_student = $student->id;
$permission->reason = $request->reason;
$permission->information = $request->information;
$permission->date_permission = Carbon::parse($request->date_permission)->toDateString();
$permission->time_period = $request->time_period;
if ($request->hasFile('evidence')) {
$file = $request->file('evidence');
// filename format: {id_student}_{nama_lengkap}_timestamp.extension
$displayName = $student->name ?? ($student->user->name ?? 'student');
$safeName = str_replace(' ', '-', strtolower($displayName));
// Example: 12_budi-sudarsono_1771945730.jpg
$filename = $student->id . '_' . $safeName . '_' . time() . '.' . $file->getClientOriginalExtension();
Storage::disk('public')->putFileAs('permission/evidence', $file, $filename);
$permission->evidence = $filename;
}
$permission->save();
return response()->json([
'success' => true,
'message' => 'Permission request created successfully',
'data' => $permission,
], 201);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'success' => false,
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Server error',
'error' => $e->getMessage(),
], 500);
}
}
public function permissionReport(Request $request)
{
try {
$request->validate([
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
]);
$user = $request->user();
$student = $user->student ?? null;
if (!$student) {
return response()->json(['success' => false, 'message' => 'Student profile not found for user'], 403);
}
$startDate = Carbon::parse($request->start_date)->toDateString();
$endDate = Carbon::parse($request->end_date)->toDateString();
$permissions = Permission::query()
->where('id_student', $student->id)
->whereDate('created_at', '>=', $startDate)
->whereDate('created_at', '<=', $endDate)
->get();
if ($permissions->isEmpty()) {
return response()->json([
'success' => true,
'message' => 'No permission records for this date',
'data' => null,
], 200);
}
return response()->json([
'success' => true,
'message' => 'Permission report fetched',
'data' => $permissions,
], 200);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'success' => false,
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Server error',
'error' => $e->getMessage(),
], 500);
}
}
// get attendance in schedule per day by class id. (teacher only)
public function reportClassById(Request $request, $classId, $date)
{
try {
// check bearer token and get user
$user = $request->user();
if (!$user) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
}
$date = Carbon::parse($date)->toDateString();
$dayName = strtolower(Carbon::parse($date)->locale('id')->dayName);
$dayIndex = Carbon::parse($date)->dayOfWeekIso; // 1..7
$students = $this->getStudentsForClass((int) $classId);
$schedules = $this->getActiveSchedulesForClassByDate((int) $classId, $dayName, $dayIndex);
$data = [];
foreach ($schedules as $sch) {
$data[] = [
'schedule' => $sch,
'attendances' => $this->buildAttendanceItemsForSchedule($sch, $date, $students),
// fallback to oz ex: 2026-04-22T00:00:00Z,
'date_attendance' => Carbon::parse($date)->toDateString(),
];
}
return response()->json([
'success' => true,
'message' => 'Attendance history for class fetched',
'data' => $data,
], 200);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'success' => false,
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Server error',
'error' => $e->getMessage(),
], 500);
}
}
// daily report attendance for teacher side.
public function reportDailyAttendance(Request $request)
{
try{
// check bearer token and get user
$user = $request->user();
if (!$user) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
}
// get input request $classId, $date
$request->validate([
'id_class' => 'required|integer',
'date' => 'required|date',
]);
$classId = $request->input('id_class');
$date = Carbon::parse($request->input('date'))->toDateString();
// Get class info
$class = Classes::find($classId);
// Get all students in the class
$students = Student::query()
->where('id_class', $classId)
->orderBy('name')
->get();
// Get all attendance records for this class and date
$historyReport = AttendanceHistoryDaily::query()
->where('id_class', $classId)
->whereHas('class.schedules.academicPeriods', function ($q) {
$q->where('is_active', 1);
})
->whereDate('created_at', $date)
->with(['class', 'student'])
->get();
// Create a map of attendance records by student id
$attendanceByStudent = $historyReport->keyBy('id_student');
// Add missing students with status 'none'
foreach ($students as $student) {
if (!$attendanceByStudent->has($student->id)) {
$newRecord = new AttendanceHistoryDaily();
$newRecord->id_student = $student->id;
$newRecord->id_class = $classId;
$newRecord->status = 'none';
$newRecord->picture = null;
$newRecord->created_at = $date;
$newRecord->setRelation('student', $student);
$newRecord->setRelation('class', $class);
$historyReport->push($newRecord);
}
}
if ($historyReport->isEmpty()) {
return response()->json([
'success' => true,
'message' => 'No attendance history for this class and date',
'data' => null,
], 200);
}
return response()->json([
'success' => true,
'message' => 'Daily attendance report for class fetched',
'data' => $historyReport,
], 200);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'success' => false,
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Server error',
'error' => $e->getMessage(),
], 500);
}
}
/**
* @return \Illuminate\Support\Collection<int, \App\Models\Student>
*/
private function getStudentsForClass(int $classId)
{
return Student::query()
->where('id_class', $classId)
->with('user')
->orderBy('name')
->get();
}
/**
* @return \Illuminate\Support\Collection<int, \App\Models\Schedule>
*/
private function getActiveSchedulesForClassByDate(int $classId, string $dayName, int $dayIndex)
{
return Schedule::query()
->where('id_class', $classId)
->whereHas('academicPeriods', function ($q) {
$q->where('is_active', 1);
})
->where(function ($q) use ($dayName, $dayIndex) {
$q->where('day_of_week', $dayName)
->orWhere('day_of_week', (string) $dayIndex);
})
->with(['subject', 'teacher', 'class'])
->orderBy('period_start')
->get();
}
/**
* @param \App\Models\Schedule $schedule
* @param string $date
* @param \Illuminate\Support\Collection<int, \App\Models\Student> $students
* @return array<int, array{attendance: \App\Models\AttendanceHistory, status: mixed}>
*/
private function buildAttendanceItemsForSchedule($schedule, string $date, $students): array
{
$attendances = AttendanceHistory::query()
->where('id_schedule', $schedule->id)
->where('attendance_date', $date)
->with(['student.user'])
->get();
$attendanceByStudent = $attendances->keyBy('id_student');
$items = [];
foreach ($students as $student) {
$attendance = $attendanceByStudent->get($student->id);
if (!$attendance) {
$attendance = new AttendanceHistory([
'period_number' => $schedule->period_start,
'status' => 'null',
'coordinate' => null,
'attendance_date' => $date,
'id_student' => $student->id,
'id_schedule' => $schedule->id,
'id_class' => $schedule->id_class,
]);
$attendance->setRelation('student', $student);
}
$items[] = [
'attendance' => $attendance,
'status' => $attendance->status,
];
}
return $items;
}
// permission get by class id. (teacher only)
public function permissionReportByClass(Request $request, $classId)
{
try {
// check bearer token and get user
$user = $request->user();
if (!$user) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
}
$request->validate([
'start_date' => 'required|string',
'end_date' => 'required|string|after_or_equal:start_date',
]);
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
$permissions = Permission::query()
->whereHas('student', function ($q) use ($classId) {
$q->where('id_class', $classId);
})
->whereDate('created_at', '>=', $startDate)
->whereDate('created_at', '<=', $endDate)
->with(['student.user'])
->orderBy('created_at', 'desc')
->get();
return response()->json([
'success' => true,
'message' => 'Permission report for class fetched',
'data' => $permissions,
], 200);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Server error',
'error' => $e->getMessage(),
], 500);
}
}
// permission accept for teacher only
public function permissionAccept(Request $request)
{
try {
// check bearer token and get user
$user = $request->user();
if (!$user) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
}
$permissionId = $request->input('permission_id');
if (!$permissionId) {
return response()->json(['success' => false, 'message' => 'Permission ID is required'], 422);
}
// find permission by id
$permission = Permission::find($permissionId);
if (!$permission) {
return response()->json(['success' => false, 'message' => 'Permission not found'], 404);
}
// permission must be processed only, if already accepted or rejected, cannot be processed again.
if ($permission->status === 'diterima') {
return response()->json(['success' => false, 'message' => 'Permission already accepted'], 422);
}
if ($permission->status === 'ditolak') {
return response()->json(['success' => false, 'message' => 'Permission already rejected'], 422);
}
$student = Student::find($permission->id_student);
if (!$student) {
return response()->json(['success' => false, 'message' => 'Student not found'], 404);
}
DB::transaction(function () use ($permission, $user, $request, $student) {
// update status to accepted
$permission->status = 'diterima';
$permission->approved_by = $user->id; // set approved by teacher id
$permission->feedback = $request->input('feedback', null); // optional feedback message from teacher
$permission->save();
// * create attendance record history to related date (only fill with schedule that has active academic period, and match with date permission).
$permissionReason = $permission->reason; // contoh: 'sakit', 'izin', 'dispen', 'lainnya'
if ($permissionReason === 'lainnya') {
$permissionReason = 'izin';
}
$permissionDate = Carbon::parse($permission->date_permission)->startOfDay();
$permissionTimePeriod = (int) $permission->time_period;
$createdSchoolDays = 0;
$currentDate = $permissionDate->copy();
// Count time_period as school days; weekend days are skipped and replaced by the next weekday.
while ($createdSchoolDays < $permissionTimePeriod) {
if ($currentDate->isWeekend()) {
$currentDate->addDay();
continue;
}
$dayName = strtolower($currentDate->locale('id')->dayName);
$dayIndex = $currentDate->dayOfWeekIso; // 1..7
// search related schedule with day_of_week same and active academic period
$schedules = Schedule::query()
->where('id_class', $student->id_class)
->whereHas('academicPeriods', function ($q) {
$q->where('is_active', 1);
})
->where(function ($q) use ($dayName, $dayIndex) {
$q->where('day_of_week', $dayName)
->orWhere('day_of_week', (string) $dayIndex);
})
->get();
foreach ($schedules as $schedule) {
AttendanceHistory::updateOrCreate(
[
'id_schedule' => $schedule->id,
'id_student' => $student->id,
'attendance_date' => $currentDate->toDateString(),
],
[
'id_class' => $schedule->id_class,
'period_number' => $schedule->period_start,
'coordinate' => "$this->latOfAttendance, $this->lonOfAttendance",
'status' => $permissionReason,
]
);
}
// update attendancehistorydaily to 'izin'
AttendanceHistoryDaily::updateOrCreate(
[
'id_student' => $student->id,
'id_class' => $student->id_class,
'created_at' => $currentDate->toDateString(),
],
[
'status' => 'izin',
'picture' => '',
]
);
$createdSchoolDays++;
$currentDate->addDay();
}
});
// * notification zone: send notification to student related to the permission that has been accepted
$userStudent = $student->user;
if (!$userStudent) {
return response()->json([
'success' => false,
'message' => 'User student not found',
], 404);
}
$fcmToken = $userStudent->device_token;
$template = $this->notificationHelper->templatePermissionApproval(
$fcmToken
);
try {
$notificationResult = $this->notificationHelper->send($template);
} catch (\Exception $e) {
Log::error('Failed to send attendance violation notification', ['exception' => $e]);
$notificationResult = [
'success' => false,
'message' => 'Failed to send notification',
'error' => $e->getMessage(),
];
}
$createTemplate = $this->notificationHelper->createTemplateForStudent(
(int) $userStudent->id,
$template->title,
$template->body,
NotificationType::PermissionAccepted,
// user id of teacher, convert to int.
(int) $user->id
);
Notification::create($createTemplate->toArray());
// * end of notification zone
return response()->json([
'success' => true,
'message' => 'Izin diterima untuk siswa ' . $student->name,
'data' => [
'permission' => $permission,
'notification_result' => $notificationResult,
],
], 200);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Server error',
'error' => $e->getMessage(),
], 500);
}
}
public function permissionReject(Request $request)
{
try {
// check bearer token and get user
$user = $request->user();
if (!$user) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
}
$permissionId = $request->input('permission_id');
if (!$permissionId) {
return response()->json(['success' => false, 'message' => 'Permission ID is required'], 422);
}
// find permission by id
$permission = Permission::find($permissionId);
if (!$permission) {
return response()->json(['success' => false, 'message' => 'Permission not found'], 404);
}
// permission must be processed only, if already accepted or rejected, cannot be processed again.
if ($permission->status === 'diterima') {
return response()->json(['success' => false, 'message' => 'Permission already accepted'], 422);
}
if ($permission->status === 'ditolak') {
return response()->json(['success' => false, 'message' => 'Permission already rejected'], 422);
}
$student = Student::find($permission->id_student);
if (!$student) {
return response()->json(['success' => false, 'message' => 'Student not found'], 404);
}
DB::transaction(function () use ($permission, $user, $request) {
// update status to rejected
$permission->status = 'ditolak';
$permission->approved_by = $user->id; // set approved by teacher id
$permission->feedback = $request->input('feedback', null); // optional feedback message from teacher
$permission->save();
});
/// send notification while saving to database notification.
$fcmToken = $student->user->device_token;
$template = $this->notificationHelper->templatePermissionRejection(
$fcmToken,
$request->input('feedback', null) // optional custom message from teacher feedback
);
try {
$notificationResult = $this->notificationHelper->send($template);
} catch (\Exception $e) {
Log::error('Failed to send attendance violation notification', ['exception' => $e]);
$notificationResult = [
'success' => false,
'message' => 'Failed to send notification',
'error' => $e->getMessage(),
];
}
$createTemplate = $this->notificationHelper->createTemplateForStudent(
(int) $student->user->id,
$template->title,
$template->body,
NotificationType::PermissionRejected,
// user id of teacher, convert to int.
(int) $user->id
);
Notification::create($createTemplate->toArray());
return response()->json([
'success' => true,
'message' => 'Izin ditolak untuk siswa ' . $student->name,
'data' => [
'permission' => $permission,
'notification_result' => $notificationResult,
],
], 200);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Server error',
'error' => $e->getMessage(),
], 500);
}
}
public function reportDisrepancyStudentAttendanceByHistoryId(Request $request)
{
$user = $request->user();
if (!$user) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
}
// input validate
$request->validate([
'attendance_history_id' => 'required|integer',
'disrepancy_type' => 'nullable|string|in:terlambat,hp_tidak_tersedia,izin,pulang_awal,alasan_lain',
'description' => 'nullable|string',
]);
$attendanceHistoryId = $request->input('attendance_history_id');
$attendanceHistory = AttendanceHistory::query()
->where('id', $attendanceHistoryId)
->with(['schedule'])
->first();
if (!$attendanceHistory) {
return response()->json(['success' => false, 'message' => 'Attendance history not found'], 404);
}
// cek status attendance history if already 'invalid', cannot report disrepancy again.
if ($attendanceHistory->status === 'invalid') {
return response()->json(['success' => false, 'message' => 'Attendance history already reported as invalid'], 422);
}
/// logic disrepancy here.
DB::transaction(function () use ($request, $attendanceHistory) {
$attendanceHistory->status = 'invalid';
$attendanceHistory->save();
// create report discrepancy
$report = new Report();
$report->id_attendance_history = $attendanceHistory->id;
$report->id_student = $attendanceHistory->id_student;
$report->id_class = $attendanceHistory->id_class;
$report->student_name = $attendanceHistory->student->name ?? 'Unknown Student';
$report->reported_by = $request->user()->id;
$report->disrepancy_type = $request->input('disrepancy_type', 'alasan_lain'); // default to 'alasan_lain' if not provided
$report->description = $request->input('description', null);
$report->marked_as_resolved = false;
$report->attendance_date = $attendanceHistory->attendance_date;
$report->save();
});
/// notification zone for report disrepancy, send notification to teacher related to the schedule of attendance history that has been reported.
$userStudent = $attendanceHistory->student->user;
$fcmToken = $userStudent->device_token;
$template = $this->notificationHelper->templateAttendanceViolation(
$fcmToken,
$request->input('description', null) // optional custom message from student about the disrepancy
);
// use try catch to handle notification sending error, but still return success response for reporting disrepancy, because the main process of reporting disrepancy has been done in database transaction.
try {
$notificationResult = $this->notificationHelper->send($template);
} catch (\Exception $e) {
Log::error('Failed to send attendance violation notification', ['exception' => $e]);
$notificationResult = [
'success' => false,
'message' => 'Failed to send notification',
'error' => $e->getMessage(),
];
}
$createTemplate = $this->notificationHelper->createTemplateForStudent(
(int) $userStudent->id,
$template->title,
$template->body,
NotificationType::AttendanceViolation,
// user id of system or admin, convert to int. (for example: 0)
(int) $user->id
);
Notification::create($createTemplate->toArray());
return response()->json([
'success' => true,
'data' => [
'report' => Report::query()->where('id_attendance_history', $attendanceHistory->id)->first(),
'notification_result' => $notificationResult,
]
], 200);
}
// return json response link randomize for public qr code, access from website
// use cache 5 minutes for the link, if more than 5 minutes, the link will be expired and generate new link.
public function generateLinkQrAttendance(Request $request, $idSchedule)
{
try {
if (!$request->user()) {
return response()->json([
'success' => false,
'message' => 'Unauthorized'
], 401);
}
$result = $this->qrLinkService->generateLink($idSchedule);
return response()->json($result);
} catch (\Throwable $th) {
return response()->json([
'success' => false,
'message' => 'Server error',
'error' => $th->getMessage()
], 500);
}
}
}