init backend presensi

This commit is contained in:
mwpn
2026-03-05 14:37:36 +07:00
commit b4fda6b9c9
319 changed files with 27261 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\ClassModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Class CRUD API (ADMIN only).
*/
class ClassController extends BaseApiController
{
/**
* GET /api/academic/classes
* Response: id, grade, major, name, wali_user_id, wali_name, full_label (e.g. "10 IPA A")
*/
public function index(): ResponseInterface
{
$db = \Config\Database::connect();
$rows = $db->table('classes')
->select('classes.id, classes.name, classes.grade, classes.major, classes.wali_user_id, users.name AS wali_user_name')
->join('users', 'users.id = classes.wali_user_id', 'left')
->orderBy('classes.grade', 'ASC')
->orderBy('classes.major', 'ASC')
->orderBy('classes.name', 'ASC')
->get()
->getResultArray();
$data = array_map(static function ($r) {
$grade = $r['grade'] !== null ? trim((string) $r['grade']) : '';
$major = $r['major'] !== null ? trim((string) $r['major']) : '';
$name = $r['name'] !== null ? trim((string) $r['name']) : '';
$parts = array_filter([$grade, $major, $name], static fn ($v) => $v !== '');
$fullLabel = implode(' ', $parts) !== '' ? implode(' ', $parts) : (string) $r['name'];
return [
'id' => (int) $r['id'],
'grade' => $grade,
'major' => $major,
'name' => $name,
'wali_user_id' => $r['wali_user_id'] !== null ? (int) $r['wali_user_id'] : null,
'wali_name' => $r['wali_user_name'] !== null ? (string) $r['wali_user_name'] : null,
'full_label' => $fullLabel,
];
}, $rows);
return $this->successResponse($data, 'Classes');
}
/**
* POST /api/academic/classes
* Requires: grade, major, name (rombel). Optional: wali_user_id.
*/
public function create(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$data = [
'name' => $payload['name'] ?? '',
'grade' => $payload['grade'] ?? '',
'major' => $payload['major'] ?? '',
'wali_user_id' => isset($payload['wali_user_id']) && $payload['wali_user_id'] !== '' && $payload['wali_user_id'] !== null
? (int) $payload['wali_user_id']
: null,
];
$model = new ClassModel();
if (!$model->validate($data)) {
return $this->errorResponse(
implode(' ', $model->errors()),
$model->errors(),
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
$id = $model->insert($data);
if ($id === false) {
return $this->errorResponse('Gagal menyimpan kelas', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$row = $model->find($id);
$fullLabel = trim($row->grade . ' ' . $row->major . ' ' . $row->name) ?: (string) $row->name;
$out = [
'id' => (int) $row->id,
'grade' => (string) $row->grade,
'major' => (string) $row->major,
'name' => (string) $row->name,
'wali_user_id' => $row->wali_user_id !== null ? (int) $row->wali_user_id : null,
'full_label' => $fullLabel,
];
return $this->successResponse($out, 'Kelas berhasil ditambahkan', null, ResponseInterface::HTTP_CREATED);
}
/**
* PUT /api/academic/classes/{id}
*/
public function update(int $id): ResponseInterface
{
$model = new ClassModel();
$row = $model->find($id);
if (!$row) {
return $this->errorResponse('Kelas tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$payload = $this->request->getJSON(true) ?? [];
$data = [
'id' => $id,
'name' => $payload['name'] ?? $row->name,
'grade' => $payload['grade'] ?? $row->grade,
'major' => $payload['major'] ?? $row->major,
'wali_user_id' => isset($payload['wali_user_id']) && $payload['wali_user_id'] !== '' && $payload['wali_user_id'] !== null
? (int) $payload['wali_user_id']
: null,
];
if (!$model->validate($data)) {
return $this->errorResponse(
implode(' ', $model->errors()),
$model->errors(),
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
unset($data['id']);
if ($model->update($id, $data) === false) {
return $this->errorResponse('Gagal mengubah kelas', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$updated = $model->find($id);
$fullLabel = trim($updated->grade . ' ' . $updated->major . ' ' . $updated->name) ?: (string) $updated->name;
$out = [
'id' => (int) $updated->id,
'grade' => (string) $updated->grade,
'major' => (string) $updated->major,
'name' => (string) $updated->name,
'wali_user_id' => $updated->wali_user_id !== null ? (int) $updated->wali_user_id : null,
'full_label' => $fullLabel,
];
return $this->successResponse($out, 'Kelas berhasil diubah');
}
/**
* DELETE /api/academic/classes/{id}
*/
public function delete(int $id): ResponseInterface
{
$model = new ClassModel();
if (!$model->find($id)) {
return $this->errorResponse('Kelas tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
if ($model->delete($id) === false) {
return $this->errorResponse('Gagal menghapus kelas', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->successResponse(null, 'Kelas berhasil dihapus');
}
}

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\DapodikSyncJobModel;
use App\Modules\Academic\Models\DapodikRombelMappingModel;
use App\Modules\Academic\Services\DapodikSyncService;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Dapodik sync and rombel mapping API (ADMIN only).
*/
class DapodikSyncController extends BaseApiController
{
/**
* POST /api/academic/dapodik/sync/students
* Starts sync job, returns job_id immediately, processes in background (same request after response sent).
* If a students-sync job is already running, returns existing job_id (job locking).
* Body/Query: limit, max_pages (optional).
*/
public function syncStudents(): ResponseInterface
{
$body = $this->request->getJSON(true) ?? [];
$limit = (int) ($body['limit'] ?? $this->request->getGet('limit') ?? 100);
$maxPages = (int) ($body['max_pages'] ?? $this->request->getGet('max_pages') ?? 50);
$limit = max(1, min(500, $limit));
$maxPages = max(1, min(100, $maxPages));
$jobModel = new DapodikSyncJobModel();
// Job locking: if there is already a running students-sync job, reuse it.
$existing = $jobModel
->where('type', DapodikSyncJobModel::TYPE_STUDENTS)
->where('status', DapodikSyncJobModel::STATUS_RUNNING)
->orderBy('started_at', 'DESC')
->first();
if ($existing) {
return $this->successResponse([
'job_id' => (int) $existing['id'],
'total_rows' => (int) ($existing['total_rows'] ?? 0),
'status' => $existing['status'],
], 'Sync already running');
}
$service = new DapodikSyncService();
$estimate = $service->estimateTotalStudents($limit);
$totalRows = $estimate['total'];
$now = date('Y-m-d H:i:s');
$jobId = $jobModel->insert([
'type' => DapodikSyncJobModel::TYPE_STUDENTS,
'total_rows' => $totalRows,
'processed_rows'=> 0,
'status' => DapodikSyncJobModel::STATUS_RUNNING,
'message' => null,
'started_at' => $now,
'finished_at' => null,
]);
$payload = [
'job_id' => (int) $jobId,
'total_rows' => $totalRows,
'status' => 'running',
];
$this->successResponse($payload, 'Sync started')->send();
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
ignore_user_abort(true);
set_time_limit(0);
$service->syncStudentsWithJob((int) $jobId, $limit, $maxPages);
exit;
}
/**
* GET /api/academic/dapodik/sync/status/{job_id}
* Returns job progress for polling.
*/
public function status(int $jobId): ResponseInterface
{
$jobModel = new DapodikSyncJobModel();
$job = $jobModel->find($jobId);
if (! $job) {
return $this->errorResponse('Job not found', null, null, 404);
}
$total = (int) ($job['total_rows'] ?? 0);
$processed = (int) ($job['processed_rows'] ?? 0);
$percent = $total > 0 ? min(100, (int) round(($processed / $total) * 100)) : ($processed > 0 ? 1 : 0);
return $this->successResponse([
'total_rows' => $total,
'processed_rows' => $processed,
'percent' => $percent,
'status' => $job['status'],
'message' => $job['message'] ?? null,
]);
}
/**
* GET /api/academic/dapodik/rombels
* Query: unmapped_only=1 to filter.
*/
public function rombels(): ResponseInterface
{
$unmappedOnly = $this->request->getGet('unmapped_only') === '1' || $this->request->getGet('unmapped_only') === 'true';
$db = \Config\Database::connect();
$builder = $db->table('dapodik_rombel_mappings AS m')
->select('m.id, m.dapodik_rombel, m.class_id, m.last_seen_at, m.updated_at, c.grade, c.major, c.name AS class_name')
->join('classes AS c', 'c.id = m.class_id', 'left')
->orderBy('m.dapodik_rombel', 'ASC');
if ($unmappedOnly) {
$builder->where('m.class_id', null);
}
$rows = $builder->get()->getResultArray();
$data = array_map(static function ($r) {
$classLabel = null;
if ($r['class_id'] !== null && isset($r['grade'])) {
$classLabel = trim(($r['grade'] ?? '') . ' ' . ($r['major'] ?? '') . ' ' . ($r['class_name'] ?? ''));
}
return [
'id' => (int) $r['id'],
'dapodik_rombel' => (string) $r['dapodik_rombel'],
'class_id' => $r['class_id'] !== null ? (int) $r['class_id'] : null,
'class_label' => $classLabel,
'last_seen_at' => $r['last_seen_at'],
'updated_at' => $r['updated_at'],
];
}, $rows);
return $this->successResponse($data, 'Rombel mappings');
}
/**
* PUT /api/academic/dapodik/rombels/{id}
* Body: class_id (nullable)
*/
public function updateRombel(int $id): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? $this->request->getPost();
$classId = null;
if (isset($payload['class_id']) && $payload['class_id'] !== '' && $payload['class_id'] !== null) {
$classId = (int) $payload['class_id'];
$db = \Config\Database::connect();
$exists = $db->table('classes')->where('id', $classId)->countAllResults();
if ($exists < 1) {
return $this->errorResponse('class_id must exist in classes', null, null, 422);
}
}
$model = new DapodikRombelMappingModel();
$row = $model->find($id);
if (! $row) {
return $this->errorResponse('Mapping not found', null, null, 404);
}
$model->update($id, ['class_id' => $classId]);
$updated = $model->find($id);
$classLabel = null;
if ($updated['class_id']) {
$db = \Config\Database::connect();
$c = $db->table('classes')->select('grade, major, name')->where('id', $updated['class_id'])->get()->getRowArray();
if ($c) {
$classLabel = trim(($c['grade'] ?? '') . ' ' . ($c['major'] ?? '') . ' ' . ($c['name'] ?? ''));
}
}
return $this->successResponse([
'id' => (int) $updated['id'],
'dapodik_rombel' => (string) $updated['dapodik_rombel'],
'class_id' => $updated['class_id'] !== null ? (int) $updated['class_id'] : null,
'class_label' => $classLabel,
], 'Mapping updated');
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\LessonSlotModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Lesson Slot CRUD API (ADMIN only).
*/
class LessonSlotController extends BaseApiController
{
protected LessonSlotModel $model;
public function __construct()
{
$this->model = new LessonSlotModel();
}
/**
* GET /api/academic/lesson-slots
*/
public function index(): ResponseInterface
{
$slots = $this->model->orderBy('slot_number', 'ASC')->findAll();
$data = array_map([$this, 'slotToArray'], $slots);
return $this->successResponse($data, 'Lesson slots');
}
/**
* POST /api/academic/lesson-slots
*/
public function create(): ResponseInterface
{
$input = $this->request->getJSON(true) ?? [];
$data = [
'slot_number' => (int) ($input['slot_number'] ?? 0),
'start_time' => (string) ($input['start_time'] ?? ''),
'end_time' => (string) ($input['end_time'] ?? ''),
'is_active' => isset($input['is_active']) ? (int) (bool) $input['is_active'] : 1,
];
if (!$this->model->validateStartBeforeEnd($data)) {
return $this->errorResponse('End time must be after start time', null, null, 422);
}
$id = $this->model->insert($data);
if ($id === false) {
$msg = $this->model->errors()['end_time'] ?? implode(' ', $this->model->errors());
return $this->errorResponse($msg ?: 'Validation failed', null, null, 422);
}
$row = $this->model->find($id);
return $this->successResponse($this->slotToArray($row), 'Lesson slot created', null, 201);
}
/**
* PUT /api/academic/lesson-slots/{id}
*/
public function update($id): ResponseInterface
{
$id = (int) $id;
$slot = $this->model->find($id);
if (!$slot) {
return $this->errorResponse('Lesson slot not found', null, null, 404);
}
$input = $this->request->getJSON(true) ?? [];
$data = [];
if (array_key_exists('slot_number', $input)) {
$data['slot_number'] = (int) $input['slot_number'];
}
if (array_key_exists('start_time', $input)) {
$data['start_time'] = (string) $input['start_time'];
}
if (array_key_exists('end_time', $input)) {
$data['end_time'] = (string) $input['end_time'];
}
if (array_key_exists('is_active', $input)) {
$data['is_active'] = (int) (bool) $input['is_active'];
}
if ($data !== [] && !$this->model->validateStartBeforeEnd(array_merge(
['start_time' => $data['start_time'] ?? $slot->start_time, 'end_time' => $data['end_time'] ?? $slot->end_time]
))) {
return $this->errorResponse('End time must be after start time', null, null, 422);
}
$this->model->update($id, $data);
if ($this->model->errors()) {
return $this->errorResponse(implode(' ', $this->model->errors()), null, null, 422);
}
$row = $this->model->find($id);
return $this->successResponse($this->slotToArray($row), 'Lesson slot updated');
}
/**
* DELETE /api/academic/lesson-slots/{id}
*/
public function delete($id): ResponseInterface
{
$id = (int) $id;
if (!$this->model->find($id)) {
return $this->errorResponse('Lesson slot not found', null, null, 404);
}
$this->model->delete($id);
return $this->successResponse(null, 'Lesson slot deleted');
}
protected function slotToArray($slot): array
{
if (is_array($slot)) {
return [
'id' => (int) ($slot['id'] ?? 0),
'slot_number' => (int) ($slot['slot_number'] ?? 0),
'start_time' => (string) ($slot['start_time'] ?? ''),
'end_time' => (string) ($slot['end_time'] ?? ''),
];
}
return [
'id' => (int) $slot->id,
'slot_number' => (int) $slot->slot_number,
'start_time' => (string) $slot->start_time,
'end_time' => (string) $slot->end_time,
];
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\LessonSlotModel;
use App\Modules\Academic\Models\ScheduleModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Weekly Schedule Management API (ADMIN only).
*/
class ScheduleManagementController extends BaseApiController
{
protected ScheduleModel $scheduleModel;
protected LessonSlotModel $lessonSlotModel;
public function __construct()
{
$this->scheduleModel = new ScheduleModel();
$this->lessonSlotModel = new LessonSlotModel();
}
/**
* GET /api/academic/schedules/class/{classId}
* Returns weekly schedule grid: slots with days 15, each cell schedule or null.
*/
public function getByClass($classId): ResponseInterface
{
$classId = (int) $classId;
$slots = $this->lessonSlotModel->where('is_active', 1)->orderBy('slot_number', 'ASC')->findAll();
$rows = $this->scheduleModel->where('class_id', $classId)->findAll();
$bySlotDay = [];
foreach ($rows as $row) {
$slotId = $row->lesson_slot_id ?? 0;
$day = (int) $row->day_of_week;
if ($slotId && $day >= 1 && $day <= 7) {
$bySlotDay[$slotId][$day] = $this->scheduleToArray($row);
}
}
$grid = [];
foreach ($slots as $slot) {
$slotId = (int) $slot->id;
$days = [];
for ($d = 1; $d <= 5; $d++) {
$days[$d] = $bySlotDay[$slotId][$d] ?? null;
}
$grid[] = [
'lesson_slot_id' => $slotId,
'slot_number' => (int) $slot->slot_number,
'start_time' => (string) $slot->start_time,
'end_time' => (string) $slot->end_time,
'days' => $days,
];
}
return $this->successResponse($grid, 'Weekly schedule');
}
/**
* POST /api/academic/schedules/bulk-save
* Body: { class_id, schedules: [ { day_of_week, lesson_slot_id, subject_id, teacher_user_id [, room] }, ... ] }
* Deletes existing schedules for class then inserts new ones in a transaction.
*/
public function bulkSave(): ResponseInterface
{
$input = $this->request->getJSON(true) ?? [];
$classId = (int) ($input['class_id'] ?? 0);
$items = $input['schedules'] ?? [];
if ($classId <= 0) {
return $this->errorResponse('class_id is required and must be positive', null, null, 422);
}
if (!is_array($items)) {
return $this->errorResponse('schedules must be an array', null, null, 422);
}
$db = \Config\Database::connect();
$db->transStart();
try {
$this->scheduleModel->where('class_id', $classId)->delete();
$this->scheduleModel->skipValidation(true);
foreach ($items as $item) {
$day = (int) ($item['day_of_week'] ?? 0);
$slot = (int) ($item['lesson_slot_id'] ?? 0);
$subj = (int) ($item['subject_id'] ?? 0);
$teacher = (int) ($item['teacher_user_id'] ?? 0);
if ($day < 1 || $day > 7 || $slot <= 0 || $subj <= 0) {
$db->transRollback();
return $this->errorResponse('Each schedule must have day_of_week (1-7), lesson_slot_id, subject_id', null, null, 422);
}
$data = [
'class_id' => $classId,
'subject_id' => $subj,
'teacher_user_id' => $teacher ?: null,
'lesson_slot_id' => $slot,
'day_of_week' => $day,
'room' => isset($item['room']) ? (string) $item['room'] : null,
'is_active' => 1,
];
if ($this->scheduleModel->insert($data) === false) {
$db->transRollback();
return $this->errorResponse('Failed to insert schedule', null, null, 422);
}
}
$this->scheduleModel->skipValidation(false);
$db->transComplete();
} catch (\Throwable $e) {
if ($db->transStatus() === false) {
$db->transRollback();
}
return $this->errorResponse($e->getMessage(), null, null, 500);
}
if ($db->transStatus() === false) {
return $this->errorResponse('Database transaction failed', null, null, 500);
}
return $this->successResponse(null, 'Schedules saved');
}
protected function scheduleToArray($schedule): array
{
if (is_array($schedule)) {
return [
'id' => (int) ($schedule['id'] ?? 0),
'class_id' => (int) ($schedule['class_id'] ?? 0),
'subject_id' => (int) ($schedule['subject_id'] ?? 0),
'teacher_user_id' => isset($schedule['teacher_user_id']) ? (int) $schedule['teacher_user_id'] : null,
'lesson_slot_id' => (int) ($schedule['lesson_slot_id'] ?? 0),
'day_of_week' => (int) ($schedule['day_of_week'] ?? 0),
'room' => isset($schedule['room']) ? (string) $schedule['room'] : null,
];
}
return [
'id' => (int) $schedule->id,
'class_id' => (int) $schedule->class_id,
'subject_id' => (int) $schedule->subject_id,
'teacher_user_id' => $schedule->teacher_user_id !== null ? (int) $schedule->teacher_user_id : null,
'lesson_slot_id' => (int) $schedule->lesson_slot_id,
'day_of_week' => (int) $schedule->day_of_week,
'room' => $schedule->room !== null ? (string) $schedule->room : null,
];
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\StudentModel;
use App\Modules\Academic\Models\ClassModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Student CRUD API (ADMIN only).
*/
class StudentController extends BaseApiController
{
/**
* GET /api/academic/students
* Query: class_id (optional), search (optional), unmapped_only (optional), page (default 1), per_page (default 25).
*/
public function index(): ResponseInterface
{
$classId = $this->request->getGet('class_id');
$search = $this->request->getGet('search');
$search = is_string($search) ? trim($search) : '';
$unmappedOnly = $this->request->getGet('unmapped_only') === '1' || $this->request->getGet('unmapped_only') === 'true';
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = max(5, min(100, (int) ($this->request->getGet('per_page') ?? 20)));
$db = \Config\Database::connect();
$builder = $db->table('students')
->select('students.id, students.nisn, students.name, students.gender, students.class_id, students.is_active,
classes.grade, classes.major, classes.name AS class_name')
->join('classes', 'classes.id = students.class_id', 'left')
->orderBy('students.name', 'ASC');
if ($unmappedOnly) {
$builder->where('students.class_id', null);
} elseif ($classId !== null && $classId !== '') {
$builder->where('students.class_id', (int) $classId);
}
if ($search !== '') {
$builder->groupStart()
->like('students.name', $search)
->orLike('students.nisn', $search)
->groupEnd();
}
$total = $builder->countAllResults(false);
$rows = $builder->limit($perPage, ($page - 1) * $perPage)->get()->getResultArray();
$data = array_map(static function ($r) {
$grade = $r['grade'] !== null ? trim((string) $r['grade']) : '';
$major = $r['major'] !== null ? trim((string) $r['major']) : '';
$cName = $r['class_name'] !== null ? trim((string) $r['class_name']) : '';
$parts = array_filter([$grade, $major, $cName], static fn ($v) => $v !== '');
$fullLabel = $parts !== [] ? implode(' ', $parts) : null;
return [
'id' => (int) $r['id'],
'nisn' => (string) $r['nisn'],
'name' => (string) $r['name'],
'gender' => $r['gender'] !== null ? (string) $r['gender'] : null,
'class_id' => $r['class_id'] !== null ? (int) $r['class_id'] : null,
'class_label' => $fullLabel,
'is_active' => (int) ($r['is_active'] ?? 1),
];
}, $rows);
$totalPages = $total > 0 ? (int) ceil($total / $perPage) : 0;
$meta = [
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'total_pages' => $totalPages,
];
return $this->successResponse($data, 'Students', $meta);
}
/**
* POST /api/academic/students
*/
public function create(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$data = $this->payloadToStudentData($payload);
$model = new StudentModel();
if (! $model->validate($data)) {
return $this->errorResponse(
implode(' ', $model->errors()),
$model->errors(),
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
$id = $model->insert($data);
if ($id === false) {
return $this->errorResponse('Gagal menyimpan siswa', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$row = $model->find($id);
return $this->successResponse($this->rowToResponse($row), 'Siswa berhasil ditambahkan', null, ResponseInterface::HTTP_CREATED);
}
/**
* PUT /api/academic/students/{id}
*/
public function update(int $id): ResponseInterface
{
$model = new StudentModel();
$row = $model->find($id);
if (! $row) {
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$payload = $this->request->getJSON(true) ?? [];
$data = $this->payloadToStudentData($payload, $row);
if (! $model->validate(array_merge(['id' => $id], $data))) {
return $this->errorResponse(
implode(' ', $model->errors()),
$model->errors(),
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
if ($model->update($id, $data) === false) {
return $this->errorResponse('Gagal mengubah siswa', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$updated = $model->find($id);
return $this->successResponse($this->rowToResponse($updated), 'Siswa berhasil diubah');
}
/**
* DELETE /api/academic/students/{id}
*/
public function delete(int $id): ResponseInterface
{
$model = new StudentModel();
if (! $model->find($id)) {
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
if ($model->delete($id) === false) {
return $this->errorResponse('Gagal menghapus siswa', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->successResponse(null, 'Siswa berhasil dihapus');
}
private function payloadToStudentData(array $payload, $existing = null): array
{
$classId = isset($payload['class_id']) && $payload['class_id'] !== '' && $payload['class_id'] !== null
? (int) $payload['class_id']
: null;
$gender = isset($payload['gender']) && in_array($payload['gender'], ['L', 'P'], true)
? $payload['gender']
: ($existing && isset($existing->gender) ? $existing->gender : null);
$isActive = array_key_exists('is_active', $payload)
? (int) (bool) $payload['is_active']
: ($existing && isset($existing->is_active) ? (int) $existing->is_active : 1);
return [
'nisn' => trim($payload['nisn'] ?? $existing->nisn ?? ''),
'name' => trim($payload['name'] ?? $existing->name ?? ''),
'gender' => $gender,
'class_id' => $classId,
'is_active'=> $isActive,
];
}
private function rowToResponse($row): array
{
$classId = $row->class_id ?? null;
$classLabel = null;
if ($classId !== null) {
$classModel = new ClassModel();
$c = $classModel->find($classId);
if ($c) {
$classLabel = trim($c->grade . ' ' . $c->major . ' ' . $c->name) ?: (string) $c->name;
}
}
return [
'id' => (int) $row->id,
'nisn' => (string) $row->nisn,
'name' => (string) $row->name,
'gender' => $row->gender !== null ? (string) $row->gender : null,
'class_id' => $classId !== null ? (int) $classId : null,
'class_label' => $classLabel,
'is_active' => (int) ($row->is_active ?? 1),
];
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\SubjectModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Subject CRUD API (ADMIN only).
*/
class SubjectController extends BaseApiController
{
/**
* GET /api/academic/subjects
*/
public function index(): ResponseInterface
{
$model = new SubjectModel();
$rows = $model->orderBy('name', 'ASC')->findAll();
$data = array_map(static function ($s) {
return [
'id' => (int) $s->id,
'name' => (string) $s->name,
'code' => $s->code !== null ? (string) $s->code : null,
];
}, $rows);
return $this->successResponse($data, 'Subjects');
}
/**
* POST /api/academic/subjects
*/
public function create(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$data = [
'name' => trim($payload['name'] ?? ''),
'code' => isset($payload['code']) && $payload['code'] !== '' ? trim($payload['code']) : null,
];
$model = new SubjectModel();
if (!$model->validate($data)) {
return $this->errorResponse(
implode(' ', $model->errors()),
$model->errors(),
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
$id = $model->insert($data);
if ($id === false) {
return $this->errorResponse('Gagal menyimpan mata pelajaran', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$row = $model->find($id);
$out = [
'id' => (int) $row->id,
'name' => (string) $row->name,
'code' => $row->code !== null ? (string) $row->code : null,
];
return $this->successResponse($out, 'Mata pelajaran berhasil ditambahkan', null, ResponseInterface::HTTP_CREATED);
}
/**
* PUT /api/academic/subjects/{id}
*/
public function update(int $id): ResponseInterface
{
$model = new SubjectModel();
$row = $model->find($id);
if (!$row) {
return $this->errorResponse('Mata pelajaran tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$payload = $this->request->getJSON(true) ?? [];
$data = [
'id' => $id,
'name' => trim($payload['name'] ?? $row->name),
'code' => isset($payload['code']) && $payload['code'] !== '' ? trim($payload['code']) : null,
];
if (!$model->validate($data)) {
return $this->errorResponse(
implode(' ', $model->errors()),
$model->errors(),
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
unset($data['id']);
if ($model->update($id, $data) === false) {
return $this->errorResponse('Gagal mengubah mata pelajaran', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$updated = $model->find($id);
$out = [
'id' => (int) $updated->id,
'name' => (string) $updated->name,
'code' => $updated->code !== null ? (string) $updated->code : null,
];
return $this->successResponse($out, 'Mata pelajaran berhasil diubah');
}
/**
* DELETE /api/academic/subjects/{id}
*/
public function delete(int $id): ResponseInterface
{
$model = new SubjectModel();
if (!$model->find($id)) {
return $this->errorResponse('Mata pelajaran tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
if ($model->delete($id) === false) {
return $this->errorResponse('Gagal menghapus mata pelajaran', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->successResponse(null, 'Mata pelajaran berhasil dihapus');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Auth\Models\RoleModel;
use App\Modules\Auth\Models\UserModel;
use App\Modules\Auth\Models\UserRoleModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Teachers list API (ADMIN only). Returns users with role GURU_MAPEL or WALI_KELAS.
*/
class TeacherController extends BaseApiController
{
/**
* GET /api/academic/teachers
*/
public function index(): ResponseInterface
{
$roleModel = new RoleModel();
$waliRole = $roleModel->findByCode('WALI_KELAS');
$guruRole = $roleModel->findByCode('GURU_MAPEL');
if (!$waliRole || !$guruRole) {
return $this->successResponse([], 'Teachers');
}
$db = \Config\Database::connect();
$userIdList = $db->table('user_roles')
->select('user_id')
->whereIn('role_id', [$waliRole->id, $guruRole->id])
->get()
->getResultArray();
$userIdList = array_values(array_unique(array_map(static fn ($r) => (int) $r['user_id'], $userIdList)));
if ($userIdList === []) {
return $this->successResponse([], 'Teachers');
}
$userModel = new UserModel();
$userRoleModel = new UserRoleModel();
$users = $userModel->whereIn('id', $userIdList)->findAll();
$data = [];
foreach ($users as $u) {
$roleIds = $userRoleModel->getRoleIdsForUser((int) $u->id);
$roles = [];
foreach ($roleIds as $rid) {
$r = $roleModel->find($rid);
if ($r && in_array($r->role_code, ['GURU_MAPEL', 'WALI_KELAS'], true)) {
$roles[] = ['role_code' => $r->role_code, 'role_name' => $r->role_name];
}
}
$data[] = [
'id' => (int) $u->id,
'name' => (string) $u->name,
'email' => (string) $u->email,
'roles' => $roles,
];
}
usort($data, static fn ($a, $b) => strcasecmp($a['name'], $b['name']));
return $this->successResponse($data, 'Teachers');
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\TeacherSubjectModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Assign mapel per guru (ADMIN only).
*/
class TeacherSubjectController extends BaseApiController
{
/**
* GET /api/academic/teacher-subjects/{teacherId}
* Return: { teacher_user_id, subject_ids: [...] }
*/
public function getByTeacher(int $teacherId): ResponseInterface
{
$model = new TeacherSubjectModel();
$ids = $model->getSubjectIdsForTeacher($teacherId);
return $this->successResponse([
'teacher_user_id' => $teacherId,
'subject_ids' => $ids,
], 'Teacher subjects');
}
/**
* PUT /api/academic/teacher-subjects/{teacherId}
* Body: { subject_ids: [1,2,3] }
*/
public function updateForTeacher(int $teacherId): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$ids = $payload['subject_ids'] ?? [];
if (! is_array($ids)) {
return $this->errorResponse('subject_ids must be an array', null, null, 422);
}
$cleanIds = [];
foreach ($ids as $id) {
$id = (int) $id;
if ($id > 0) {
$cleanIds[$id] = true;
}
}
$subjectIds = array_keys($cleanIds);
$model = new TeacherSubjectModel();
$db = \Config\Database::connect();
$db->transStart();
try {
$model->where('teacher_user_id', $teacherId)->delete();
foreach ($subjectIds as $sid) {
$model->insert([
'teacher_user_id' => $teacherId,
'subject_id' => $sid,
]);
}
$db->transComplete();
} catch (\Throwable $e) {
$db->transRollback();
return $this->errorResponse($e->getMessage(), null, null, 500);
}
if ($db->transStatus() === false) {
return $this->errorResponse('Database transaction failed', null, null, 500);
}
return $this->successResponse([
'teacher_user_id' => $teacherId,
'subject_ids' => $subjectIds,
], 'Teacher subjects updated');
}
/**
* GET /api/academic/teacher-subjects/map
* Return: { [subject_id]: [teacher_user_id, ...], ... }
*/
public function map(): ResponseInterface
{
$model = new TeacherSubjectModel();
$map = $model->getMapSubjectToTeacherIds();
return $this->successResponse($map, 'Subject to teacher map');
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Modules\Academic\Entities;
use CodeIgniter\Entity\Entity;
/**
* Class Entity
*
* Represents a class/kelas in the system.
*/
class ClassEntity extends Entity
{
/**
* Attributes that can be mass assigned
*
* @var array<string>
*/
protected $allowedFields = [
'name',
'grade',
'major',
'wali_user_id',
];
/**
* Attributes that should be cast to specific types
*
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'wali_user_id' => '?integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Modules\Academic\Entities;
use CodeIgniter\Entity\Entity;
/**
* Lesson Slot Entity
*/
class LessonSlot extends Entity
{
protected $allowedFields = [
'slot_number',
'start_time',
'end_time',
'is_active',
];
protected $casts = [
'id' => 'integer',
'slot_number' => 'integer',
'is_active' => 'boolean',
];
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Modules\Academic\Entities;
use CodeIgniter\Entity\Entity;
/**
* Schedule Entity
*
* Represents a class schedule in the system.
*/
class Schedule extends Entity
{
/**
* Attributes that can be mass assigned
*
* @var array<string>
*/
protected $allowedFields = [
'class_id',
'subject_id',
'teacher_user_id',
'teacher_name',
'lesson_slot_id',
'day_of_week',
'start_time',
'end_time',
'room',
'is_active',
];
/**
* Attributes that should be cast to specific types
*
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'class_id' => 'integer',
'subject_id' => 'integer',
'teacher_user_id' => 'integer',
'lesson_slot_id' => 'integer',
'day_of_week' => 'integer',
'is_active' => 'boolean',
'start_time' => 'string',
'end_time' => 'string',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Modules\Academic\Entities;
use CodeIgniter\Entity\Entity;
/**
* Student Entity
*
* Represents a student in the system.
*/
class Student extends Entity
{
/**
* Attributes that can be mass assigned
*
* @var array<string>
*/
protected $allowedFields = [
'nisn',
'name',
'gender',
'class_id',
'is_active',
'face_external_id',
'face_hash',
'dapodik_id',
'parent_link_code',
];
/**
* Attributes that should be cast to specific types
*
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'class_id' => '?integer',
'is_active' => 'integer',
'face_hash' => '?string',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Modules\Academic\Entities;
use CodeIgniter\Entity\Entity;
/**
* Subject Entity
*
* Represents a subject/mata pelajaran in the system.
*/
class Subject extends Entity
{
/**
* Attributes that can be mass assigned
*
* @var array<string>
*/
protected $allowedFields = [
'name',
'code',
];
/**
* Attributes that should be cast to specific types
*
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Modules\Academic\Models;
use App\Modules\Academic\Entities\ClassEntity;
use CodeIgniter\Model;
/**
* Class Model
*
* Handles database operations for classes.
*/
class ClassModel extends Model
{
protected $table = 'classes';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = ClassEntity::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'name',
'grade',
'major',
'wali_user_id',
];
// Dates
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation
protected $validationRules = [
'name' => 'required|max_length[100]',
'grade' => 'required|max_length[50]',
'major' => 'required|max_length[50]',
'wali_user_id' => 'permit_empty|integer|is_not_unique[users.id]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Modules\Academic\Models;
use CodeIgniter\Model;
/**
* Dapodik rombel string -> internal class_id mapping.
*/
class DapodikRombelMappingModel extends Model
{
protected $table = 'dapodik_rombel_mappings';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $allowedFields = [
'dapodik_rombel',
'class_id',
'last_seen_at',
];
public function getByRombel(string $rombel): ?array
{
$row = $this->where('dapodik_rombel', $rombel)->first();
return $row !== null ? $row : null;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Modules\Academic\Models;
use CodeIgniter\Model;
/**
* Dapodik sync job tracker for progress reporting.
*/
class DapodikSyncJobModel extends Model
{
protected $table = 'dapodik_sync_jobs';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $allowedFields = [
'type',
'total_rows',
'processed_rows',
'status',
'message',
'started_at',
'finished_at',
];
public const STATUS_PENDING = 'pending';
public const STATUS_RUNNING = 'running';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const TYPE_STUDENTS = 'students';
public const TYPE_CLASSES = 'classes';
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Modules\Academic\Models;
use App\Modules\Academic\Entities\LessonSlot;
use CodeIgniter\Model;
/**
* Lesson Slot Model
*/
class LessonSlotModel extends Model
{
protected $table = 'lesson_slots';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = LessonSlot::class;
protected $allowedFields = [
'slot_number',
'start_time',
'end_time',
'is_active',
];
protected $useTimestamps = false;
protected $validationRules = [
'slot_number' => 'required|integer|greater_than[0]|is_unique[lesson_slots.slot_number,id,{id}]',
'start_time' => 'required|valid_date[H:i:s]',
'end_time' => 'required|valid_date[H:i:s]',
'is_active' => 'permit_empty|in_list[0,1]',
];
protected $validationMessages = [
'end_time' => [
'required' => 'End time is required.',
],
];
protected function getSlotStartBeforeEndRule(): array
{
return [
'end_time' => 'required|valid_date[H:i:s]|greater_than_field[start_time]',
];
}
/**
* Validate that start_time < end_time (custom rule or after validation).
*/
public function validateStartBeforeEnd(array $data): bool
{
$start = $data['start_time'] ?? '';
$end = $data['end_time'] ?? '';
if ($start === '' || $end === '') {
return true;
}
return strtotime($start) < strtotime($end);
}
/**
* Override insert to check start < end.
*/
public function insert($data = null, bool $returnID = true)
{
if (is_array($data) && !$this->validateStartBeforeEnd($data)) {
$this->errors['end_time'] = 'End time must be after start time.';
return false;
}
return parent::insert($data, $returnID);
}
/**
* Override update to check start < end (merge with existing when partial).
*/
public function update($id = null, $data = null): bool
{
if (is_array($data) && (array_key_exists('start_time', $data) || array_key_exists('end_time', $data))) {
$existing = $this->find($id);
$exStart = $existing && is_object($existing) ? $existing->start_time : ($existing['start_time'] ?? '');
$exEnd = $existing && is_object($existing) ? $existing->end_time : ($existing['end_time'] ?? '');
$merged = [
'start_time' => $data['start_time'] ?? $exStart,
'end_time' => $data['end_time'] ?? $exEnd,
];
if (!$this->validateStartBeforeEnd($merged)) {
$this->errors['end_time'] = 'End time must be after start time.';
return false;
}
}
return parent::update($id, $data);
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace App\Modules\Academic\Models;
use App\Modules\Academic\Entities\Schedule;
use CodeIgniter\Model;
/**
* Schedule Model
*
* Handles database operations for schedules.
*/
class ScheduleModel extends Model
{
protected $table = 'schedules';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = Schedule::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'class_id',
'subject_id',
'teacher_user_id',
'teacher_name',
'lesson_slot_id',
'day_of_week',
'start_time',
'end_time',
'room',
'is_active',
];
// Dates
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation
protected $validationRules = [
'class_id' => 'required|integer|is_not_unique[classes.id]',
'subject_id' => 'required|integer|is_not_unique[subjects.id]',
'teacher_name' => 'required|max_length[255]',
'day_of_week' => 'required|integer|greater_than_equal_to[1]|less_than_equal_to[7]',
'start_time' => 'required|valid_date[H:i:s]',
'end_time' => 'required|valid_date[H:i:s]',
'room' => 'permit_empty|max_length[100]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
/**
* Find active schedule for class on specific day and time (uses schedule columns only).
*
* @deprecated Prefer getActiveScheduleRow() for slot/user as source of truth with fallback.
* @param int $classId
* @param int $dayOfWeek
* @param string $time Time in H:i:s format
* @return Schedule|null
*/
public function findActiveSchedule(int $classId, int $dayOfWeek, string $time): ?Schedule
{
return $this->where('class_id', $classId)
->where('day_of_week', $dayOfWeek)
->where('start_time <=', $time)
->where('end_time >', $time)
->first();
}
/**
* Get active schedule row with lesson_slots and users as source of truth.
* start_time, end_time from lesson_slots; teacher_name from users; fallback to schedule columns when NULL.
*
* @param int $classId
* @param int $dayOfWeek
* @param string $time Time in H:i:s format
* @return array{id: int, class_id: int, subject_id: int, teacher_user_id: int|null, teacher_name: string, room: string|null, start_time: string, end_time: string, day_of_week: int}|null
*/
public function getActiveScheduleRow(int $classId, int $dayOfWeek, string $time): ?array
{
$db = \Config\Database::connect();
$sql = 'SELECT sch.id AS id, sch.class_id, sch.subject_id, sch.teacher_user_id, '
. 'COALESCE(u.name, sch.teacher_name) AS teacher_name, sch.room, '
. 'COALESCE(ls.start_time, sch.start_time) AS start_time, COALESCE(ls.end_time, sch.end_time) AS end_time, sch.day_of_week '
. 'FROM schedules sch '
. 'LEFT JOIN lesson_slots ls ON ls.id = sch.lesson_slot_id '
. 'LEFT JOIN users u ON u.id = sch.teacher_user_id '
. 'WHERE sch.class_id = ? AND sch.day_of_week = ? '
. 'AND ( COALESCE(ls.start_time, sch.start_time) ) <= ? AND ( COALESCE(ls.end_time, sch.end_time) ) > ? '
. 'LIMIT 1';
$row = $db->query($sql, [$classId, $dayOfWeek, $time, $time])->getRowArray();
if ($row === null) {
return null;
}
return [
'id' => (int) $row['id'],
'class_id' => (int) $row['class_id'],
'subject_id' => (int) $row['subject_id'],
'teacher_user_id' => isset($row['teacher_user_id']) ? (int) $row['teacher_user_id'] : null,
'teacher_name' => (string) ($row['teacher_name'] ?? ''),
'room' => isset($row['room']) ? (string) $row['room'] : null,
'start_time' => (string) $row['start_time'],
'end_time' => (string) $row['end_time'],
'day_of_week' => (int) $row['day_of_week'],
];
}
/**
* Get latest schedule for class on given day where start_time <= time (may already be ended).
* Uses lesson_slots/users as source of truth. ORDER BY start_time DESC, LIMIT 1.
*
* @param int $classId
* @param int $dayOfWeek
* @param string $time Time in H:i:s format
* @return array{id: int, class_id: int, subject_id: int, teacher_user_id: int|null, teacher_name: string, room: string|null, start_time: string, end_time: string, day_of_week: int}|null
*/
public function getLatestScheduleForClassToday(int $classId, int $dayOfWeek, string $time): ?array
{
$db = \Config\Database::connect();
$sql = 'SELECT sch.id AS id, sch.class_id, sch.subject_id, sch.teacher_user_id, '
. 'COALESCE(u.name, sch.teacher_name) AS teacher_name, sch.room, '
. 'COALESCE(ls.start_time, sch.start_time) AS start_time, COALESCE(ls.end_time, sch.end_time) AS end_time, sch.day_of_week '
. 'FROM schedules sch '
. 'LEFT JOIN lesson_slots ls ON ls.id = sch.lesson_slot_id '
. 'LEFT JOIN users u ON u.id = sch.teacher_user_id '
. 'WHERE sch.class_id = ? AND sch.day_of_week = ? '
. 'AND ( COALESCE(ls.start_time, sch.start_time) ) <= ? '
. 'ORDER BY ( COALESCE(ls.start_time, sch.start_time) ) DESC '
. 'LIMIT 1';
$row = $db->query($sql, [$classId, $dayOfWeek, $time])->getRowArray();
if ($row === null) {
return null;
}
return [
'id' => (int) $row['id'],
'class_id' => (int) $row['class_id'],
'subject_id' => (int) $row['subject_id'],
'teacher_user_id' => isset($row['teacher_user_id']) ? (int) $row['teacher_user_id'] : null,
'teacher_name' => (string) ($row['teacher_name'] ?? ''),
'room' => isset($row['room']) ? (string) $row['room'] : null,
'start_time' => (string) $row['start_time'],
'end_time' => (string) $row['end_time'],
'day_of_week' => (int) $row['day_of_week'],
];
}
/**
* Get one schedule row by id with lesson_slots and users as source of truth.
*
* @param int $scheduleId
* @return array{id: int, class_id: int, subject_id: int, teacher_user_id: int|null, teacher_name: string, start_time: string, end_time: string, day_of_week: int, room: string|null}|null
*/
public function getScheduleWithSlot(int $scheduleId): ?array
{
$db = \Config\Database::connect();
$sql = 'SELECT sch.id AS id, sch.class_id, sch.subject_id, sch.teacher_user_id, '
. 'COALESCE(u.name, sch.teacher_name) AS teacher_name, '
. 'COALESCE(ls.start_time, sch.start_time) AS start_time, COALESCE(ls.end_time, sch.end_time) AS end_time, '
. 'sch.day_of_week, sch.room '
. 'FROM schedules sch '
. 'LEFT JOIN lesson_slots ls ON ls.id = sch.lesson_slot_id '
. 'LEFT JOIN users u ON u.id = sch.teacher_user_id '
. 'WHERE sch.id = ? LIMIT 1';
$row = $db->query($sql, [$scheduleId])->getRowArray();
if ($row === null) {
return null;
}
return [
'id' => (int) $row['id'],
'class_id' => (int) $row['class_id'],
'subject_id' => (int) $row['subject_id'],
'teacher_user_id' => isset($row['teacher_user_id']) ? (int) $row['teacher_user_id'] : null,
'teacher_name' => (string) ($row['teacher_name'] ?? ''),
'start_time' => (string) $row['start_time'],
'end_time' => (string) $row['end_time'],
'day_of_week' => (int) $row['day_of_week'],
'room' => isset($row['room']) ? (string) $row['room'] : null,
];
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Modules\Academic\Models;
use App\Modules\Academic\Entities\Student;
use CodeIgniter\Model;
/**
* Student Model
*
* Handles database operations for students.
*/
class StudentModel extends Model
{
protected $table = 'students';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = Student::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'dapodik_id',
'face_external_id',
'face_hash',
'nisn',
'name',
'gender',
'class_id',
'is_active',
'parent_link_code',
];
// Dates
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation (class_id nullable for unmapped/Dapodik)
protected $validationRules = [
'dapodik_id' => 'permit_empty|max_length[64]|is_unique[students.dapodik_id,id,{id}]',
'face_external_id' => 'permit_empty|max_length[100]|is_unique[students.face_external_id,id,{id}]',
'face_hash' => 'permit_empty|max_length[32]',
'nisn' => 'required|max_length[50]|is_unique[students.nisn,id,{id}]',
'name' => 'required|max_length[255]',
'gender' => 'permit_empty|in_list[L,P]',
'class_id' => 'permit_empty|integer|is_not_unique[classes.id]',
'is_active' => 'permit_empty|in_list[0,1]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
/**
* Find student by NISN
*
* @param string $nisn
* @return Student|null
*/
public function findByNisn(string $nisn): ?Student
{
return $this->where('nisn', $nisn)->first();
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Modules\Academic\Models;
use App\Modules\Academic\Entities\Subject;
use CodeIgniter\Model;
/**
* Subject Model
*
* Handles database operations for subjects.
*/
class SubjectModel extends Model
{
protected $table = 'subjects';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = Subject::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'name',
'code',
];
// Dates
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation
protected $validationRules = [
'name' => 'required|max_length[255]',
'code' => 'permit_empty|max_length[50]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Modules\Academic\Models;
use CodeIgniter\Model;
class TeacherSubjectModel extends Model
{
protected $table = 'teacher_subjects';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'teacher_user_id',
'subject_id',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
/**
* Get subject IDs for a teacher.
*
* @param int $teacherId
* @return array<int>
*/
public function getSubjectIdsForTeacher(int $teacherId): array
{
$rows = $this->select('subject_id')
->where('teacher_user_id', $teacherId)
->findAll();
return array_map(static fn ($row) => (int) $row['subject_id'], $rows);
}
/**
* Get mapping: subject_id => [teacher_user_id, ...].
*
* @return array<int, array<int>>
*/
public function getMapSubjectToTeacherIds(): array
{
$rows = $this->select('teacher_user_id, subject_id')->findAll();
$map = [];
foreach ($rows as $r) {
$s = (int) $r['subject_id'];
$t = (int) $r['teacher_user_id'];
$map[$s][] = $t;
}
return $map;
}
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* Academic Module Routes
*
* This file is automatically loaded by ModuleLoader.
* Define your academic management routes here.
*
* @var \CodeIgniter\Router\RouteCollection $routes
*/
$routes->group('api/academic', [
'namespace' => 'App\Modules\Academic\Controllers',
'filter' => 'admin_only',
], function ($routes) {
$routes->get('lesson-slots', 'LessonSlotController::index');
$routes->post('lesson-slots', 'LessonSlotController::create');
$routes->put('lesson-slots/(:num)', 'LessonSlotController::update/$1');
$routes->delete('lesson-slots/(:num)', 'LessonSlotController::delete/$1');
$routes->get('schedules/class/(:num)', 'ScheduleManagementController::getByClass/$1');
$routes->post('schedules/bulk-save', 'ScheduleManagementController::bulkSave');
$routes->get('teachers', 'TeacherController::index');
$routes->get('teacher-subjects/(:num)', 'TeacherSubjectController::getByTeacher/$1');
$routes->put('teacher-subjects/(:num)', 'TeacherSubjectController::updateForTeacher/$1');
$routes->get('teacher-subjects/map', 'TeacherSubjectController::map');
$routes->get('subjects', 'SubjectController::index');
$routes->post('subjects', 'SubjectController::create');
$routes->put('subjects/(:num)', 'SubjectController::update/$1');
$routes->delete('subjects/(:num)', 'SubjectController::delete/$1');
$routes->get('classes', 'ClassController::index');
$routes->post('classes', 'ClassController::create');
$routes->put('classes/(:num)', 'ClassController::update/$1');
$routes->delete('classes/(:num)', 'ClassController::delete/$1');
$routes->get('students', 'StudentController::index');
$routes->post('students', 'StudentController::create');
$routes->put('students/(:num)', 'StudentController::update/$1');
$routes->delete('students/(:num)', 'StudentController::delete/$1');
$routes->post('dapodik/sync/students', 'DapodikSyncController::syncStudents');
$routes->get('dapodik/sync/status/(:num)', 'DapodikSyncController::status/$1');
$routes->get('dapodik/rombels', 'DapodikSyncController::rombels');
$routes->put('dapodik/rombels/(:num)', 'DapodikSyncController::updateRombel/$1');
});

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Modules\Academic\Services;
/**
* Dapodik WebService API client.
* Uses env: DAPODIK_BASE_URL, DAPODIK_TOKEN, DAPODIK_NPSN.
* Do not log or expose token.
*/
class DapodikClient
{
protected string $baseUrl;
protected string $token;
protected string $npsn;
public function __construct(?string $baseUrl = null, ?string $token = null, ?string $npsn = null)
{
$this->baseUrl = rtrim($baseUrl ?? (string) env('DAPODIK_BASE_URL', ''), '/');
$this->token = $token ?? (string) env('DAPODIK_TOKEN', '');
$this->npsn = $npsn ?? (string) env('DAPODIK_NPSN', '');
}
/**
* GET getSekolah
*
* @return array{success: bool, data?: array, error?: string}
*/
public function getSekolah(): array
{
$url = $this->baseUrl . '/getSekolah';
if ($this->npsn !== '') {
$url .= '?npsn=' . rawurlencode($this->npsn);
}
return $this->request('GET', $url);
}
/**
* GET getPesertaDidik with pagination
*
* @param int $start
* @param int $limit
* @return array{success: bool, data?: array, rows?: array, id?: mixed, start?: int, limit?: int, results?: int, error?: string}
*/
public function getPesertaDidik(int $start = 0, int $limit = 200): array
{
$params = [];
if ($this->npsn !== '') {
$params['npsn'] = $this->npsn;
}
$params['start'] = $start;
$params['limit'] = $limit;
$url = $this->baseUrl . '/getPesertaDidik?' . http_build_query($params);
return $this->request('GET', $url);
}
/**
* Execute HTTP request. Returns decoded JSON with success flag and error message on failure.
*/
protected function request(string $method, string $url): array
{
$ch = curl_init();
if ($ch === false) {
return ['success' => false, 'error' => 'cURL init failed'];
}
$headers = [
'Accept: application/json',
'Authorization: Bearer ' . $this->token,
];
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CUSTOMREQUEST => $method,
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) {
return ['success' => false, 'error' => 'Network error: ' . ($errno === CURLE_OPERATION_TIMEDOUT ? 'timeout' : 'curl ' . $errno)];
}
$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['success' => false, 'error' => 'Invalid JSON response', 'http_code' => $httpCode];
}
if ($httpCode < 200 || $httpCode >= 300) {
$msg = is_array($decoded) && isset($decoded['message']) ? $decoded['message'] : 'HTTP ' . $httpCode;
return ['success' => false, 'error' => $msg, 'http_code' => $httpCode, 'data' => $decoded];
}
return array_merge(['success' => true], $decoded);
}
/**
* Normalize Dapodik response to a list of rows.
* Dapodik returns { results, id, start, limit, rows: [...] }
*
* @param array $response Response from getPesertaDidik
* @return array<int, array<string, mixed>>
*/
public static function normalizePesertaDidikRows(array $response): array
{
if (! isset($response['rows']) || ! is_array($response['rows'])) {
return [];
}
$rows = $response['rows'];
$out = [];
foreach ($rows as $i => $row) {
$out[] = is_array($row) ? $row : (array) $row;
}
return $out;
}
}

View File

@@ -0,0 +1,312 @@
<?php
namespace App\Modules\Academic\Services;
use App\Modules\Academic\Models\DapodikRombelMappingModel;
use App\Modules\Academic\Models\DapodikSyncJobModel;
use App\Modules\Academic\Models\StudentModel;
/**
* Syncs Dapodik peserta didik into local students with rombel -> class mapping.
*/
class DapodikSyncService
{
protected DapodikClient $client;
protected DapodikRombelMappingModel $mappingModel;
protected StudentModel $studentModel;
protected DapodikSyncJobModel $jobModel;
public function __construct(?DapodikClient $client = null, ?DapodikSyncJobModel $jobModel = null)
{
$this->client = $client ?? new DapodikClient();
$this->mappingModel = new DapodikRombelMappingModel();
$this->studentModel = new StudentModel();
$this->jobModel = $jobModel ?? new DapodikSyncJobModel();
}
/**
* Sync students from Dapodik with job tracking. Paginates getPesertaDidik, upserts per batch.
*
* @param int $jobId Job row id for progress updates
* @param int $limit Per-page limit
* @param int $maxPages Max pages to fetch
* @return void
*/
public function syncStudentsWithJob(int $jobId, int $limit = 100, int $maxPages = 50): void
{
$start = 0;
$page = 0;
$now = date('Y-m-d H:i:s');
$processedTotal = 0;
$totalRows = 0;
$knownTotal = false;
$db = \Config\Database::connect();
try {
while ($page < $maxPages) {
$response = $this->client->getPesertaDidik($start, $limit);
if (! ($response['success'] ?? false)) {
$err = $response['error'] ?? 'Unknown error';
$this->jobModel->update($jobId, [
'status' => DapodikSyncJobModel::STATUS_FAILED,
'message' => $err,
'finished_at' => $now,
]);
return;
}
$rows = DapodikClient::normalizePesertaDidikRows($response);
if ($rows === []) {
break;
}
if (! $knownTotal) {
$results = $response['results'] ?? null;
if (is_numeric($results) && (int) $results > 0) {
$totalRows = (int) $results;
$knownTotal = true;
$this->jobModel->update($jobId, ['total_rows' => $totalRows]);
}
}
$db->transStart();
try {
$batchCount = 0;
foreach ($rows as $row) {
$dapodikId = $this->extractDapodikId($row);
$nisn = $this->extractString($row, ['nisn']);
$nama = $this->extractString($row, ['nama', 'name']);
$gender = $this->extractGender($row);
$rombel = $this->extractString($row, ['rombel', 'nama_rombel', 'rombongan_belajar']);
// Require at least one stable identifier
if ($dapodikId === null && $nisn === '') {
continue;
}
$mapping = $this->mappingModel->getByRombel($rombel);
if ($mapping === null) {
$this->mappingModel->insert([
'dapodik_rombel' => $rombel,
'class_id' => null,
'last_seen_at' => $now,
]);
$classId = null;
} else {
$this->mappingModel->update($mapping['id'], ['last_seen_at' => $now]);
$classId = ! empty($mapping['class_id']) ? (int) $mapping['class_id'] : null;
}
$data = [
'dapodik_id' => $dapodikId,
'nisn' => $nisn,
'name' => $nama !== '' ? $nama : ($nisn !== '' ? 'Siswa ' . $nisn : 'Siswa Dapodik'),
'gender' => $gender,
'class_id' => $classId,
'is_active' => 1,
];
// Use upsert so repeated syncs update instead of inserting duplicates
$this->studentModel->skipValidation(true);
$this->studentModel->upsert($data);
$batchCount++;
}
$db->transComplete();
} catch (\Throwable $e) {
$db->transRollback();
$this->jobModel->update($jobId, [
'status' => DapodikSyncJobModel::STATUS_FAILED,
'message' => $e->getMessage(),
'finished_at' => $now,
]);
return;
}
$processedTotal += $batchCount;
$this->jobModel->update($jobId, [
'processed_rows' => $processedTotal,
]);
if (count($rows) < $limit) {
if (! $knownTotal) {
$this->jobModel->update($jobId, ['total_rows' => $processedTotal]);
}
break;
}
$start += $limit;
$page++;
}
$this->jobModel->update($jobId, [
'status' => DapodikSyncJobModel::STATUS_COMPLETED,
'finished_at' => $now,
]);
} catch (\Throwable $e) {
$this->jobModel->update($jobId, [
'status' => DapodikSyncJobModel::STATUS_FAILED,
'message' => $e->getMessage(),
'finished_at' => date('Y-m-d H:i:s'),
]);
}
}
/**
* Extract Dapodik stable identifier for a student row.
*
* @param array $row
* @return string|null
*/
private function extractDapodikId(array $row): ?string
{
foreach (['peserta_didik_id', 'id', 'pd_id'] as $key) {
if (isset($row[$key]) && is_scalar($row[$key])) {
$v = trim((string) $row[$key]);
if ($v !== '') {
return $v;
}
}
}
return null;
}
/**
* Estimate total rows from first Dapodik response (if available).
*
* @param int $limit Per-page limit
* @return array{total: int, from_api: bool}
*/
public function estimateTotalStudents(int $limit = 100): array
{
$response = $this->client->getPesertaDidik(0, $limit);
if (! ($response['success'] ?? false)) {
return ['total' => 0, 'from_api' => false];
}
$results = $response['results'] ?? null;
if (is_numeric($results) && (int) $results > 0) {
return ['total' => (int) $results, 'from_api' => true];
}
$rows = DapodikClient::normalizePesertaDidikRows($response);
return ['total' => count($rows) > 0 ? count($rows) : 0, 'from_api' => false];
}
/**
* Legacy sync (no job). Kept for backwards compatibility.
*
* @param int $limit Per-page limit
* @param int $maxPages Max pages to fetch
* @return array{fetched_total: int, inserted_students: int, updated_students: int, unmapped_students_count: int, mappings_created: int, mappings_seen_updated: int, errors: array<int, string>}
*/
public function syncStudents(int $limit = 200, int $maxPages = 50): array
{
$summary = [
'fetched_total' => 0,
'inserted_students' => 0,
'updated_students' => 0,
'unmapped_students_count' => 0,
'mappings_created' => 0,
'mappings_seen_updated' => 0,
'errors' => [],
];
$start = 0;
$page = 0;
$now = date('Y-m-d H:i:s');
while ($page < $maxPages) {
$response = $this->client->getPesertaDidik($start, $limit);
if (! ($response['success'] ?? false)) {
$summary['errors'][] = $response['error'] ?? 'Unknown error';
break;
}
$rows = DapodikClient::normalizePesertaDidikRows($response);
if ($rows === []) {
break;
}
$summary['fetched_total'] += count($rows);
foreach ($rows as $row) {
$nisn = $this->extractString($row, ['nisn']);
$nama = $this->extractString($row, ['nama', 'name']);
$gender = $this->extractGender($row);
$rombel = $this->extractString($row, ['rombel', 'nama_rombel', 'rombongan_belajar']);
if ($nisn === '') {
continue;
}
$mapping = $this->mappingModel->getByRombel($rombel);
if ($mapping === null) {
$this->mappingModel->insert([
'dapodik_rombel' => $rombel,
'class_id' => null,
'last_seen_at' => $now,
]);
$summary['mappings_created']++;
$classId = null;
} else {
$this->mappingModel->update($mapping['id'], ['last_seen_at' => $now]);
$summary['mappings_seen_updated']++;
$classId = ! empty($mapping['class_id']) ? (int) $mapping['class_id'] : null;
}
if ($classId === null) {
$summary['unmapped_students_count']++;
}
$existing = $this->studentModel->findByNisn($nisn);
$data = [
'nisn' => $nisn,
'name' => $nama !== '' ? $nama : 'Siswa ' . $nisn,
'gender' => $gender,
'class_id' => $classId,
'is_active'=> 1,
];
$this->studentModel->skipValidation(false);
if ($existing) {
$this->studentModel->update($existing->id, $data);
$summary['updated_students']++;
} else {
$this->studentModel->insert($data);
$summary['inserted_students']++;
}
}
if (count($rows) < $limit) {
break;
}
$start += $limit;
$page++;
}
return $summary;
}
private function extractString(array $row, array $keys): string
{
foreach ($keys as $k) {
if (isset($row[$k]) && is_scalar($row[$k])) {
return trim((string) $row[$k]);
}
}
return '';
}
private function extractGender(array $row): ?string
{
$v = $row['jenis_kelamin'] ?? $row['gender'] ?? $row['jk'] ?? null;
if ($v === null || $v === '') {
return null;
}
$v = strtoupper(trim((string) $v));
if ($v === 'L' || $v === 'LAKI-LAKI' || $v === 'Laki-laki') {
return 'L';
}
if ($v === 'P' || $v === 'PEREMPUAN' || $v === 'Perempuan') {
return 'P';
}
return null;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Modules\Academic\Services;
use App\Modules\Academic\Models\ScheduleModel;
use App\Modules\Academic\Models\StudentModel;
/**
* Schedule Resolver Service
*
* Handles schedule resolution logic for students.
*/
class ScheduleResolverService
{
protected StudentModel $studentModel;
protected ScheduleModel $scheduleModel;
public function __construct()
{
$this->studentModel = new StudentModel();
$this->scheduleModel = new ScheduleModel();
}
/**
* Get active schedule for a student at a specific datetime
*
* Rules:
* - Find student's class_id
* - Determine day_of_week from datetime (1=Monday, 7=Sunday)
* - Find schedule where start_time <= time < end_time
*
* @param int $studentId Student ID
* @param string $datetime Datetime string (Y-m-d H:i:s format)
* @return array|null Returns schedule detail or null if not found
*/
public function getActiveSchedule(int $studentId, string $datetime): ?array
{
// Find student
$student = $this->studentModel->find($studentId);
if (!$student) {
return null;
}
// Get class_id from student
$classId = $student->class_id;
// Parse datetime to get day of week and time
$timestamp = strtotime($datetime);
if ($timestamp === false) {
return null;
}
// Get day of week (1=Monday, 7=Sunday)
// PHP date('N') returns 1-7 (Monday-Sunday)
$dayOfWeek = (int) date('N', $timestamp);
// Get time in H:i:s format
$time = date('H:i:s', $timestamp);
// Find active schedule (lesson_slots/users as source of truth with fallback to schedule columns)
$row = $this->scheduleModel->getActiveScheduleRow($classId, $dayOfWeek, $time);
if ($row === null) {
return null;
}
return [
'schedule_id' => $row['id'],
'class_id' => $row['class_id'],
'subject_id' => $row['subject_id'],
'teacher_name' => $row['teacher_name'],
'room' => $row['room'],
'start_time' => $row['start_time'],
'end_time' => $row['end_time'],
];
}
}

View File

@@ -0,0 +1 @@
# Attendance Module - Controllers

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Modules\Attendance\Controllers;
use App\Core\BaseApiController;
use App\Modules\Attendance\Services\AttendanceCheckinService;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Attendance Controller
*
* Handles attendance check-in endpoints.
*/
class AttendanceController extends BaseApiController
{
protected AttendanceCheckinService $checkinService;
public function __construct()
{
$this->checkinService = new AttendanceCheckinService();
}
/**
* Attendance check-in endpoint
*
* POST /api/attendance/checkin
* Body: {
* "device_code": "",
* "api_key": "",
* "student_id": 0,
* "datetime": "Y-m-d H:i:s",
* "lat": 0.0,
* "lng": 0.0,
* "confidence": 0.0 (optional)
* }
*
* @return ResponseInterface
*/
public function checkin(): ResponseInterface
{
// Get JSON input
$input = $this->request->getJSON(true);
// Validate required fields
$requiredFields = ['device_code', 'api_key', 'student_id', 'datetime', 'lat', 'lng'];
foreach ($requiredFields as $field) {
if (!isset($input[$field])) {
return $this->errorResponse(
"Field '{$field}' is required",
null,
null,
400
);
}
}
// Process check-in
try {
$result = $this->checkinService->checkin($input);
// Determine message based on status
$messages = [
'PRESENT' => 'Attendance recorded successfully',
'LATE' => 'Attendance recorded but marked as late',
'OUTSIDE_ZONE' => 'Check-in failed: Location outside school zone',
'NO_SCHEDULE' => 'Check-in failed: No active schedule found',
'INVALID_DEVICE' => 'Check-in failed: Invalid device credentials',
'ALREADY_CHECKED_IN' => 'Already checked in for this schedule today',
'ABSENCE_WINDOW_CLOSED' => 'Check-in failed: Outside attendance window',
'SESSION_CLOSED' => 'Attendance session closed',
];
$message = $messages[$result['status']] ?? 'Attendance check-in processed';
// Return success response (even for failures, as the record is saved)
return $this->successResponse(
$result,
$message
);
} catch (\Exception $e) {
return $this->errorResponse(
'An error occurred while processing check-in',
['error' => $e->getMessage()],
null,
500
);
}
}
}

View File

@@ -0,0 +1,260 @@
<?php
namespace App\Modules\Attendance\Controllers;
use App\Core\BaseApiController;
use App\Modules\Auth\Entities\Role;
use App\Modules\Auth\Services\AuthService;
use App\Modules\Attendance\Services\AttendanceReportService;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Attendance Report Controller
*
* On-the-fly schedule attendance reports (expected, present, late, absent).
*/
class AttendanceReportController extends BaseApiController
{
protected AttendanceReportService $reportService;
protected AuthService $authService;
public function __construct()
{
$this->reportService = new AttendanceReportService();
$this->authService = new AuthService();
}
/**
* GET /api/attendance/reports
*
* Query:
* - from_date (YYYY-MM-DD, optional, default: today if both empty)
* - to_date (YYYY-MM-DD, optional, default: today if both empty)
* - class_id (int, optional)
* - student_id (int, optional)
* - status (PRESENT,LATE,OUTSIDE_ZONE,NO_SCHEDULE,INVALID_DEVICE; optional)
*
* Returns recap per hari/per kelas + daftar detail kehadiran.
*/
public function index(): ResponseInterface
{
$user = $this->authService->currentUser();
if (! $user) {
return $this->errorResponse('Unauthorized', null, null, ResponseInterface::HTTP_UNAUTHORIZED);
}
$roles = $user['roles'] ?? [];
$roleCodes = array_column($roles, 'role_code');
$isAdmin = in_array(Role::CODE_ADMIN, $roleCodes, true);
$isGuruBk = in_array(Role::CODE_GURU_BK, $roleCodes, true);
$isWali = in_array(Role::CODE_WALI_KELAS, $roleCodes, true);
$isGuruMap = in_array(Role::CODE_GURU_MAPEL, $roleCodes, true);
// ORANG_TUA pakai Portal Orang Tua, bukan endpoint ini
if (! $isAdmin && ! $isGuruBk && ! $isWali && ! $isGuruMap) {
return $this->errorResponse('Forbidden', null, null, ResponseInterface::HTTP_FORBIDDEN);
}
$from = $this->request->getGet('from_date');
$to = $this->request->getGet('to_date');
$today = date('Y-m-d');
if (($from === null || $from === '') && ($to === null || $to === '')) {
$from = $today;
$to = $today;
}
if ($from !== null && $from !== '' && ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
return $this->errorResponse('from_date must be YYYY-MM-DD', null, null, ResponseInterface::HTTP_BAD_REQUEST);
}
if ($to !== null && $to !== '' && ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
return $this->errorResponse('to_date must be YYYY-MM-DD', null, null, ResponseInterface::HTTP_BAD_REQUEST);
}
$classId = (int) $this->request->getGet('class_id');
$studentId = (int) $this->request->getGet('student_id');
$status = $this->request->getGet('status');
if ($status !== null && $status !== '') {
$allowedStatus = ['PRESENT', 'LATE', 'OUTSIDE_ZONE', 'NO_SCHEDULE', 'INVALID_DEVICE'];
if (! in_array($status, $allowedStatus, true)) {
return $this->errorResponse('Invalid status value', null, null, ResponseInterface::HTTP_BAD_REQUEST);
}
}
$db = \Config\Database::connect();
$builder = $db->table('attendance_sessions AS att')
->select(
'att.id, att.attendance_date, att.status, att.checkin_at, ' .
's.id AS student_id, s.nisn, s.name AS student_name, ' .
'c.id AS class_id, c.grade, c.major, c.name AS class_name, ' .
'sch.id AS schedule_id, sub.id AS subject_id, sub.name AS subject_name, ' .
'u.id AS teacher_user_id, u.name AS teacher_name'
)
->join('students AS s', 's.id = att.student_id', 'inner')
->join('classes AS c', 'c.id = s.class_id', 'left')
->join('schedules AS sch', 'sch.id = att.schedule_id', 'left')
->join('subjects AS sub', 'sub.id = sch.subject_id', 'left')
->join('users AS u', 'u.id = sch.teacher_user_id', 'left');
if ($from) {
$builder->where('att.attendance_date >=', $from);
}
if ($to) {
$builder->where('att.attendance_date <=', $to);
}
if ($classId > 0) {
$builder->where('c.id', $classId);
}
if ($studentId > 0) {
$builder->where('s.id', $studentId);
}
if ($status) {
$builder->where('att.status', $status);
}
// RBAC batasan data
if ($isWali) {
$builder->where('c.wali_user_id', (int) $user['id']);
} elseif ($isGuruMap) {
$builder->where('sch.teacher_user_id', (int) $user['id']);
} elseif (! $isAdmin && ! $isGuruBk) {
// Should not reach here, tetapi jaga-jaga
return $this->errorResponse('Forbidden', null, null, ResponseInterface::HTTP_FORBIDDEN);
}
$builder->orderBy('att.attendance_date', 'DESC')
->orderBy('c.grade', 'ASC')
->orderBy('c.major', 'ASC')
->orderBy('c.name', 'ASC')
->orderBy('s.name', 'ASC');
$rows = $builder->get()->getResultArray();
// Detail records
$records = array_map(static function (array $r): array {
$classLabel = null;
if ($r['grade'] !== null || $r['major'] !== null || $r['class_name'] !== null) {
$parts = array_filter([
trim((string) ($r['grade'] ?? '')),
trim((string) ($r['major'] ?? '')),
trim((string) ($r['class_name'] ?? '')),
]);
$classLabel = implode(' ', $parts);
}
return [
'id' => (int) $r['id'],
'attendance_date' => $r['attendance_date'],
'status' => $r['status'],
'checkin_at' => $r['checkin_at'],
'student_id' => (int) $r['student_id'],
'student_name' => $r['student_name'],
'nisn' => $r['nisn'],
'class_id' => $r['class_id'] !== null ? (int) $r['class_id'] : null,
'class_label' => $classLabel,
'schedule_id' => $r['schedule_id'] !== null ? (int) $r['schedule_id'] : null,
'subject_id' => $r['subject_id'] !== null ? (int) $r['subject_id'] : null,
'subject_name' => $r['subject_name'],
'teacher_user_id' => $r['teacher_user_id'] !== null ? (int) $r['teacher_user_id'] : null,
'teacher_name' => $r['teacher_name'],
];
}, $rows);
// Rekap per hari & kelas
$summaryMap = [];
foreach ($records as $rec) {
$date = $rec['attendance_date'];
$cid = $rec['class_id'] ?? 0;
$label = $rec['class_label'] ?? '-';
$key = $date . '|' . $cid;
if (! isset($summaryMap[$key])) {
$summaryMap[$key] = [
'attendance_date' => $date,
'class_id' => $cid,
'class_label' => $label,
'total' => 0,
'present' => 0,
'late' => 0,
'outside_zone' => 0,
'no_schedule' => 0,
'invalid_device' => 0,
];
}
$summaryMap[$key]['total']++;
switch ($rec['status']) {
case 'PRESENT':
$summaryMap[$key]['present']++;
break;
case 'LATE':
$summaryMap[$key]['late']++;
break;
case 'OUTSIDE_ZONE':
$summaryMap[$key]['outside_zone']++;
break;
case 'NO_SCHEDULE':
$summaryMap[$key]['no_schedule']++;
break;
case 'INVALID_DEVICE':
$summaryMap[$key]['invalid_device']++;
break;
}
}
$summary = array_values($summaryMap);
usort($summary, static function (array $a, array $b): int {
if ($a['attendance_date'] === $b['attendance_date']) {
return strcmp((string) $a['class_label'], (string) $b['class_label']);
}
return strcmp($a['attendance_date'], $b['attendance_date']);
});
$payload = [
'filters' => [
'from_date' => $from,
'to_date' => $to,
'class_id' => $classId > 0 ? $classId : null,
'student_id' => $studentId > 0 ? $studentId : null,
'status' => $status ?: null,
],
'summary' => $summary,
'records' => $records,
];
return $this->successResponse($payload, 'Attendance reports');
}
/**
* GET /api/attendance/report/schedule/{scheduleId}?date=YYYY-MM-DD
*
* @param int|string $scheduleId From route (:num)
* @return ResponseInterface
*/
public function scheduleReport($scheduleId): ResponseInterface
{
$scheduleId = (int) $scheduleId;
$date = $this->request->getGet('date');
if ($date === null || $date === '') {
return $this->errorResponse('Query parameter date (YYYY-MM-DD) is required', null, null, 400);
}
$date = (string) $date;
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $this->errorResponse('Parameter date must be YYYY-MM-DD', null, null, 400);
}
$userContext = $this->authService->currentUser();
$report = $this->reportService->getScheduleAttendanceReport($scheduleId, $date, $userContext);
if ($report === null) {
return $this->errorResponse('Schedule not found or access denied', null, null, 404);
}
return $this->successResponse($report, 'Schedule attendance report');
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Modules\Attendance\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\StudentModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Face link API: menghubungkan ID wajah eksternal dengan siswa.
*
* Catatan:
* - Engine AI / OpenCV bertugas menghasilkan face_external_id.
* - Backend hanya menyimpan mapping face_external_id -> student_id.
*/
class FaceLinkController extends BaseApiController
{
/**
* POST /api/attendance/face/enroll
* Body: { student_id: int, face_external_id: string }
*/
public function enroll(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$studentId = (int) ($payload['student_id'] ?? 0);
$faceId = trim((string) ($payload['face_external_id'] ?? ''));
if ($studentId <= 0 || $faceId === '') {
return $this->errorResponse(
'student_id dan face_external_id wajib diisi',
null,
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
$studentModel = new StudentModel();
$student = $studentModel->find($studentId);
if (! $student) {
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
// Pastikan face_external_id belum dipakai siswa lain
$existing = $studentModel
->where('face_external_id', $faceId)
->where('id !=', $studentId)
->first();
if ($existing) {
return $this->errorResponse('face_external_id sudah terpakai siswa lain', null, null, ResponseInterface::HTTP_CONFLICT);
}
$studentModel->skipValidation(true);
$studentModel->update($studentId, ['face_external_id' => $faceId]);
return $this->successResponse([
'student_id' => $studentId,
'face_external_id' => $faceId,
], 'Face ID berhasil dihubungkan dengan siswa');
}
/**
* POST /api/attendance/face/resolve
* Body: { face_external_id: string }
* Return: { student_id, name, class_id } atau 404.
*/
public function resolve(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$faceId = trim((string) ($payload['face_external_id'] ?? ''));
if ($faceId === '') {
return $this->errorResponse(
'face_external_id wajib diisi',
null,
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
$studentModel = new StudentModel();
$student = $studentModel->where('face_external_id', $faceId)->first();
if (! $student) {
return $this->errorResponse('Siswa untuk face_external_id ini tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
return $this->successResponse([
'student_id' => (int) $student->id,
'name' => (string) $student->name,
'class_id' => $student->class_id !== null ? (int) $student->class_id : null,
], 'Face ID berhasil dikenali');
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Modules\Attendance\Controllers;
use App\Core\BaseApiController;
use App\Modules\Face\Models\StudentFaceModel;
use App\Modules\Face\Services\FaceService;
use CodeIgniter\HTTP\ResponseInterface;
/**
* FaceVerifyController
*
* POST /api/attendance/verify-face
* Body: { "student_id": 123, "image": "data:image/jpeg;base64,..." }
*
* Strategi: Option 1 — bandingkan probe hanya dengan embedding milik student_id kandidat.
*/
class FaceVerifyController extends BaseApiController
{
public function verify(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$studentId = (int) ($payload['student_id'] ?? 0);
$imageData = (string) ($payload['image'] ?? '');
if ($studentId < 1) {
return $this->errorResponse('student_id wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
if ($imageData === '') {
return $this->errorResponse('image (base64) wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$tmpFile = tempnam(sys_get_temp_dir(), 'probe_');
try {
$raw = $this->decodeBase64Image($imageData);
if ($raw === null) {
throw new \RuntimeException('Format gambar tidak valid (harus base64)');
}
file_put_contents($tmpFile, $raw);
$faceService = new FaceService();
$probe = $faceService->extractEmbeddingWithQuality($tmpFile)['embedding'];
$faceModel = new StudentFaceModel();
$rows = $faceModel->where('student_id', $studentId)->findAll();
if (empty($rows)) {
return $this->errorResponse('Belum ada embedding wajah untuk siswa ini', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$bestSim = -1.0;
$bestSource = null;
foreach ($rows as $row) {
$embedding = json_decode($row['embedding'] ?? '[]', true);
if (! is_array($embedding) || $embedding === []) {
continue;
}
$sim = $faceService->cosineSimilarity($probe, array_map('floatval', $embedding));
if ($sim > $bestSim) {
$bestSim = $sim;
$bestSource = $row['source'] ?? null;
}
}
if ($bestSim < 0) {
return $this->errorResponse('Tidak ada embedding valid untuk siswa ini', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$threshold = $faceService->getDefaultThreshold();
$status = $bestSim >= $threshold ? 'match' : 'no_match';
$data = [
'student_id' => $studentId,
'similarity' => $bestSim,
'threshold' => $threshold,
'matched_source'=> $bestSource,
'status' => $status,
];
return $this->successResponse($data, 'Face verification processed');
} catch (\Throwable $e) {
return $this->errorResponse('Gagal verifikasi wajah: ' . $e->getMessage(), null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
} finally {
if (is_file($tmpFile)) {
@unlink($tmpFile);
}
}
}
protected function decodeBase64Image(string $input): ?string
{
$input = trim($input);
if ($input === '') {
return null;
}
if (strpos($input, 'base64,') !== false) {
$parts = explode('base64,', $input, 2);
$input = $parts[1];
}
$data = base64_decode($input, true);
return $data === false ? null : $data;
}
}

View File

@@ -0,0 +1 @@
# Attendance Module - Entities

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Modules\Attendance\Entities;
use CodeIgniter\Entity\Entity;
/**
* Attendance Session Entity
*
* Represents an attendance check-in session.
*/
class AttendanceSession extends Entity
{
/**
* Attributes that can be mass assigned
*
* @var array<string>
*/
protected $allowedFields = [
'student_id',
'schedule_id',
'checkin_type',
'attendance_date',
'device_id',
'checkin_at',
'latitude',
'longitude',
'confidence',
'status',
];
/**
* Attributes that should be cast to specific types
*
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'student_id' => 'integer',
'schedule_id' => 'integer',
'checkin_type' => 'string',
'attendance_date' => 'date',
'device_id' => 'integer',
'checkin_at' => 'datetime',
'latitude' => 'float',
'longitude' => 'float',
'confidence' => 'float',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Status constants
*/
public const STATUS_PRESENT = 'PRESENT';
public const STATUS_LATE = 'LATE';
public const STATUS_OUTSIDE_ZONE = 'OUTSIDE_ZONE';
public const STATUS_NO_SCHEDULE = 'NO_SCHEDULE';
public const STATUS_INVALID_DEVICE = 'INVALID_DEVICE';
public const STATUS_ALREADY_CHECKED_IN = 'ALREADY_CHECKED_IN';
public const STATUS_ABSENCE_WINDOW_CLOSED = 'ABSENCE_WINDOW_CLOSED';
public const STATUS_SESSION_CLOSED = 'SESSION_CLOSED';
}

View File

@@ -0,0 +1 @@
# Attendance Module - Models

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Modules\Attendance\Models;
use App\Modules\Attendance\Entities\AttendanceSession;
use CodeIgniter\Model;
/**
* Attendance Session Model
*
* Handles database operations for attendance sessions.
*/
class AttendanceSessionModel extends Model
{
protected $table = 'attendance_sessions';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = AttendanceSession::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'student_id',
'schedule_id',
'checkin_type',
'attendance_date',
'device_id',
'checkin_at',
'latitude',
'longitude',
'confidence',
'status',
];
// Dates
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation
protected $validationRules = [
'student_id' => 'required|integer|is_not_unique[students.id]',
'schedule_id' => 'permit_empty|integer|is_not_unique[schedules.id]',
'checkin_type' => 'permit_empty|in_list[mapel,masuk,pulang]',
'attendance_date' => 'required|valid_date[Y-m-d]',
'device_id' => 'required|integer|is_not_unique[devices.id]',
'checkin_at' => 'required|valid_date[Y-m-d H:i:s]',
'latitude' => 'required|decimal',
'longitude' => 'required|decimal',
'confidence' => 'permit_empty|decimal',
'status' => 'required|in_list[PRESENT,LATE,OUTSIDE_ZONE,NO_SCHEDULE,INVALID_DEVICE]',
];
/**
* Check if attendance already exists for student + schedule on the given date (server date).
* Used for duplicate protection: one attendance per (student_id, schedule_id, attendance_date).
*
* @param int $studentId
* @param int $scheduleId
* @param string $attendanceDate Date in Y-m-d format (use server date)
* @return bool
*/
public function hasAttendanceFor(int $studentId, int $scheduleId, string $attendanceDate): bool
{
$row = $this->where('student_id', $studentId)
->where('schedule_id', $scheduleId)
->where('attendance_date', $attendanceDate)
->first();
return $row !== null;
}
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Modules\Attendance\Models;
use CodeIgniter\Model;
/**
* Token QR untuk absen mapel: guru generate, siswa scan.
*/
class QrAttendanceTokenModel extends Model
{
protected $table = 'qr_attendance_tokens';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $allowedFields = [
'schedule_id',
'token',
'expires_at',
'created_by_user_id',
];
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
/** Token valid (default) 15 menit */
public const VALID_MINUTES = 15;
/**
* Generate token untuk schedule_id. Returns token string or null on failure.
*/
public function generateForSchedule(int $scheduleId, ?int $createdByUserId = null): ?string
{
$token = bin2hex(random_bytes(16));
$expires = date('Y-m-d H:i:s', strtotime('+' . self::VALID_MINUTES . ' minutes'));
$id = $this->insert([
'schedule_id' => $scheduleId,
'token' => $token,
'expires_at' => $expires,
'created_by_user_id' => $createdByUserId,
]);
return $id ? $token : null;
}
/**
* Validate token: return row (schedule_id, expires_at) if valid and not expired; null otherwise.
*/
public function validateToken(string $token): ?array
{
$row = $this->where('token', $token)->first();
if (!$row || !is_array($row)) {
return null;
}
$expiresAt = $row['expires_at'] ?? null;
if (!$expiresAt || strtotime($expiresAt) < time()) {
return null;
}
return [
'schedule_id' => (int) $row['schedule_id'],
'expires_at' => $row['expires_at'],
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
/**
* Attendance Module Routes
*
* This file is automatically loaded by ModuleLoader.
* Define your attendance management routes here.
*
* @var RouteCollection $routes
*/
// Attendance routes
$routes->group('api/attendance', ['namespace' => 'App\Modules\Attendance\Controllers'], function ($routes) {
$routes->post('checkin', 'AttendanceController::checkin');
$routes->post('face/enroll', 'FaceLinkController::enroll');
$routes->post('face/resolve', 'FaceLinkController::resolve');
$routes->post('verify-face', 'FaceVerifyController::verify');
$routes->get('reports', 'AttendanceReportController::index');
$routes->get('report/schedule/(:num)', 'AttendanceReportController::scheduleReport/$1');
});

View File

@@ -0,0 +1 @@
# Attendance Module - Services

View File

@@ -0,0 +1,776 @@
<?php
namespace App\Modules\Attendance\Services;
use App\Modules\Academic\Models\ClassModel;
use App\Modules\Academic\Models\ScheduleModel;
use App\Modules\Academic\Models\StudentModel;
use App\Modules\Academic\Models\SubjectModel;
use App\Modules\Academic\Services\ScheduleResolverService;
use App\Modules\Attendance\Entities\AttendanceSession;
use App\Modules\Attendance\Models\AttendanceSessionModel;
use App\Modules\Attendance\Models\QrAttendanceTokenModel;
use App\Modules\Devices\Models\DeviceModel;
use App\Modules\Devices\Services\DeviceAuthService;
use App\Modules\Dashboard\Models\SchoolPresenceSettingsModel;
use App\Modules\Geo\Models\ZoneModel;
use App\Modules\Geo\Services\GeoFenceService;
use App\Modules\Notification\Models\TelegramAccountModel;
use App\Modules\Notification\Services\TelegramBotService;
/**
* Attendance Check-in Service
*
* Handles attendance check-in logic combining device auth, schedule resolution, and geofencing.
* Sends Telegram notification to parents when status is PRESENT or LATE.
*/
class AttendanceCheckinService
{
protected DeviceAuthService $deviceAuthService;
protected ScheduleResolverService $scheduleResolverService;
protected ScheduleModel $scheduleModel;
protected StudentModel $studentModel;
protected GeoFenceService $geoFenceService;
protected ZoneModel $zoneModel;
protected AttendanceSessionModel $attendanceModel;
protected QrAttendanceTokenModel $qrTokenModel;
protected DeviceModel $deviceModel;
protected SchoolPresenceSettingsModel $presenceSettingsModel;
/**
* Device code untuk aplikasi mobile (absen masuk/pulang)
*/
public const MOBILE_APP_DEVICE_CODE = 'MOBILE_APP';
/**
* Late tolerance in minutes (default: 10 minutes)
*/
protected int $lateToleranceMinutes = 10;
/**
* Grace period in seconds after schedule end_time before session is considered closed (default: 30)
*/
protected int $sessionCloseGraceSeconds = 30;
public function __construct()
{
$this->deviceAuthService = new DeviceAuthService();
$this->scheduleResolverService = new ScheduleResolverService();
$this->scheduleModel = new ScheduleModel();
$this->studentModel = new StudentModel();
$this->geoFenceService = new GeoFenceService();
$this->zoneModel = new ZoneModel();
$this->attendanceModel = new AttendanceSessionModel();
$this->qrTokenModel = new QrAttendanceTokenModel();
$this->deviceModel = new DeviceModel();
$this->presenceSettingsModel = new SchoolPresenceSettingsModel();
}
/**
* Process attendance check-in
*
* Schedule resolution and attendance timestamp always use server current time;
* payload "datetime" is ignored.
*
* @param array $payload Payload containing:
* - device_code: string
* - api_key: string
* - student_id: int
* - datetime: string (ignored; server time is used)
* - lat: float
* - lng: float
* - confidence: float|null
* @return array Result with status and attendance session data
*/
public function checkin(array $payload): array
{
// Extract payload (datetime from client is ignored; use server time)
$deviceCode = $payload['device_code'] ?? '';
$apiKey = $payload['api_key'] ?? '';
$studentId = (int) ($payload['student_id'] ?? 0);
$datetime = date('Y-m-d H:i:s');
$lat = isset($payload['lat']) ? (float) $payload['lat'] : 0.0;
$lng = isset($payload['lng']) ? (float) $payload['lng'] : 0.0;
$confidence = isset($payload['confidence']) ? (float) $payload['confidence'] : null;
// Step 1: Authenticate device
$deviceData = $this->deviceAuthService->authenticate($deviceCode, $apiKey);
if (!$deviceData) {
return $this->createResult(
AttendanceSession::STATUS_INVALID_DEVICE,
$studentId,
null,
0,
$datetime,
$lat,
$lng,
$confidence
);
}
$deviceId = $deviceData['device_id'];
// Step 2: Geofence & validasi koordinat — cepat gagal sebelum query jadwal yang berat
$zoneConfig = null;
$zone = $this->zoneModel->findActiveByZoneCode('SMA1-SCHOOL');
if (!$zone) {
$zones = $this->zoneModel->findAllActive();
$zone = !empty($zones) ? $zones[0] : null;
}
if ($zone) {
$zoneConfig = [
'latitude' => $zone->latitude,
'longitude' => $zone->longitude,
'radius_meters' => $zone->radius_meters,
];
}
// Jika koordinat null / 0 (belum diambil) langsung OUTSIDE_ZONE (tanpa insert)
if ($lat === 0.0 && $lng === 0.0) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_OUTSIDE_ZONE,
$studentId,
null,
$deviceId,
$datetime,
$lat,
$lng,
$confidence
);
}
if ($zoneConfig) {
$isInsideZone = $this->geoFenceService->isInsideZone(
$lat,
$lng,
$zoneConfig
);
if (!$isInsideZone) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_OUTSIDE_ZONE,
$studentId,
null,
$deviceId,
$datetime,
$lat,
$lng,
$confidence
);
}
}
// Step 3: Resolve active schedule (start_time <= now < end_time)
$schedule = $this->scheduleResolverService->getActiveSchedule($studentId, $datetime);
if (!$schedule) {
// No active slot: check latest schedule for class today (may already be ended) for SESSION_CLOSED vs NO_SCHEDULE
$student = $this->studentModel->find($studentId);
$classId = $student && $student->class_id ? (int) $student->class_id : 0;
$dayOfWeek = (int) date('N', strtotime($datetime));
$time = date('H:i:s', strtotime($datetime));
$latest = $classId > 0 ? $this->scheduleModel->getLatestScheduleForClassToday($classId, $dayOfWeek, $time) : null;
if ($latest !== null) {
$currentTimeTs = strtotime($datetime);
$dateOnly = date('Y-m-d', $currentTimeTs);
$endTime = $latest['end_time'] ?? '';
$latestEndTs = $endTime !== '' ? strtotime($dateOnly . ' ' . $endTime) : 0;
if ($latestEndTs > 0 && $currentTimeTs > ($latestEndTs + $this->sessionCloseGraceSeconds)) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_SESSION_CLOSED,
$studentId,
$latest['id'],
$deviceId,
$datetime,
$lat,
$lng,
$confidence
);
}
}
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_NO_SCHEDULE,
$studentId,
null,
$deviceId,
$datetime,
$lat,
$lng,
$confidence
);
}
$scheduleId = $schedule['schedule_id'];
// Step 3b: Auto-lock — if current server time is past schedule end_time, reject (uses resolved start_time/end_time from ScheduleResolverService)
$currentTimeTs = strtotime($datetime);
$dateOnly = date('Y-m-d', $currentTimeTs);
$endTime = $schedule['end_time'] ?? '';
if ($endTime !== '') {
$scheduleEndTs = strtotime($dateOnly . ' ' . $endTime);
if ($currentTimeTs > ($scheduleEndTs + $this->sessionCloseGraceSeconds)) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_SESSION_CLOSED,
$studentId,
$scheduleId,
$deviceId,
$datetime,
$lat,
$lng,
$confidence
);
}
}
// Step 4: Attendance window — only allow check-in within [start - open_before, start + close_after] minutes
if (!$this->isWithinAttendanceWindow($datetime, $schedule['start_time'])) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_ABSENCE_WINDOW_CLOSED,
$studentId,
$scheduleId,
$deviceId,
$datetime,
$lat,
$lng,
$confidence
);
}
// Step 6: Duplicate check — one attendance per (student_id, schedule_id, attendance_date); date from server
$attendanceDate = date('Y-m-d');
if ($this->attendanceModel->hasAttendanceFor($studentId, $scheduleId, $attendanceDate)) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_ALREADY_CHECKED_IN,
$studentId,
$scheduleId,
$deviceId,
$datetime,
$lat,
$lng,
$confidence
);
}
// Step 7: Determine if LATE or PRESENT
$status = $this->determineStatus($datetime, $schedule['start_time']);
// Step 8: Save attendance session
return $this->createResult(
$status,
$studentId,
$scheduleId,
$deviceId,
$datetime,
$lat,
$lng,
$confidence
);
}
/**
* Absen masuk atau pulang dari aplikasi mobile (auth NISN+PIN).
* Memakai jam dari Pengaturan Presensi dan zona sekolah.
*
* @param array $payload ['student_id' => int, 'type' => 'masuk'|'pulang', 'lat' => float, 'lng' => float]
* @return array Same shape as checkin(): status, attendance_id, checkin_at, ...
*/
public function checkinMasukPulang(array $payload): array
{
$studentId = (int) ($payload['student_id'] ?? 0);
$type = strtolower(trim((string) ($payload['type'] ?? '')));
$lat = isset($payload['lat']) ? (float) $payload['lat'] : 0.0;
$lng = isset($payload['lng']) ? (float) $payload['lng'] : 0.0;
$datetime = date('Y-m-d H:i:s');
$dateOnly = date('Y-m-d');
if ($studentId < 1) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_NO_SCHEDULE,
$studentId,
null,
0,
$datetime,
$lat,
$lng,
null
);
}
if ($type !== 'masuk' && $type !== 'pulang') {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_NO_SCHEDULE,
$studentId,
null,
0,
$datetime,
$lat,
$lng,
null
);
}
// Device aplikasi mobile (harus ada di dashboard)
$device = $this->deviceModel->findActiveByDeviceCode(self::MOBILE_APP_DEVICE_CODE);
if (! $device) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_INVALID_DEVICE,
$studentId,
null,
0,
$datetime,
$lat,
$lng,
null
);
}
$deviceId = (int) $device->id;
// Koordinat wajib ada dan di dalam zona sebelum cek jam / duplikasi
if ($lat === 0.0 && $lng === 0.0) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_OUTSIDE_ZONE,
$studentId,
null,
$deviceId,
$datetime,
$lat,
$lng,
null
);
}
// Zona sekolah (geofence)
$zone = $this->zoneModel->findActiveByZoneCode('SMA1-SCHOOL');
if (! $zone) {
$zones = $this->zoneModel->findAllActive();
$zone = ! empty($zones) ? $zones[0] : null;
}
if ($zone) {
$zoneConfig = [
'latitude' => $zone->latitude,
'longitude' => $zone->longitude,
'radius_meters' => $zone->radius_meters,
];
if (! $this->geoFenceService->isInsideZone($lat, $lng, $zoneConfig)) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_OUTSIDE_ZONE,
$studentId,
null,
$deviceId,
$datetime,
$lat,
$lng,
null
);
}
}
// Jam masuk/pulang dari pengaturan presensi
$times = $this->presenceSettingsModel->getSettings();
$timeStart = $type === 'masuk' ? ($times['time_masuk_start'] ?? '06:30:00') : ($times['time_pulang_start'] ?? '14:00:00');
$timeEnd = $type === 'masuk' ? ($times['time_masuk_end'] ?? '07:00:00') : ($times['time_pulang_end'] ?? '14:30:00');
$nowTime = date('H:i:s', strtotime($datetime));
if ($nowTime < $timeStart || $nowTime > $timeEnd) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_ABSENCE_WINDOW_CLOSED,
$studentId,
null,
$deviceId,
$datetime,
$lat,
$lng,
null
);
}
// Sudah absen masuk/pulang hari ini?
$db = \Config\Database::connect();
$existing = $db->table('attendance_sessions')
->where('student_id', $studentId)
->where('attendance_date', $dateOnly)
->where('checkin_type', $type)
->limit(1)
->get()
->getRowArray();
if ($existing) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_ALREADY_CHECKED_IN,
$studentId,
null,
$deviceId,
$datetime,
$lat,
$lng,
null
);
}
// Simpan PRESENT
$attendanceData = [
'student_id' => $studentId,
'schedule_id' => null,
'checkin_type' => $type,
'attendance_date' => $dateOnly,
'device_id' => $deviceId,
'checkin_at' => $datetime,
'latitude' => $lat,
'longitude' => $lng,
'confidence' => null,
'status' => AttendanceSession::STATUS_PRESENT,
];
$attendanceId = $this->attendanceModel->insert($attendanceData);
$this->notifyParentsOfCheckin($studentId, null, $datetime, AttendanceSession::STATUS_PRESENT);
return [
'status' => AttendanceSession::STATUS_PRESENT,
'attendance_id' => $attendanceId,
'student_id' => $studentId,
'schedule_id' => null,
'device_id' => $deviceId,
'checkin_at' => $datetime,
'latitude' => $lat,
'longitude' => $lng,
'confidence' => null,
];
}
/**
* Determine attendance status (PRESENT or LATE)
*
* @param string $checkinDatetime Check-in datetime
* @param string $scheduleStartTime Schedule start time (H:i:s format)
* @return string Status (PRESENT or LATE)
*/
protected function determineStatus(string $checkinDatetime, string $scheduleStartTime): string
{
$checkinTimestamp = strtotime($checkinDatetime);
$checkinTime = date('H:i:s', $checkinTimestamp);
// Parse schedule start time
$scheduleParts = explode(':', $scheduleStartTime);
$scheduleHour = (int) $scheduleParts[0];
$scheduleMinute = (int) $scheduleParts[1];
$scheduleSecond = isset($scheduleParts[2]) ? (int) $scheduleParts[2] : 0;
// Create schedule datetime from checkin date
$checkinDate = date('Y-m-d', $checkinTimestamp);
$scheduleDatetime = sprintf(
'%s %02d:%02d:%02d',
$checkinDate,
$scheduleHour,
$scheduleMinute,
$scheduleSecond
);
$scheduleTimestamp = strtotime($scheduleDatetime);
$toleranceSeconds = $this->lateToleranceMinutes * 60;
$lateThreshold = $scheduleTimestamp + $toleranceSeconds;
// Check if check-in time exceeds late threshold
if ($checkinTimestamp > $lateThreshold) {
return AttendanceSession::STATUS_LATE;
}
return AttendanceSession::STATUS_PRESENT;
}
/**
* Check if check-in time is within the configured attendance window.
* Window: [schedule_start - open_before_minutes, schedule_start + close_after_minutes].
*
* @param string $checkinDatetime Check-in datetime (Y-m-d H:i:s)
* @param string $scheduleStartTime Schedule start time (H:i:s)
* @return bool True if within window
*/
protected function isWithinAttendanceWindow(string $checkinDatetime, string $scheduleStartTime): bool
{
$config = config('Attendance');
$openMin = $config->attendanceOpenBeforeMinutes ?? 5;
$closeMin = $config->attendanceCloseAfterMinutes ?? 15;
$checkinTs = strtotime($checkinDatetime);
$date = date('Y-m-d', $checkinTs);
$scheduleParts = explode(':', $scheduleStartTime);
$h = (int) ($scheduleParts[0] ?? 0);
$m = (int) ($scheduleParts[1] ?? 0);
$s = (int) ($scheduleParts[2] ?? 0);
$scheduleStartTs = strtotime(sprintf('%s %02d:%02d:%02d', $date, $h, $m, $s));
$windowOpenTs = $scheduleStartTs - ($openMin * 60);
$windowCloseTs = $scheduleStartTs + ($closeMin * 60);
return $checkinTs >= $windowOpenTs && $checkinTs <= $windowCloseTs;
}
/**
* Check-in via QR token (guru generate QR, siswa scan).
* Payload: student_id, qr_token; optional lat, lng.
* Returns same shape as checkin(): status, attendance_id, checkin_at, etc.
*/
public function checkinByQr(array $payload): array
{
$studentId = (int) ($payload['student_id'] ?? 0);
$qrToken = trim((string) ($payload['qr_token'] ?? ''));
$datetime = date('Y-m-d H:i:s');
$lat = (float) ($payload['lat'] ?? 0);
$lng = (float) ($payload['lng'] ?? 0);
if ($studentId < 1 || $qrToken === '') {
return $this->createResultWithoutInsert(
'INVALID_QR_TOKEN',
$studentId,
null,
0,
$datetime,
$lat,
$lng,
null
);
}
$tokenData = $this->qrTokenModel->validateToken($qrToken);
if (!$tokenData) {
return $this->createResultWithoutInsert(
'INVALID_QR_TOKEN',
$studentId,
null,
0,
$datetime,
$lat,
$lng,
null
);
}
$scheduleId = $tokenData['schedule_id'];
$schedule = $this->scheduleModel->getScheduleWithSlot($scheduleId);
if (!$schedule) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_NO_SCHEDULE,
$studentId,
$scheduleId,
0,
$datetime,
$lat,
$lng,
null
);
}
$student = $this->studentModel->find($studentId);
if (!$student) {
return $this->createResultWithoutInsert(
'INVALID_QR_TOKEN',
$studentId,
$scheduleId,
0,
$datetime,
$lat,
$lng,
null
);
}
$scheduleClassId = (int) $schedule['class_id'];
$studentClassId = (int) ($student->class_id ?? 0);
if ($studentClassId !== $scheduleClassId) {
return $this->createResultWithoutInsert(
'STUDENT_NOT_IN_CLASS',
$studentId,
$scheduleId,
0,
$datetime,
$lat,
$lng,
null
);
}
$attendanceDate = date('Y-m-d');
if ($this->attendanceModel->hasAttendanceFor($studentId, $scheduleId, $attendanceDate)) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_ALREADY_CHECKED_IN,
$studentId,
$scheduleId,
0,
$datetime,
$lat,
$lng,
null
);
}
$mobileDevice = $this->deviceModel->findActiveByDeviceCode('MOBILE_APP');
$deviceId = $mobileDevice ? (int) $mobileDevice->id : 0;
if ($deviceId < 1) {
return $this->createResultWithoutInsert(
AttendanceSession::STATUS_INVALID_DEVICE,
$studentId,
$scheduleId,
0,
$datetime,
$lat,
$lng,
null
);
}
$status = $this->determineStatus($datetime, $schedule['start_time']);
return $this->createResult(
$status,
$studentId,
$scheduleId,
$deviceId,
$datetime,
$lat,
$lng,
1.0
);
}
/**
* Create result and save attendance session
*
* @param string $status Status
* @param int $studentId Student ID
* @param int|null $scheduleId Schedule ID
* @param int $deviceId Device ID
* @param string $datetime Check-in datetime
* @param float $lat Latitude
* @param float $lng Longitude
* @param float|null $confidence Confidence score
* @return array Result array
*/
protected function createResult(
string $status,
int $studentId,
?int $scheduleId,
int $deviceId,
string $datetime,
float $lat,
float $lng,
?float $confidence
): array {
// Save attendance session (attendance_date = server date for unique constraint)
$attendanceData = [
'student_id' => $studentId,
'schedule_id' => $scheduleId,
'attendance_date' => date('Y-m-d'),
'device_id' => $deviceId,
'checkin_at' => $datetime,
'latitude' => $lat,
'longitude' => $lng,
'confidence' => $confidence,
'status' => $status,
];
$attendanceId = $this->attendanceModel->insert($attendanceData);
$result = [
'status' => $status,
'attendance_id' => $attendanceId,
'student_id' => $studentId,
'schedule_id' => $scheduleId,
'device_id' => $deviceId,
'checkin_at' => $datetime,
'latitude' => $lat,
'longitude' => $lng,
'confidence' => $confidence,
];
// Notify parents via Telegram when PRESENT or LATE (do not break flow on failure)
if ($status === AttendanceSession::STATUS_PRESENT || $status === AttendanceSession::STATUS_LATE) {
$this->notifyParentsOfCheckin($studentId, $scheduleId, $datetime, $status);
}
return $result;
}
/**
* Build result array without inserting (for ALREADY_CHECKED_IN, ABSENCE_WINDOW_CLOSED).
*/
protected function createResultWithoutInsert(
string $status,
int $studentId,
?int $scheduleId,
int $deviceId,
string $datetime,
float $lat,
float $lng,
?float $confidence
): array {
return [
'status' => $status,
'attendance_id' => null,
'student_id' => $studentId,
'schedule_id' => $scheduleId,
'device_id' => $deviceId,
'checkin_at' => $datetime,
'latitude' => $lat,
'longitude' => $lng,
'confidence' => $confidence,
];
}
/**
* Send Telegram notification to student's linked parents
*/
protected function notifyParentsOfCheckin(int $studentId, ?int $scheduleId, string $checkinAt, string $status): void
{
try {
$studentModel = new StudentModel();
$classModel = new ClassModel();
$scheduleModel = new ScheduleModel();
$subjectModel = new SubjectModel();
$telegramAccountModel = new TelegramAccountModel();
$telegramBot = new TelegramBotService();
$student = $studentModel->find($studentId);
if (!$student) {
return;
}
$studentName = htmlspecialchars((string) ($student->name ?? '-'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$className = '-';
if ($student->class_id) {
$class = $classModel->find($student->class_id);
$className = $class ? $class->name : '-';
}
$subjectName = '-';
if ($scheduleId) {
$schedule = $scheduleModel->find($scheduleId);
if ($schedule && $schedule->subject_id) {
$subject = $subjectModel->find($schedule->subject_id);
$subjectName = $subject ? htmlspecialchars((string) $subject->name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') : '-';
}
}
$statusLabel = $status === AttendanceSession::STATUS_PRESENT ? 'Hadir' : 'Terlambat';
$timeStr = date('H:i', strtotime($checkinAt)) . ' WIB';
$emojiStatus = $status === AttendanceSession::STATUS_PRESENT ? '✅' : '⏰';
$message = "<b>{$emojiStatus} Absensi SMAN 1 Garut</b>\n\n";
$message .= "Nama: <b>{$studentName}</b>\n";
$message .= "Kelas: " . htmlspecialchars($className, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n";
$message .= "Mapel: " . htmlspecialchars($subjectName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n";
$message .= "Status: {$emojiStatus} <b>{$statusLabel}</b>\n";
$message .= "Waktu: <i>{$timeStr}</i>";
$telegramUserIds = $telegramAccountModel->getTelegramUserIdsByStudentId($studentId);
foreach ($telegramUserIds as $telegramUserId) {
try {
$telegramBot->sendMessage($telegramUserId, $message);
} catch (\Throwable $e) {
log_message('error', 'AttendanceCheckinService notifyParents: sendMessage failed for telegram_user_id=' . $telegramUserId . ' - ' . $e->getMessage());
}
}
} catch (\Throwable $e) {
log_message('error', 'AttendanceCheckinService notifyParents: ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,189 @@
<?php
namespace App\Modules\Attendance\Services;
use App\Modules\Auth\Entities\Role;
use App\Modules\Attendance\Entities\AttendanceSession;
use App\Modules\Academic\Models\ClassModel;
use App\Modules\Academic\Models\ScheduleModel;
use App\Modules\Academic\Models\StudentModel;
use App\Modules\Academic\Models\SubjectModel;
use App\Modules\Attendance\Models\AttendanceSessionModel;
/**
* Attendance Report Service
*
* On-the-fly computation of schedule attendance report (expected, present, late, absent).
* No DB insert for ABSENT. RBAC applied when userContext provided.
*/
class AttendanceReportService
{
protected ScheduleModel $scheduleModel;
protected StudentModel $studentModel;
protected SubjectModel $subjectModel;
protected ClassModel $classModel;
protected AttendanceSessionModel $attendanceModel;
public function __construct()
{
$this->scheduleModel = new ScheduleModel();
$this->studentModel = new StudentModel();
$this->subjectModel = new SubjectModel();
$this->classModel = new ClassModel();
$this->attendanceModel = new AttendanceSessionModel();
}
/**
* Get schedule attendance report for a given date.
* Absent = expected students minus those with PRESENT/LATE record (no insert).
*
* @param int $scheduleId
* @param string $dateYmd Y-m-d
* @param array|null $userContext { id, name, email, roles: [ { role_code } ] } for RBAC
* @return array|null Report array or null if schedule not found / no access
*/
public function getScheduleAttendanceReport(int $scheduleId, string $dateYmd, ?array $userContext = null): ?array
{
$schedule = $this->scheduleModel->getScheduleWithSlot($scheduleId);
if ($schedule === null) {
return null;
}
if ($userContext !== null && !$this->canAccessSchedule($schedule, $userContext)) {
return null;
}
$classId = (int) $schedule['class_id'];
$expectedStudents = $this->studentModel->where('class_id', $classId)->findAll();
$expectedIds = array_map(fn ($s) => (int) $s->id, $expectedStudents);
$expectedTotal = count($expectedIds);
$db = \Config\Database::connect();
$builder = $db->table('attendance_sessions AS a');
$builder->select('a.student_id, s.nisn, s.name, a.status, a.checkin_at');
$builder->join('students AS s', 's.id = a.student_id', 'inner');
$builder->where('a.schedule_id', $scheduleId);
$builder->where('a.attendance_date', $dateYmd);
$builder->whereIn('a.status', [AttendanceSession::STATUS_PRESENT, AttendanceSession::STATUS_LATE]);
$builder->orderBy('s.name', 'ASC');
$rows = $builder->get()->getResultArray();
$presentList = [];
$presentIds = [];
$lateTotal = 0;
foreach ($rows as $row) {
$sid = (int) $row['student_id'];
$presentIds[] = $sid;
$presentList[] = [
'student_id' => $sid,
'nisn' => (string) ($row['nisn'] ?? ''),
'name' => (string) ($row['name'] ?? ''),
'status' => (string) $row['status'],
'checkin_at' => (string) $row['checkin_at'],
];
if ($row['status'] === AttendanceSession::STATUS_LATE) {
$lateTotal++;
}
}
$presentTotal = count($presentList);
$absentIds = array_diff($expectedIds, $presentIds);
$absentList = [];
foreach ($expectedStudents as $s) {
if (in_array((int) $s->id, $absentIds, true)) {
$absentList[] = [
'student_id' => (int) $s->id,
'nisn' => (string) ($s->nisn ?? ''),
'name' => (string) ($s->name ?? ''),
];
}
}
usort($absentList, fn ($a, $b) => strcmp($a['name'], $b['name']));
$absentTotal = count($absentList);
$subject = $this->subjectModel->find($schedule['subject_id']);
$subjectName = $subject ? (string) $subject->name : '-';
$classEntity = $this->classModel->find($classId);
$className = $classEntity ? (string) $classEntity->name : '-';
$schedulePayload = [
'id' => (int) $schedule['id'],
'class_id' => (int) $schedule['class_id'],
'class_name' => $className,
'subject' => $subjectName,
'teacher' => (string) ($schedule['teacher_name'] ?? ''),
'start_time' => (string) $schedule['start_time'],
'end_time' => (string) $schedule['end_time'],
'day_of_week' => (int) $schedule['day_of_week'],
];
return [
'schedule' => $schedulePayload,
'summary' => [
'expected_total' => $expectedTotal,
'present_total' => $presentTotal,
'late_total' => $lateTotal,
'absent_total' => $absentTotal,
],
'present' => $presentList,
'absent' => $absentList,
];
}
/**
* RBAC: whether user can access this schedule.
* @param array|object $schedule Schedule row (array from getScheduleWithSlot) or entity with class_id, teacher_user_id
*/
protected function canAccessSchedule(array|object $schedule, array $user): bool
{
$roles = $user['roles'] ?? [];
$roleCodes = array_column($roles, 'role_code');
if (in_array(Role::CODE_ADMIN, $roleCodes, true) || in_array(Role::CODE_GURU_BK, $roleCodes, true)) {
return true;
}
$classId = (int) (is_array($schedule) ? $schedule['class_id'] : $schedule->class_id);
if (in_array(Role::CODE_WALI_KELAS, $roleCodes, true)) {
$db = \Config\Database::connect();
$row = $db->table('classes')->select('id')->where('id', $classId)->where('wali_user_id', $user['id'])->get()->getRow();
return $row !== null;
}
if (in_array(Role::CODE_GURU_MAPEL, $roleCodes, true)) {
$teacherUserId = is_array($schedule)
? (isset($schedule['teacher_user_id']) ? (int) $schedule['teacher_user_id'] : 0)
: (isset($schedule->teacher_user_id) ? (int) $schedule->teacher_user_id : 0);
return $teacherUserId === (int) $user['id'];
}
if (in_array(Role::CODE_ORANG_TUA, $roleCodes, true)) {
$studentIds = $this->getStudentIdsForParent((int) $user['id']);
if ($studentIds === []) {
return false;
}
$db = \Config\Database::connect();
$overlap = $db->table('students')
->where('class_id', $classId)
->whereIn('id', $studentIds)
->countAllResults();
return $overlap > 0;
}
return false;
}
protected function getStudentIdsForParent(int $userId): array
{
$db = \Config\Database::connect();
$rows = $db->table('student_parents AS sp')
->select('sp.student_id')
->join('parents AS p', 'p.id = sp.parent_id', 'inner')
->where('p.user_id', $userId)
->get()
->getResultArray();
return array_map('intval', array_column($rows, 'student_id'));
}
}

View File

@@ -0,0 +1 @@
# Auth Module - Controllers

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Modules\Auth\Controllers;
use App\Core\BaseApiController;
use App\Modules\Auth\Services\AuthService;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Auth Controller
*
* POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me (session-based).
*/
class AuthController extends BaseApiController
{
protected AuthService $authService;
public function __construct()
{
$this->authService = new AuthService();
}
/**
* POST /api/auth/login
* Body: { "email": "", "password": "" }
*/
public function login(): ResponseInterface
{
$input = $this->request->getJSON(true);
$email = $input['email'] ?? '';
$password = $input['password'] ?? '';
if ($email === '' || $password === '') {
return $this->errorResponse('Email and password are required', null, null, 400);
}
$user = $this->authService->login($email, $password);
if (!$user) {
return $this->errorResponse('Invalid email or password', null, null, 401);
}
return $this->successResponse($user, 'Login successful');
}
/**
* POST /api/auth/logout
*/
public function logout(): ResponseInterface
{
$this->authService->logout();
return $this->successResponse(null, 'Logged out');
}
/**
* GET /api/auth/me
*/
public function me(): ResponseInterface
{
$user = $this->authService->currentUser();
if (!$user) {
return $this->errorResponse('Not authenticated', null, null, 401);
}
return $this->successResponse($user, 'Current user');
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace App\Modules\Auth\Controllers;
use App\Core\BaseApiController;
use App\Modules\Auth\Models\RoleModel;
use App\Modules\Auth\Models\UserModel;
use App\Modules\Auth\Models\UserRoleModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* User API (ADMIN only). List by role; create/update/delete users (for teachers).
*/
class UserController extends BaseApiController
{
/**
* GET /api/users?role=GURU_MAPEL
* Returns users with optional role filter. Response: [{ id, name, email }, ...]
*/
public function index(): ResponseInterface
{
$roleCode = $this->request->getGet('role');
if ($roleCode === null || $roleCode === '') {
return $this->errorResponse('Query parameter role is required', null, null, 422);
}
$roleModel = new RoleModel();
$role = $roleModel->findByCode((string) $roleCode);
if (!$role) {
return $this->successResponse([], 'Users');
}
$userRoleModel = new UserRoleModel();
$db = \Config\Database::connect();
$builder = $db->table('user_roles');
$builder->select('user_id');
$builder->where('role_id', $role->id);
$rows = $builder->get()->getResultArray();
$userIdList = array_values(array_unique(array_map(static fn ($r) => (int) $r['user_id'], $rows)));
if ($userIdList === []) {
return $this->successResponse([], 'Users');
}
$userModel = new UserModel();
$users = $userModel->whereIn('id', $userIdList)->findAll();
$data = [];
foreach ($users as $u) {
$data[] = [
'id' => (int) $u->id,
'name' => (string) $u->name,
'email' => (string) $u->email,
];
}
usort($data, static fn ($a, $b) => strcasecmp($a['name'], $b['name']));
return $this->successResponse($data, 'Users');
}
/**
* POST /api/users
* Body (baru): name, email, password, roles[] (isi: GURU_MAPEL dan/atau WALI_KELAS).
* Body (lama, tetap didukung): name, email, password, role_code.
*/
public function store(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$name = trim($payload['name'] ?? '');
$email = trim($payload['email'] ?? '');
$password = $payload['password'] ?? '';
$rolesInput = $payload['roles'] ?? null;
$roleCode = $payload['role_code'] ?? null; // fallback lama
if ($name === '' || $email === '' || $password === '') {
return $this->errorResponse('Name, email, and password are required', null, null, 422);
}
$roleModel = new RoleModel();
$allowedCodes = ['GURU_MAPEL', 'WALI_KELAS'];
// Normalisasi roles: jika roles[] tidak ada, pakai role_code tunggal (kompatibilitas lama)
$roleCodes = [];
if (is_array($rolesInput)) {
foreach ($rolesInput as $rc) {
$rc = (string) $rc;
if (in_array($rc, $allowedCodes, true)) {
$roleCodes[] = $rc;
}
}
$roleCodes = array_values(array_unique($roleCodes));
} elseif (is_string($roleCode) && $roleCode !== '') {
if (! in_array($roleCode, $allowedCodes, true)) {
return $this->errorResponse('role_code must be GURU_MAPEL or WALI_KELAS', null, null, 422);
}
$roleCodes = [$roleCode];
}
if ($roleCodes === []) {
return $this->errorResponse('At least one role (GURU_MAPEL or WALI_KELAS) is required', null, null, 422);
}
$userModel = new UserModel();
$existing = $userModel->findByEmail($email);
if ($existing) {
return $this->errorResponse('Email already registered', null, null, 422);
}
$userModel->skipValidation(false);
$id = $userModel->insert([
'name' => $name,
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'is_active' => 1,
]);
if ($id === false) {
return $this->errorResponse(implode(' ', $userModel->errors()), $userModel->errors(), null, 422);
}
$userRoleModel = new UserRoleModel();
foreach ($roleCodes as $code) {
$role = $roleModel->findByCode($code);
if ($role) {
$userRoleModel->insert(['user_id' => $id, 'role_id' => $role->id]);
}
}
$user = $userModel->find($id);
return $this->successResponse([
'id' => (int) $user->id,
'name' => (string) $user->name,
'email' => (string) $user->email,
], 'User created', null, ResponseInterface::HTTP_CREATED);
}
/**
* PUT /api/users/{id}
* Body (baru): name?, email?, password?, roles[] (GURU_MAPEL/WALI_KELAS)
* Body (lama, tetap didukung): role_code tunggal.
*/
public function update(int $id): ResponseInterface
{
$userModel = new UserModel();
$user = $userModel->find($id);
if (! $user) {
return $this->errorResponse('User not found', null, null, 404);
}
$payload = $this->request->getJSON(true) ?? [];
$data = [];
if (array_key_exists('name', $payload)) {
$data['name'] = trim($payload['name']);
}
if (array_key_exists('email', $payload)) {
$data['email'] = trim($payload['email']);
}
if (isset($payload['password']) && $payload['password'] !== '') {
$data['password_hash'] = password_hash($payload['password'], PASSWORD_DEFAULT);
}
if ($data !== []) {
$validation = \Config\Services::validation();
$rules = [
'name' => 'required|max_length[255]',
'email' => 'required|valid_email|max_length[255]|is_unique[users.email,id,' . $id . ']',
];
$toValidate = [
'name' => $data['name'] ?? $user->name,
'email' => $data['email'] ?? $user->email,
];
if (! $validation->setRules($rules)->run($toValidate)) {
return $this->errorResponse(implode(' ', $validation->getErrors()), $validation->getErrors(), null, 422);
}
$userModel->skipValidation(true);
$userModel->update($id, $data);
}
// Sinkronisasi roles
$rolesInput = $payload['roles'] ?? null;
$roleCode = $payload['role_code'] ?? null; // fallback lama
$allowedCodes = ['GURU_MAPEL', 'WALI_KELAS'];
$roleCodes = null;
if (is_array($rolesInput)) {
$roleCodes = [];
foreach ($rolesInput as $rc) {
$rc = (string) $rc;
if (in_array($rc, $allowedCodes, true)) {
$roleCodes[] = $rc;
}
}
$roleCodes = array_values(array_unique($roleCodes));
} elseif (isset($roleCode) && in_array($roleCode, $allowedCodes, true)) {
// kompatibilitas lama: satu role_code
$roleCodes = [$roleCode];
}
if (is_array($roleCodes)) {
$roleModel = new RoleModel();
$userRoleModel = new UserRoleModel();
$db = \Config\Database::connect();
// Hapus semua role sebelumnya, lalu insert sesuai request
$db->table('user_roles')->where('user_id', $id)->delete();
foreach ($roleCodes as $code) {
$role = $roleModel->findByCode($code);
if ($role) {
$userRoleModel->insert(['user_id' => $id, 'role_id' => $role->id]);
}
}
}
$updated = $userModel->find($id);
return $this->successResponse([
'id' => (int) $updated->id,
'name' => (string) $updated->name,
'email' => (string) $updated->email,
], 'User updated');
}
/**
* DELETE /api/users/{id}
*/
public function delete(int $id): ResponseInterface
{
$userModel = new UserModel();
if (! $userModel->find($id)) {
return $this->errorResponse('User not found', null, null, 404);
}
$userModel->delete($id);
return $this->successResponse(null, 'User deleted');
}
}

View File

@@ -0,0 +1 @@
# Auth Module - Entities

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Modules\Auth\Entities;
use CodeIgniter\Entity\Entity;
/**
* Role Entity
*/
class Role extends Entity
{
public const CODE_ADMIN = 'ADMIN';
public const CODE_WALI_KELAS = 'WALI_KELAS';
public const CODE_GURU_BK = 'GURU_BK';
public const CODE_GURU_MAPEL = 'GURU_MAPEL';
public const CODE_ORANG_TUA = 'ORANG_TUA';
protected $allowedFields = [
'role_code',
'role_name',
];
protected $casts = [
'id' => 'integer',
];
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Modules\Auth\Entities;
use CodeIgniter\Entity\Entity;
/**
* User Entity
*/
class User extends Entity
{
protected $allowedFields = [
'name',
'email',
'password_hash',
'is_active',
];
protected $casts = [
'id' => 'integer',
'is_active' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function isActive(): bool
{
return (bool) ($this->attributes['is_active'] ?? true);
}
}

View File

@@ -0,0 +1 @@
# Auth Module - Models

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Modules\Auth\Models;
use App\Modules\Auth\Entities\Role;
use CodeIgniter\Model;
/**
* Role Model
*/
class RoleModel extends Model
{
protected $table = 'roles';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = Role::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'role_code',
'role_name',
];
protected $useTimestamps = false;
protected $validationRules = [
'role_code' => 'required|max_length[50]|is_unique[roles.role_code,id,{id}]',
'role_name' => 'required|max_length[100]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
public function findByCode(string $code): ?Role
{
return $this->where('role_code', $code)->first();
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Modules\Auth\Models;
use App\Modules\Auth\Entities\User;
use CodeIgniter\Model;
/**
* User Model
*/
class UserModel extends Model
{
protected $table = 'users';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = User::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'name',
'email',
'password_hash',
'is_active',
];
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $validationRules = [
'name' => 'required|max_length[255]',
'email' => 'required|valid_email|max_length[255]|is_unique[users.email,id,{id}]',
'password_hash' => 'required|max_length[255]',
'is_active' => 'permit_empty|in_list[0,1]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
public function findByEmail(string $email): ?User
{
return $this->where('email', $email)->first();
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Modules\Auth\Models;
use CodeIgniter\Model;
/**
* User Role Model (pivot)
*/
class UserRoleModel extends Model
{
protected $table = 'user_roles';
// Use a simple primary key to avoid composite PK issues in Model internals.
// Database level tetap punya primary key (user_id, role_id) via migration.
protected $primaryKey = 'user_id';
protected $useAutoIncrement = false;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'user_id',
'role_id',
];
protected $useTimestamps = false;
/**
* Get role IDs for a user
*
* @param int $userId
* @return array<int>
*/
public function getRoleIdsForUser(int $userId): array
{
$rows = $this->where('user_id', $userId)->findAll();
$ids = [];
foreach ($rows as $row) {
$ids[] = (int) $row['role_id'];
}
return $ids;
}
}

View File

@@ -0,0 +1,17 @@
<?php
/**
* Auth Module Routes
*
* This file is automatically loaded by ModuleLoader.
* Define your authentication routes here.
*
* @var RouteCollection $routes
*/
// Auth routes (session-based)
$routes->group('api/auth', ['namespace' => 'App\Modules\Auth\Controllers'], function ($routes) {
$routes->post('login', 'AuthController::login');
$routes->post('logout', 'AuthController::logout');
$routes->get('me', 'AuthController::me');
});

View File

@@ -0,0 +1 @@
# Auth Module - Services

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Modules\Auth\Services;
use App\Modules\Auth\Models\RoleModel;
use App\Modules\Auth\Models\UserModel;
use App\Modules\Auth\Models\UserRoleModel;
/**
* Auth Service
*
* Login / logout / currentUser using PHP session.
*/
class AuthService
{
public const SESSION_USER_ID = 'auth_user_id';
protected UserModel $userModel;
protected RoleModel $roleModel;
protected UserRoleModel $userRoleModel;
public function __construct()
{
$this->userModel = new UserModel();
$this->roleModel = new RoleModel();
$this->userRoleModel = new UserRoleModel();
}
/**
* Login with email and password.
*
* @param string $email
* @param string $password
* @return array|null User data + roles, or null on failure
*/
public function login(string $email, string $password): ?array
{
$user = $this->userModel->findByEmail($email);
if (!$user || !$user->isActive()) {
return null;
}
if (!password_verify($password, $user->password_hash)) {
return null;
}
$session = session();
$session->set(self::SESSION_USER_ID, $user->id);
return $this->userWithRoles($user);
}
/**
* Logout (destroy session auth data).
*/
public function logout(): void
{
$session = session();
$session->remove(self::SESSION_USER_ID);
}
/**
* Get current logged-in user with roles, or null.
*
* @return array|null { id, name, email, roles: [ role_code, role_name ] }
*/
public function currentUser(): ?array
{
$session = session();
$userId = $session->get(self::SESSION_USER_ID);
if (!$userId) {
return null;
}
$user = $this->userModel->find($userId);
if (!$user || !$user->isActive()) {
$session->remove(self::SESSION_USER_ID);
return null;
}
return $this->userWithRoles($user);
}
/**
* Build user array with roles (no password).
*/
protected function userWithRoles($user): array
{
$roleIds = $this->userRoleModel->getRoleIdsForUser($user->id);
$roles = [];
foreach ($roleIds as $roleId) {
$role = $this->roleModel->find($roleId);
if ($role) {
$roles[] = [
'role_code' => $role->role_code,
'role_name' => $role->role_name,
];
}
}
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'roles' => $roles,
];
}
}

View File

@@ -0,0 +1 @@
# Dashboard Module - Controllers

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Modules\Dashboard\Controllers;
use App\Core\BaseApiController;
use App\Modules\Auth\Services\AuthService;
use App\Modules\Dashboard\Services\DashboardScheduleService;
use App\Modules\Attendance\Entities\AttendanceSession;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Dashboard Attendance API (e.g. live progress for current lesson).
*/
class DashboardAttendanceController extends BaseApiController
{
/**
* GET /api/dashboard/attendance/progress/current
*
* Returns live attendance progress for the current schedule (if any).
* expected_total = students in class; present/late from attendance_sessions today; absent = expected - (present + late).
*/
public function progressCurrent(): ResponseInterface
{
$authService = new AuthService();
$userContext = $authService->currentUser();
$scheduleService = new DashboardScheduleService();
$current = $scheduleService->getCurrentSchedule($userContext);
if (empty($current['is_active_now']) || empty($current['schedule_id'])) {
return $this->successResponse(['active' => false], 'No active schedule');
}
$scheduleId = (int) $current['schedule_id'];
$classId = (int) ($current['class_id'] ?? 0);
$tz = new \DateTimeZone('Asia/Jakarta');
$today = (new \DateTimeImmutable('now', $tz))->format('Y-m-d');
$db = \Config\Database::connect();
$expectedTotal = 0;
if ($classId > 0) {
$expectedTotal = $db->table('students')->where('class_id', $classId)->countAllResults();
}
$presentTotal = $db->table('attendance_sessions')
->where('schedule_id', $scheduleId)
->where('attendance_date', $today)
->where('status', AttendanceSession::STATUS_PRESENT)
->countAllResults();
$lateTotal = $db->table('attendance_sessions')
->where('schedule_id', $scheduleId)
->where('attendance_date', $today)
->where('status', AttendanceSession::STATUS_LATE)
->countAllResults();
$absentTotal = $expectedTotal - ($presentTotal + $lateTotal);
if ($absentTotal < 0) {
$absentTotal = 0;
}
$data = [
'active' => true,
'subject_name' => (string) ($current['subject_name'] ?? '-'),
'class_name' => (string) ($current['class_name'] ?? '-'),
'expected_total' => $expectedTotal,
'present_total' => $presentTotal,
'late_total' => $lateTotal,
'absent_total' => $absentTotal,
];
return $this->successResponse($data, 'Attendance progress');
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Modules\Dashboard\Controllers;
use App\Core\BaseApiController;
use App\Modules\Dashboard\Services\DashboardService;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Dashboard Controller
*
* API endpoints for monitoring attendance system.
*/
class DashboardController extends BaseApiController
{
protected DashboardService $dashboardService;
public function __construct()
{
$this->dashboardService = new DashboardService();
}
/**
* GET /api/dashboard/summary
*
* @return ResponseInterface
*/
public function summary(): ResponseInterface
{
$data = $this->dashboardService->getSummary();
return $this->successResponse($data, 'Dashboard summary');
}
/**
* GET /api/dashboard/realtime
*
* @return ResponseInterface
*/
public function realtime(): ResponseInterface
{
$data = $this->dashboardService->getRealtimeCheckins(20);
return $this->successResponse($data, 'Last 20 check-ins');
}
/**
* GET /api/dashboard/devices
*
* @return ResponseInterface
*/
public function devices(): ResponseInterface
{
$data = $this->dashboardService->getDevices();
return $this->successResponse($data, 'Device monitoring');
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Modules\Dashboard\Controllers;
use App\Core\BaseApiController;
use App\Modules\Auth\Services\AuthService;
use App\Modules\Dashboard\Services\DashboardScheduleService;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Dashboard Schedule Controller
*
* API for today's schedules (role-filtered).
*/
class DashboardScheduleController extends BaseApiController
{
protected DashboardScheduleService $scheduleService;
protected AuthService $authService;
public function __construct()
{
$this->scheduleService = new DashboardScheduleService();
$this->authService = new AuthService();
}
/**
* GET /api/dashboard/schedules/today
*
* Returns today's schedules (day_of_week from Asia/Jakarta) filtered by current user role.
*
* @return ResponseInterface
*/
public function today(): ResponseInterface
{
$userContext = $this->authService->currentUser();
$schedules = $this->scheduleService->getSchedulesToday($userContext);
return $this->successResponse($schedules, 'Today\'s schedules');
}
/**
* GET /api/dashboard/schedules/by-date?date=YYYY-MM-DD
*
* Schedules for the given date (day_of_week from date in Asia/Jakarta). Role-filtered.
*
* @return ResponseInterface
*/
public function byDate(): ResponseInterface
{
$date = $this->request->getGet('date');
if ($date === null || $date === '') {
return $this->errorResponse('Query parameter date is required (Y-m-d)', null, null, 422);
}
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($dt === false || $dt->format('Y-m-d') !== $date) {
return $this->errorResponse('Invalid date format. Use Y-m-d.', null, null, 422);
}
$userContext = $this->authService->currentUser();
$schedules = $this->scheduleService->getSchedulesByDate($date, $userContext);
return $this->successResponse($schedules, 'Schedules for date');
}
/**
* GET /api/dashboard/schedules/current
*
* Current lesson (active now) or next upcoming today. Asia/Jakarta. Role-filtered.
*
* @return ResponseInterface
*/
public function current(): ResponseInterface
{
$userContext = $this->authService->currentUser();
$data = $this->scheduleService->getCurrentSchedule($userContext);
return $this->successResponse($data, 'Current schedule');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Modules\Dashboard\Controllers;
use App\Core\BaseApiController;
/**
* Health Controller
*
* Provides health check endpoint for API monitoring.
*/
class HealthController extends BaseApiController
{
/**
* Health check endpoint
*
* Returns API status and service information.
*
* @return ResponseInterface
*/
public function index()
{
$data = [
'service' => 'SMAN1 Attendance API',
'status' => 'ok',
];
return $this->successResponse(
$data,
'API is running'
);
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Modules\Dashboard\Controllers;
use App\Core\BaseApiController;
use App\Modules\Dashboard\Models\SchoolPresenceSettingsModel;
use App\Modules\Geo\Models\ZoneModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* API: Pengaturan Presensi terpusat (zona sekolah + jam masuk/pulang).
* GET /api/dashboard/presence-settings
* PUT /api/dashboard/presence-settings
*/
class PresenceSettingsController extends BaseApiController
{
protected const ZONE_CODE = 'SMA1-SCHOOL';
/**
* GET /api/dashboard/presence-settings
* Returns zone (lat, lng, radius) + jam masuk/pulang.
*/
public function index(): ResponseInterface
{
$zoneModel = new ZoneModel();
$settingsModel = new SchoolPresenceSettingsModel();
$zone = $zoneModel->findActiveByZoneCode(self::ZONE_CODE);
if (!$zone) {
$zones = $zoneModel->findAllActive();
$zone = !empty($zones) ? $zones[0] : null;
}
$zoneData = null;
if ($zone) {
$zoneData = [
'zone_code' => $zone->zone_code,
'zone_name' => $zone->zone_name,
'latitude' => (float) $zone->latitude,
'longitude' => (float) $zone->longitude,
'radius_meters' => (int) $zone->radius_meters,
];
} else {
$zoneData = [
'zone_code' => self::ZONE_CODE,
'zone_name' => 'Zona Sekolah',
'latitude' => 0.0,
'longitude' => 0.0,
'radius_meters' => 150,
];
}
$times = $settingsModel->getSettings();
$data = [
'zone' => $zoneData,
'times' => $times,
];
return $this->successResponse($data, 'Pengaturan presensi');
}
/**
* PUT /api/dashboard/presence-settings
* Body: { zone: { latitude, longitude, radius_meters }, times: { time_masuk_start, time_masuk_end, time_pulang_start, time_pulang_end } }
*/
public function update(): ResponseInterface
{
$body = $this->request->getJSON(true) ?? [];
$zoneModel = new ZoneModel();
$settingsModel = new SchoolPresenceSettingsModel();
$zone = $zoneModel->findByZoneCode(self::ZONE_CODE);
$zoneInput = $body['zone'] ?? [];
if (!empty($zoneInput)) {
$lat = isset($zoneInput['latitude']) ? (float) $zoneInput['latitude'] : null;
$lng = isset($zoneInput['longitude']) ? (float) $zoneInput['longitude'] : null;
$radius = isset($zoneInput['radius_meters']) ? (int) $zoneInput['radius_meters'] : null;
if ($lat !== null && $lng !== null && $radius !== null && $radius > 0) {
$zonePayload = [
'latitude' => $lat,
'longitude' => $lng,
'radius_meters' => $radius,
'zone_name' => $zoneInput['zone_name'] ?? 'Zona Sekolah',
'is_active' => 1,
];
if ($zone) {
$zoneModel->update($zone->id, $zonePayload);
} else {
$zonePayload['zone_code'] = self::ZONE_CODE;
$zoneModel->insert($zonePayload);
}
}
}
$timesInput = $body['times'] ?? [];
if (!empty($timesInput)) {
$settingsModel->saveSettings([
'time_masuk_start' => $timesInput['time_masuk_start'] ?? null,
'time_masuk_end' => $timesInput['time_masuk_end'] ?? null,
'time_pulang_start' => $timesInput['time_pulang_start'] ?? null,
'time_pulang_end' => $timesInput['time_pulang_end'] ?? null,
]);
}
$z = $zoneModel->findActiveByZoneCode(self::ZONE_CODE);
$data = [
'zone' => $z ? [
'zone_code' => $z->zone_code,
'latitude' => (float) $z->latitude,
'longitude' => (float) $z->longitude,
'radius_meters' => (int) $z->radius_meters,
] : null,
'times' => $settingsModel->getSettings(),
];
return $this->successResponse($data, 'Pengaturan presensi berhasil disimpan');
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Modules\Dashboard\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\ClassModel;
use App\Modules\Academic\Models\ScheduleModel;
use App\Modules\Academic\Models\SubjectModel;
use App\Modules\Auth\Entities\Role;
use App\Modules\Auth\Services\AuthService;
use App\Modules\Attendance\Models\QrAttendanceTokenModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Generate QR token untuk absen mapel (guru tampilkan QR, siswa scan).
* POST /api/dashboard/qr-attendance/generate
* Body: { schedule_id: number }
*/
class QrAttendanceController extends BaseApiController
{
protected AuthService $authService;
public function __construct()
{
$this->authService = new AuthService();
}
/**
* Generate token untuk schedule_id. Hanya guru mapel jadwal tersebut / admin / wali kelas.
*/
public function generate(): ResponseInterface
{
$user = $this->authService->currentUser();
if (!$user) {
return $this->errorResponse('Unauthorized', null, null, 401);
}
$payload = $this->request->getJSON(true) ?? [];
$scheduleId = (int) ($payload['schedule_id'] ?? 0);
if ($scheduleId < 1) {
return $this->errorResponse('schedule_id wajib diisi', null, null, 400);
}
$scheduleModel = new ScheduleModel();
$schedule = $scheduleModel->getScheduleWithSlot($scheduleId);
if (!$schedule) {
return $this->errorResponse('Jadwal tidak ditemukan', null, null, 404);
}
if (!$this->canAccessSchedule($schedule, $user)) {
return $this->errorResponse('Anda tidak punya akses untuk jadwal ini', null, null, 403);
}
$qrModel = new QrAttendanceTokenModel();
$token = $qrModel->generateForSchedule($scheduleId, (int) $user['id']);
if (!$token) {
return $this->errorResponse('Gagal generate token', null, null, 500);
}
$subjectName = '-';
$className = '-';
if (!empty($schedule['subject_id'])) {
$subject = (new SubjectModel())->find($schedule['subject_id']);
$subjectName = $subject ? (string) $subject->name : '-';
}
if (!empty($schedule['class_id'])) {
$class = (new ClassModel())->find($schedule['class_id']);
$className = $class ? (trim($class->grade . ' ' . $class->major . ' ' . $class->name) ?: ('Kelas #' . $class->id)) : '-';
}
$expiresAt = date('Y-m-d H:i:s', strtotime('+' . QrAttendanceTokenModel::VALID_MINUTES . ' minutes'));
$data = [
'token' => $token,
'expires_at' => $expiresAt,
'schedule_id' => $scheduleId,
'subject_name' => $subjectName,
'class_name' => $className,
'valid_minutes' => QrAttendanceTokenModel::VALID_MINUTES,
];
return $this->successResponse($data, 'QR token berhasil dibuat. Tampilkan QR untuk siswa scan.');
}
protected function canAccessSchedule(array $schedule, array $user): bool
{
$roleCodes = array_column($user['roles'] ?? [], 'role_code');
if (in_array(Role::CODE_ADMIN, $roleCodes, true) || in_array(Role::CODE_GURU_BK, $roleCodes, true)) {
return true;
}
$classId = (int) ($schedule['class_id'] ?? 0);
if (in_array(Role::CODE_WALI_KELAS, $roleCodes, true)) {
$row = \Config\Database::connect()
->table('classes')
->select('id')
->where('id', $classId)
->where('wali_user_id', $user['id'])
->get()
->getRow();
return $row !== null;
}
if (in_array(Role::CODE_GURU_MAPEL, $roleCodes, true)) {
$teacherUserId = (int) ($schedule['teacher_user_id'] ?? 0);
return $teacherUserId === (int) $user['id'];
}
return false;
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Modules\Dashboard\Controllers;
use App\Core\BaseApiController;
use App\Modules\Auth\Services\AuthService;
use App\Modules\Dashboard\Services\DashboardRealtimeService;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Realtime Stream Controller
*
* Server-Sent Events (SSE) for live attendance updates.
* Role-aware: stream content is filtered by current user's roles (ADMIN / WALI_KELAS / GURU_MAPEL / ORANG_TUA).
*/
class RealtimeStreamController extends BaseApiController
{
protected DashboardRealtimeService $realtimeService;
protected AuthService $authService;
/** Stream interval in seconds */
protected int $intervalSeconds = 2;
/** Max stream duration in seconds */
protected int $timeoutSeconds = 60;
public function __construct()
{
$this->realtimeService = new DashboardRealtimeService();
$this->authService = new AuthService();
}
/**
* GET /api/dashboard/stream
*
* SSE stream: every 2s check for new attendance, send event; timeout after 60s.
* Uses after_id for incremental feed. User context from session applied for role filtering.
*
* @return ResponseInterface
*/
public function index(): ResponseInterface
{
ignore_user_abort(true);
set_time_limit(0);
while (ob_get_level() > 0) {
ob_end_flush();
}
$this->response->setHeader('Content-Type', 'text/event-stream');
$this->response->setHeader('Cache-Control', 'no-cache');
$this->response->setHeader('Connection', 'keep-alive');
$this->response->setHeader('X-Accel-Buffering', 'no'); // nginx
$this->response->setBody('');
if (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
}
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', false);
$this->response->sendHeaders();
$currentUser = $this->authService->currentUser();
$startTime = time();
$lastId = (int) ($this->request->getGet('after_id') ?? 0);
while (true) {
if (time() - $startTime >= $this->timeoutSeconds) {
$this->sendEvent('timeout', ['message' => 'Stream ended after ' . $this->timeoutSeconds . 's']);
break;
}
$rows = $this->realtimeService->getAttendanceSinceId($lastId, 50, $currentUser);
if (empty($rows)) {
$this->sendEvent('heartbeat', ['ts' => gmdate('Y-m-d\TH:i:s\Z')]);
} else {
foreach ($rows as $row) {
$this->sendEvent('attendance', $row);
$lastId = max($lastId, $row['id']);
}
}
flush();
sleep($this->intervalSeconds);
}
return $this->response;
}
/**
* Send one SSE event (event name + data line)
*
* @param string $event Event name
* @param array $data Data to send as JSON
*/
protected function sendEvent(string $event, array $data): void
{
echo 'event: ' . $event . "\n";
echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
}
}

View File

@@ -0,0 +1 @@
# Dashboard Module - Entities

View File

@@ -0,0 +1 @@
# Dashboard Module - Models

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Modules\Dashboard\Models;
use CodeIgniter\Model;
/**
* Pengaturan presensi terpusat: jam masuk & jam pulang sekolah.
* Satu row saja (id=1).
*/
class SchoolPresenceSettingsModel extends Model
{
protected $table = 'school_presence_settings';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $allowedFields = [
'time_masuk_start',
'time_masuk_end',
'time_pulang_start',
'time_pulang_end',
];
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $validationRules = [
'time_masuk_start' => 'permit_empty|valid_date[H:i:s]',
'time_masuk_end' => 'permit_empty|valid_date[H:i:s]',
'time_pulang_start' => 'permit_empty|valid_date[H:i:s]',
'time_pulang_end' => 'permit_empty|valid_date[H:i:s]',
];
/** ID row default */
public const DEFAULT_ID = 1;
/**
* Ambil satu row pengaturan (id=1). Jika belum ada, return default.
*/
public function getSettings(): array
{
$row = $this->find(self::DEFAULT_ID);
if ($row && is_object($row)) {
$row = (array) $row;
}
if (empty($row)) {
return [
'time_masuk_start' => '06:30:00',
'time_masuk_end' => '07:00:00',
'time_pulang_start' => '14:00:00',
'time_pulang_end' => '14:30:00',
];
}
return [
'time_masuk_start' => $row['time_masuk_start'] ?? '06:30:00',
'time_masuk_end' => $row['time_masuk_end'] ?? '07:00:00',
'time_pulang_start' => $row['time_pulang_start'] ?? '14:00:00',
'time_pulang_end' => $row['time_pulang_end'] ?? '14:30:00',
];
}
/**
* Simpan pengaturan (upsert id=1).
*/
public function saveSettings(array $data): bool
{
$row = $this->find(self::DEFAULT_ID);
$payload = [
'time_masuk_start' => $data['time_masuk_start'] ?? null,
'time_masuk_end' => $data['time_masuk_end'] ?? null,
'time_pulang_start' => $data['time_pulang_start'] ?? null,
'time_pulang_end' => $data['time_pulang_end'] ?? null,
];
if ($row) {
return $this->update(self::DEFAULT_ID, $payload);
}
$payload['id'] = self::DEFAULT_ID;
return $this->insert($payload) !== false;
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* Dashboard Module Routes
*
* This file is automatically loaded by ModuleLoader.
* Define your dashboard routes here.
*
* @var RouteCollection $routes
*/
// Health check endpoint
$routes->get('api/health', '\App\Modules\Dashboard\Controllers\HealthController::index');
// Dashboard API endpoints
$routes->group('api/dashboard', ['namespace' => 'App\Modules\Dashboard\Controllers'], function ($routes) {
$routes->get('summary', 'DashboardController::summary');
$routes->get('realtime', 'DashboardController::realtime');
$routes->get('devices', 'DashboardController::devices');
$routes->get('presence-settings', 'PresenceSettingsController::index');
$routes->put('presence-settings', 'PresenceSettingsController::update');
$routes->post('qr-attendance/generate', 'QrAttendanceController::generate');
$routes->get('stream', 'RealtimeStreamController::index');
$routes->get('schedules/today', 'DashboardScheduleController::today');
$routes->get('schedules/by-date', 'DashboardScheduleController::byDate');
$routes->get('schedules/current', 'DashboardScheduleController::current');
$routes->get('attendance/progress/current', 'DashboardAttendanceController::progressCurrent');
});

View File

@@ -0,0 +1 @@
# Dashboard Module - Services

View File

@@ -0,0 +1,176 @@
<?php
namespace App\Modules\Dashboard\Services;
use App\Modules\Auth\Entities\Role;
/**
* Dashboard Realtime Service
*
* Fetches latest attendance records for SSE stream (by id > afterId).
* Role-aware filtering: ADMIN (all), WALI_KELAS (by assigned class), GURU_MAPEL (by teacher), ORANG_TUA (by own children).
*/
class DashboardRealtimeService
{
/** Scope type: no filter */
public const SCOPE_ADMIN = 'admin';
/** Scope type: filter by class_id(s) where user is wali */
public const SCOPE_WALI_KELAS = 'wali_kelas';
/** Scope type: filter by schedules taught by this teacher (teacher_user_id) */
public const SCOPE_GURU_MAPEL = 'guru_mapel';
/** Scope type: filter by student_ids linked to parent */
public const SCOPE_ORANG_TUA = 'orang_tua';
/**
* Resolve user scope for realtime filtering from current user (with roles).
*
* @param array $user { id, name, email, roles: [ { role_code, role_name } ] }
* @return array{ type: string, class_ids?: int[], teacher_user_id?: int, student_ids?: int[] }
*/
public function resolveUserScope(array $user): array
{
$roles = $user['roles'] ?? [];
$roleCodes = array_column($roles, 'role_code');
if (in_array(Role::CODE_ADMIN, $roleCodes, true)) {
return ['type' => self::SCOPE_ADMIN];
}
if (in_array(Role::CODE_WALI_KELAS, $roleCodes, true)) {
$classIds = $this->getClassIdsForWali($user['id']);
if ($classIds !== []) {
return ['type' => self::SCOPE_WALI_KELAS, 'class_ids' => $classIds];
}
}
if (in_array(Role::CODE_GURU_MAPEL, $roleCodes, true)) {
return ['type' => self::SCOPE_GURU_MAPEL, 'teacher_user_id' => (int) ($user['id'] ?? 0)];
}
if (in_array(Role::CODE_ORANG_TUA, $roleCodes, true)) {
$studentIds = $this->getStudentIdsForParent($user['id']);
if ($studentIds !== []) {
return ['type' => self::SCOPE_ORANG_TUA, 'student_ids' => $studentIds];
}
}
return ['type' => self::SCOPE_ADMIN];
}
/**
* Get class IDs where user is wali (classes.wali_user_id = userId).
*/
protected function getClassIdsForWali(int $userId): array
{
$db = \Config\Database::connect();
$rows = $db->table('classes')->select('id')->where('wali_user_id', $userId)->get()->getResultArray();
return array_map('intval', array_column($rows, 'id'));
}
/**
* Get student IDs linked to parent (parent.user_id = userId via student_parents).
*/
protected function getStudentIdsForParent(int $userId): array
{
$db = \Config\Database::connect();
$rows = $db->table('student_parents AS sp')
->select('sp.student_id')
->join('parents AS p', 'p.id = sp.parent_id', 'inner')
->where('p.user_id', $userId)
->get()
->getResultArray();
return array_map('intval', array_column($rows, 'student_id'));
}
/**
* Get attendance sessions after a given ID (for SSE incremental feed), with optional role scope.
*
* @param int $afterId Only return rows with id > afterId
* @param int $limit Max rows to return
* @param array|null $userContext Current user from session (id, name, email, roles). If null, no scope filter.
* @return array<int, array{id: int, student_name: string, class_name: string, subject: string, checkin_at: string, status: string}>
*/
public function getAttendanceSinceId(int $afterId = 0, int $limit = 100, ?array $userContext = null): array
{
$db = \Config\Database::connect();
$builder = $db->table('attendance_sessions AS a');
$builder->select('
a.id,
s.name AS student_name,
c.name AS class_name,
COALESCE(sub.name, "-") AS subject,
a.checkin_at,
a.status
');
$builder->join('students AS s', 's.id = a.student_id', 'left');
$builder->join('classes AS c', 'c.id = s.class_id', 'left');
$builder->join('schedules AS sch', 'sch.id = a.schedule_id', 'left');
$builder->join('subjects AS sub', 'sub.id = sch.subject_id', 'left');
$builder->where('a.id >', $afterId);
if ($userContext !== null) {
$this->applyScopeToBuilder($builder, $this->resolveUserScope($userContext));
}
$builder->orderBy('a.id', 'ASC');
$builder->limit($limit);
$rows = $builder->get()->getResultArray();
$out = [];
foreach ($rows as $row) {
$out[] = [
'id' => (int) $row['id'],
'student_name' => (string) ($row['student_name'] ?? '-'),
'class_name' => (string) ($row['class_name'] ?? '-'),
'subject' => (string) ($row['subject'] ?? '-'),
'checkin_at' => (string) $row['checkin_at'],
'status' => (string) $row['status'],
];
}
return $out;
}
/**
* Apply scope conditions to the attendance query builder (no duplicate query).
*
* @param \CodeIgniter\Database\BaseBuilder $builder
* @param array $scope From resolveUserScope()
*/
protected function applyScopeToBuilder($builder, array $scope): void
{
switch ($scope['type'] ?? '') {
case self::SCOPE_WALI_KELAS:
$classIds = $scope['class_ids'] ?? [];
if ($classIds !== []) {
$builder->whereIn('s.class_id', $classIds);
} else {
$builder->where('1 =', 0);
}
break;
case self::SCOPE_GURU_MAPEL:
$teacherUserId = (int) ($scope['teacher_user_id'] ?? 0);
if ($teacherUserId > 0) {
$builder->where('sch.teacher_user_id', $teacherUserId);
} else {
$builder->where('1 =', 0);
}
break;
case self::SCOPE_ORANG_TUA:
$studentIds = $scope['student_ids'] ?? [];
if ($studentIds !== []) {
$builder->whereIn('a.student_id', $studentIds);
} else {
$builder->where('1 =', 0);
}
break;
case self::SCOPE_ADMIN:
default:
break;
}
}
}

View File

@@ -0,0 +1,271 @@
<?php
namespace App\Modules\Dashboard\Services;
use App\Modules\Auth\Entities\Role;
/**
* Dashboard Schedule Service
*
* Returns today's schedules filtered by user role (Asia/Jakarta).
*/
class DashboardScheduleService
{
/**
* Get today's day_of_week (1=Monday, 7=Sunday) in Asia/Jakarta.
*/
public function getTodayDayOfWeek(): int
{
$tz = new \DateTimeZone('Asia/Jakarta');
$now = new \DateTimeImmutable('now', $tz);
return (int) $now->format('N');
}
/**
* Get schedules for today filtered by user role.
* Uses lesson_slots for start/end time and users for teacher_name; fallback to schedule columns when NULL.
*
* @param array|null $userContext { id, roles: [ { role_code } ] }
* @return list<array{schedule_id: int, subject_name: string, class_name: string, teacher_name: string, start_time: string, end_time: string}>
*/
public function getSchedulesToday(?array $userContext = null): array
{
$dayOfWeek = $this->getTodayDayOfWeek();
$db = \Config\Database::connect();
$builder = $db->table('schedules AS sch');
$builder->select('
sch.id AS schedule_id,
COALESCE(sub.name, "-") AS subject_name,
COALESCE(c.name, "-") AS class_name,
COALESCE(u.name, sch.teacher_name) AS teacher_name,
COALESCE(ls.start_time, sch.start_time) AS start_time,
COALESCE(ls.end_time, sch.end_time) AS end_time
');
$builder->join('lesson_slots AS ls', 'ls.id = sch.lesson_slot_id', 'left');
$builder->join('users AS u', 'u.id = sch.teacher_user_id', 'left');
$builder->join('subjects AS sub', 'sub.id = sch.subject_id', 'left');
$builder->join('classes AS c', 'c.id = sch.class_id', 'left');
$builder->where('sch.day_of_week', $dayOfWeek);
$builder->orderBy('COALESCE(ls.start_time, sch.start_time)', 'ASC', false);
$this->applyRoleFilter($builder, $userContext);
$rows = $builder->get()->getResultArray();
$out = [];
foreach ($rows as $row) {
$out[] = [
'schedule_id' => (int) $row['schedule_id'],
'subject_name' => (string) ($row['subject_name'] ?? '-'),
'class_name' => (string) ($row['class_name'] ?? '-'),
'teacher_name' => (string) ($row['teacher_name'] ?? '-'),
'start_time' => (string) $row['start_time'],
'end_time' => (string) $row['end_time'],
];
}
return $out;
}
/**
* Get schedules for a given date (day_of_week from date in Asia/Jakarta). Same shape as getSchedulesToday.
*
* @param string $dateYmd Y-m-d
* @param array|null $userContext
* @return list<array{schedule_id: int, subject_name: string, class_name: string, teacher_name: string, start_time: string, end_time: string}>
*/
public function getSchedulesByDate(string $dateYmd, ?array $userContext = null): array
{
$tz = new \DateTimeZone('Asia/Jakarta');
$date = new \DateTimeImmutable($dateYmd . ' 12:00:00', $tz);
$dayOfWeek = (int) $date->format('N');
$db = \Config\Database::connect();
$builder = $db->table('schedules AS sch');
$builder->select('
sch.id AS schedule_id,
COALESCE(sub.name, "-") AS subject_name,
COALESCE(c.name, "-") AS class_name,
COALESCE(u.name, sch.teacher_name) AS teacher_name,
COALESCE(ls.start_time, sch.start_time) AS start_time,
COALESCE(ls.end_time, sch.end_time) AS end_time
');
$builder->join('lesson_slots AS ls', 'ls.id = sch.lesson_slot_id', 'left');
$builder->join('users AS u', 'u.id = sch.teacher_user_id', 'left');
$builder->join('subjects AS sub', 'sub.id = sch.subject_id', 'left');
$builder->join('classes AS c', 'c.id = sch.class_id', 'left');
$builder->where('sch.day_of_week', $dayOfWeek);
$builder->orderBy('COALESCE(ls.start_time, sch.start_time)', 'ASC', false);
$this->applyRoleFilter($builder, $userContext);
$rows = $builder->get()->getResultArray();
$out = [];
foreach ($rows as $row) {
$out[] = [
'schedule_id' => (int) $row['schedule_id'],
'subject_name' => (string) ($row['subject_name'] ?? '-'),
'class_name' => (string) ($row['class_name'] ?? '-'),
'teacher_name' => (string) ($row['teacher_name'] ?? '-'),
'start_time' => (string) $row['start_time'],
'end_time' => (string) $row['end_time'],
];
}
return $out;
}
/**
* Get current lesson (schedule active right now) or next upcoming today.
* Uses Asia/Jakarta. Same RBAC as getSchedulesToday. lesson_slots/users as source of truth with fallback.
*
* @param array|null $userContext
* @return array{is_active_now: bool, schedule_id?: int, subject_name?: string, class_name?: string, teacher_name?: string, start_time?: string, end_time?: string, next_schedule?: array}
*/
public function getCurrentSchedule(?array $userContext = null): array
{
$tz = new \DateTimeZone('Asia/Jakarta');
$now = new \DateTimeImmutable('now', $tz);
$dayOfWeek = (int) $now->format('N');
$currentTime = $now->format('H:i:s');
$db = \Config\Database::connect();
$base = 'sch.id AS schedule_id, sch.class_id AS class_id, COALESCE(sub.name, "-") AS subject_name, COALESCE(c.name, "-") AS class_name, '
. 'COALESCE(u.name, sch.teacher_name) AS teacher_name, '
. 'COALESCE(ls.start_time, sch.start_time) AS start_time, COALESCE(ls.end_time, sch.end_time) AS end_time';
$timeRangeWhere = '( COALESCE(ls.start_time, sch.start_time) <= ' . $db->escape($currentTime)
. ' AND COALESCE(ls.end_time, sch.end_time) > ' . $db->escape($currentTime) . ' )';
$builder = $db->table('schedules AS sch');
$builder->select($base);
$builder->join('lesson_slots AS ls', 'ls.id = sch.lesson_slot_id', 'left');
$builder->join('users AS u', 'u.id = sch.teacher_user_id', 'left');
$builder->join('subjects AS sub', 'sub.id = sch.subject_id', 'left');
$builder->join('classes AS c', 'c.id = sch.class_id', 'left');
$builder->where('sch.day_of_week', $dayOfWeek);
$builder->where($timeRangeWhere);
$builder->orderBy('COALESCE(ls.start_time, sch.start_time)', 'ASC', false);
$builder->limit(1);
$this->applyRoleFilter($builder, $userContext);
$rows = $builder->get()->getResultArray();
if (!empty($rows)) {
$row = $rows[0];
return [
'is_active_now' => true,
'schedule_id' => (int) $row['schedule_id'],
'class_id' => (int) ($row['class_id'] ?? 0),
'subject_name' => (string) ($row['subject_name'] ?? '-'),
'class_name' => (string) ($row['class_name'] ?? '-'),
'teacher_name' => (string) ($row['teacher_name'] ?? '-'),
'start_time' => (string) $row['start_time'],
'end_time' => (string) $row['end_time'],
];
}
$nextTimeWhere = '( COALESCE(ls.start_time, sch.start_time) > ' . $db->escape($currentTime) . ' )';
$nextBuilder = $db->table('schedules AS sch');
$nextBuilder->select($base);
$nextBuilder->join('lesson_slots AS ls', 'ls.id = sch.lesson_slot_id', 'left');
$nextBuilder->join('users AS u', 'u.id = sch.teacher_user_id', 'left');
$nextBuilder->join('subjects AS sub', 'sub.id = sch.subject_id', 'left');
$nextBuilder->join('classes AS c', 'c.id = sch.class_id', 'left');
$nextBuilder->where('sch.day_of_week', $dayOfWeek);
$nextBuilder->where($nextTimeWhere);
$nextBuilder->orderBy('COALESCE(ls.start_time, sch.start_time)', 'ASC', false);
$nextBuilder->limit(1);
$this->applyRoleFilter($nextBuilder, $userContext);
$nextRows = $nextBuilder->get()->getResultArray();
$nextSchedule = null;
if (!empty($nextRows)) {
$r = $nextRows[0];
$nextSchedule = [
'schedule_id' => (int) $r['schedule_id'],
'subject_name' => (string) ($r['subject_name'] ?? '-'),
'class_name' => (string) ($r['class_name'] ?? '-'),
'teacher_name' => (string) ($r['teacher_name'] ?? '-'),
'start_time' => (string) $r['start_time'],
'end_time' => (string) $r['end_time'],
];
}
$result = ['is_active_now' => false];
if ($nextSchedule !== null) {
$result['next_schedule'] = $nextSchedule;
}
return $result;
}
protected function applyRoleFilter($builder, ?array $userContext): void
{
if ($userContext === null) {
return;
}
$roles = $userContext['roles'] ?? [];
$codes = array_column($roles, 'role_code');
$userId = (int) ($userContext['id'] ?? 0);
if (in_array(Role::CODE_ADMIN, $codes, true) || in_array(Role::CODE_GURU_BK, $codes, true)) {
return;
}
if (in_array(Role::CODE_WALI_KELAS, $codes, true)) {
$classIds = $this->getClassIdsForWali($userId);
if ($classIds === []) {
$builder->where('1 =', 0);
return;
}
$builder->whereIn('sch.class_id', $classIds);
return;
}
if (in_array(Role::CODE_GURU_MAPEL, $codes, true)) {
$builder->where('sch.teacher_user_id', $userId);
return;
}
if (in_array(Role::CODE_ORANG_TUA, $codes, true)) {
$studentIds = $this->getStudentIdsForParent($userId);
if ($studentIds === []) {
$builder->where('1 =', 0);
return;
}
$db = \Config\Database::connect();
$subQuery = $db->table('students')->select('class_id')->whereIn('id', $studentIds)->get()->getResultArray();
$classIds = array_values(array_unique(array_map('intval', array_column($subQuery, 'class_id'))));
if ($classIds === []) {
$builder->where('1 =', 0);
return;
}
$builder->whereIn('sch.class_id', $classIds);
return;
}
}
protected function getClassIdsForWali(int $userId): array
{
$db = \Config\Database::connect();
$rows = $db->table('classes')->select('id')->where('wali_user_id', $userId)->get()->getResultArray();
return array_map('intval', array_column($rows, 'id'));
}
protected function getStudentIdsForParent(int $userId): array
{
$db = \Config\Database::connect();
$rows = $db->table('student_parents AS sp')
->select('sp.student_id')
->join('parents AS p', 'p.id = sp.parent_id', 'inner')
->where('p.user_id', $userId)
->get()
->getResultArray();
return array_map('intval', array_column($rows, 'student_id'));
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Modules\Dashboard\Services;
use App\Modules\Academic\Models\StudentModel;
use App\Modules\Attendance\Models\AttendanceSessionModel;
use App\Modules\Devices\Models\DeviceModel;
/**
* Dashboard Service
*
* Aggregates data for dashboard monitoring endpoints.
*/
class DashboardService
{
protected AttendanceSessionModel $attendanceModel;
protected DeviceModel $deviceModel;
protected StudentModel $studentModel;
/**
* Minutes threshold for device "online" status
*/
protected int $deviceOnlineThresholdMinutes = 2;
public function __construct()
{
$this->attendanceModel = new AttendanceSessionModel();
$this->deviceModel = new DeviceModel();
$this->studentModel = new StudentModel();
}
/**
* Get today's attendance summary
*
* @return array{total_students: int, present_today: int, late_today: int, outside_zone_today: int, no_schedule_today: int, invalid_device_today: int}
*/
public function getSummary(): array
{
$db = \Config\Database::connect();
$totalStudents = (int) $this->studentModel->countAll();
// Count by status for today only (date part of checkin_at = today)
$today = date('Y-m-d');
$builder = $db->table('attendance_sessions');
$builder->select('status, COUNT(*) as cnt');
$builder->where('DATE(checkin_at)', $today);
$builder->groupBy('status');
$rows = $builder->get()->getResultArray();
$counts = [
'PRESENT' => 0,
'LATE' => 0,
'OUTSIDE_ZONE' => 0,
'NO_SCHEDULE' => 0,
'INVALID_DEVICE' => 0,
];
foreach ($rows as $row) {
if (isset($counts[$row['status']])) {
$counts[$row['status']] = (int) $row['cnt'];
}
}
return [
'total_students' => $totalStudents,
'present_today' => $counts['PRESENT'],
'late_today' => $counts['LATE'],
'outside_zone_today' => $counts['OUTSIDE_ZONE'],
'no_schedule_today' => $counts['NO_SCHEDULE'],
'invalid_device_today' => $counts['INVALID_DEVICE'],
];
}
/**
* Get last N attendance check-ins with student, class, subject, device info
*
* @param int $limit
* @return array<int, array{student_name: string, class_name: string, subject: string, checkin_at: string, status: string, device_code: string}>
*/
public function getRealtimeCheckins(int $limit = 20): array
{
$db = \Config\Database::connect();
$builder = $db->table('attendance_sessions AS a');
$builder->select('
s.name AS student_name,
c.name AS class_name,
COALESCE(sub.name, "-") AS subject,
a.checkin_at,
a.status,
d.device_code
');
$builder->join('students AS s', 's.id = a.student_id', 'left');
$builder->join('classes AS c', 'c.id = s.class_id', 'left');
$builder->join('schedules AS sch', 'sch.id = a.schedule_id', 'left');
$builder->join('subjects AS sub', 'sub.id = sch.subject_id', 'left');
$builder->join('devices AS d', 'd.id = a.device_id', 'left');
$builder->orderBy('a.checkin_at', 'DESC');
$builder->limit($limit);
$rows = $builder->get()->getResultArray();
$out = [];
foreach ($rows as $row) {
$out[] = [
'student_name' => (string) ($row['student_name'] ?? '-'),
'class_name' => (string) ($row['class_name'] ?? '-'),
'subject' => (string) ($row['subject'] ?? '-'),
'checkin_at' => (string) $row['checkin_at'],
'status' => (string) $row['status'],
'device_code' => (string) ($row['device_code'] ?? '-'),
];
}
return $out;
}
/**
* Get devices with online status (online if last_seen_at within threshold minutes)
*
* @return array<int, array{
* id: int,
* device_code: string,
* device_name: string,
* is_active: bool,
* last_seen_at: string|null,
* online_status: string,
* latitude: float|null,
* longitude: float|null,
* radius_meters: int|null
* }>
*/
public function getDevices(): array
{
$devices = $this->deviceModel->findAll();
$thresholdSeconds = $this->deviceOnlineThresholdMinutes * 60;
$now = time();
$out = [];
foreach ($devices as $d) {
$lastSeenAt = $d->last_seen_at;
$lastSeenStr = null;
$lastSeenTs = null;
if ($lastSeenAt !== null) {
$lastSeenStr = is_object($lastSeenAt) && method_exists($lastSeenAt, 'format')
? $lastSeenAt->format('Y-m-d H:i:s')
: (string) $lastSeenAt;
$lastSeenTs = strtotime($lastSeenStr);
}
$onlineStatus = 'offline';
if ($lastSeenTs !== null && ($now - $lastSeenTs) < $thresholdSeconds) {
$onlineStatus = 'online';
}
$out[] = [
'id' => (int) $d->id,
'device_code' => $d->device_code,
'device_name' => (string) ($d->device_name ?? ''),
'is_active' => (bool) $d->is_active,
'last_seen_at' => $lastSeenStr,
'online_status' => $onlineStatus,
'latitude' => $d->latitude !== null ? (float) $d->latitude : null,
'longitude' => $d->longitude !== null ? (float) $d->longitude : null,
'radius_meters' => $d->radius_meters !== null ? (int) $d->radius_meters : null,
];
}
return $out;
}
}

View File

@@ -0,0 +1 @@
# Devices Module - Controllers

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Modules\Devices\Controllers;
use App\Core\BaseApiController;
use App\Modules\Devices\Services\DeviceAuthService;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Device Authentication Controller
*
* Handles device authentication endpoints.
*/
class DeviceAuthController extends BaseApiController
{
protected DeviceAuthService $deviceAuthService;
public function __construct()
{
$this->deviceAuthService = new DeviceAuthService();
}
/**
* Device login endpoint
*
* Authenticates device using device_code and api_key.
*
* POST /api/device/login
* Body: { "device_code": "", "api_key": "" }
*
* @return ResponseInterface
*/
public function login(): ResponseInterface
{
// Get JSON input
$input = $this->request->getJSON(true);
// Validate input
if (empty($input['device_code']) || empty($input['api_key'])) {
return $this->errorResponse(
'device_code and api_key are required',
null,
null,
400
);
}
// Authenticate device
$deviceData = $this->deviceAuthService->authenticate(
$input['device_code'],
$input['api_key']
);
if (!$deviceData) {
return $this->errorResponse(
'Invalid device credentials',
null,
null,
401
);
}
// Return success response
return $this->successResponse(
[
'device_id' => $deviceData['device_id'],
'device_code' => $deviceData['device_code'],
],
'Device authenticated'
);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Modules\Devices\Controllers;
use App\Core\BaseApiController;
use App\Modules\Devices\Models\DeviceModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Device Controller
*
* Admin-only management for device configuration (geo-fence, etc).
*/
class DeviceController extends BaseApiController
{
/**
* PUT /api/devices/{id}
*
* Body (JSON):
* - latitude: float|null
* - longitude: float|null
* - radius_meters: int|null
*/
public function update($id): ResponseInterface
{
$id = (int) $id;
if ($id <= 0) {
return $this->errorResponse('Invalid device id', null, null, ResponseInterface::HTTP_BAD_REQUEST);
}
$payload = $this->request->getJSON(true) ?? [];
$lat = $payload['latitude'] ?? null;
$lng = $payload['longitude'] ?? null;
$radius = $payload['radius_meters'] ?? null;
// Normalize empty strings to null
$lat = ($lat === '' || $lat === null) ? null : $lat;
$lng = ($lng === '' || $lng === null) ? null : $lng;
$radius = ($radius === '' || $radius === null) ? null : $radius;
// Basic validation
if ($lat !== null && !is_numeric($lat)) {
return $this->errorResponse('Latitude harus berupa angka atau kosong', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
if ($lng !== null && !is_numeric($lng)) {
return $this->errorResponse('Longitude harus berupa angka atau kosong', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
if ($radius !== null && (!is_numeric($radius) || (int) $radius < 0)) {
return $this->errorResponse('Radius harus berupa angka >= 0 atau kosong', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
// If one of lat/lng/radius set, require all three
$hasAny = $lat !== null || $lng !== null || $radius !== null;
if ($hasAny) {
if ($lat === null || $lng === null || $radius === null) {
return $this->errorResponse('Jika mengatur zona, latitude, longitude, dan radius wajib diisi semua', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
}
$data = [
'latitude' => $lat !== null ? (float) $lat : null,
'longitude' => $lng !== null ? (float) $lng : null,
'radius_meters' => $radius !== null ? (int) $radius : null,
];
$model = new DeviceModel();
$device = $model->find($id);
if (!$device) {
return $this->errorResponse('Device tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
if (!$model->update($id, $data)) {
return $this->errorResponse('Gagal menyimpan konfigurasi device', $model->errors(), null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->successResponse(null, 'Konfigurasi geo-fence device berhasil disimpan');
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Modules\Devices\Controllers;
use App\Core\BaseApiController;
use App\Modules\Geo\Models\ZoneModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Mobile Support Controller
*
* Endpoints for Android app (ping, bootstrap). No authentication required.
*/
class MobileController extends BaseApiController
{
/**
* Default check-in late tolerance in minutes (must match AttendanceCheckinService)
*/
protected int $checkinToleranceMinutes = 10;
/**
* GET /api/mobile/ping
*
* @return ResponseInterface
*/
public function ping(): ResponseInterface
{
$data = [
'server_time' => date('Y-m-d H:i:s'),
'api_version' => '1.0',
];
return $this->successResponse($data, 'Mobile connected');
}
/**
* GET /api/mobile/bootstrap
*
* Returns active zones, device validation rules, and checkin tolerance for app startup.
*
* @return ResponseInterface
*/
public function bootstrap(): ResponseInterface
{
$zoneModel = new ZoneModel();
$activeZones = $zoneModel->findAllActive();
$zonesData = [];
foreach ($activeZones as $zone) {
$zonesData[] = [
'zone_code' => $zone->zone_code,
'zone_name' => $zone->zone_name,
'latitude' => (float) $zone->latitude,
'longitude' => (float) $zone->longitude,
'radius_meters' => (int) $zone->radius_meters,
];
}
$data = [
'server_timestamp' => gmdate('Y-m-d\TH:i:s\Z'), // ISO 8601 UTC — for device_time_offset = server_time - device_time
'server_timezone' => 'Asia/Jakarta', // WIB — for display / jadwal sekolah
'active_zones' => $zonesData,
'device_validation_rules' => [
'require_device_code' => true,
'require_api_key' => true,
],
'checkin_tolerance_minutes' => $this->checkinToleranceMinutes,
];
return $this->successResponse($data, 'Bootstrap data');
}
}

View File

@@ -0,0 +1 @@
# Devices Module - Entities

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Modules\Devices\Entities;
use CodeIgniter\Entity\Entity;
/**
* Device Entity
*
* Represents a device in the system.
*/
class Device extends Entity
{
/**
* Attributes that can be mass assigned
*
* @var array<string>
*/
protected $allowedFields = [
'device_code',
'device_name',
'api_key',
'is_active',
'last_seen_at',
];
/**
* Attributes that should be cast to specific types
*
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'is_active' => 'boolean',
'last_seen_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Check if device is active
*
* @return bool
*/
public function isActive(): bool
{
return (bool) $this->attributes['is_active'];
}
}

View File

@@ -0,0 +1 @@
# Devices Module - Models

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Modules\Devices\Models;
use App\Modules\Devices\Entities\Device;
use CodeIgniter\Model;
/**
* Device Model
*
* Handles database operations for devices.
*/
class DeviceModel extends Model
{
protected $table = 'devices';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = Device::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'device_code',
'device_name',
'api_key',
'is_active',
'last_seen_at',
'latitude',
'longitude',
'radius_meters',
];
// Dates
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation
protected $validationRules = [
'device_code' => 'required|max_length[100]|is_unique[devices.device_code,id,{id}]',
'device_name' => 'permit_empty|max_length[255]',
'api_key' => 'required|max_length[255]',
'is_active' => 'permit_empty|in_list[0,1]',
'latitude' => 'permit_empty|decimal',
'longitude' => 'permit_empty|decimal',
'radius_meters' => 'permit_empty|integer|greater_than_equal_to[0]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
/**
* Find device by device_code
*
* @param string $deviceCode
* @return Device|null
*/
public function findByDeviceCode(string $deviceCode): ?Device
{
return $this->where('device_code', $deviceCode)->first();
}
/**
* Find active device by device_code
*
* @param string $deviceCode
* @return Device|null
*/
public function findActiveByDeviceCode(string $deviceCode): ?Device
{
return $this->where('device_code', $deviceCode)
->where('is_active', 1)
->first();
}
/**
* Update last_seen_at timestamp
*
* @param int $deviceId
* @return bool
*/
public function updateLastSeen(int $deviceId): bool
{
return $this->update($deviceId, [
'last_seen_at' => date('Y-m-d H:i:s'),
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
/**
* Devices Module Routes
*
* This file is automatically loaded by ModuleLoader.
* Define your device management routes here.
*
* @var RouteCollection $routes
*/
// Device authentication routes
$routes->group('api/device', ['namespace' => 'App\Modules\Devices\Controllers'], function ($routes) {
$routes->post('login', 'DeviceAuthController::login');
});
// Mobile support routes (no auth required)
$routes->group('api/mobile', ['namespace' => 'App\Modules\Devices\Controllers'], function ($routes) {
$routes->get('ping', 'MobileController::ping');
$routes->get('bootstrap', 'MobileController::bootstrap');
});
// Device management (admin only)
$routes->group('api/devices', [
'namespace' => 'App\Modules\Devices\Controllers',
'filter' => 'admin_only',
], function ($routes) {
$routes->put('(:num)', 'DeviceController::update/$1');
});
// Example route structure (uncomment and modify as needed):
// $routes->group('api/devices', ['namespace' => 'App\Modules\Devices\Controllers'], function($routes) {
// $routes->get('/', 'DeviceController::index');
// $routes->get('(:num)', 'DeviceController::show/$1');
// $routes->post('/', 'DeviceController::register');
// $routes->put('(:num)', 'DeviceController::update/$1');
// $routes->delete('(:num)', 'DeviceController::delete/$1');
// });

View File

@@ -0,0 +1 @@
# Devices Module - Services

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Modules\Devices\Services;
use App\Modules\Devices\Models\DeviceModel;
/**
* Device Authentication Service
*
* Handles device authentication logic.
*/
class DeviceAuthService
{
protected DeviceModel $deviceModel;
public function __construct()
{
$this->deviceModel = new DeviceModel();
}
/**
* Authenticate device by device_code and api_key
*
* @param string $deviceCode Device code
* @param string $apiKey API key
* @return array|null Returns device data if authenticated, null otherwise
*/
public function authenticate(string $deviceCode, string $apiKey): ?array
{
// Find active device by device_code
$device = $this->deviceModel->findActiveByDeviceCode($deviceCode);
if (!$device) {
return null;
}
// For development: compare plain api_key
// In production, should use password_verify() with hashed api_key
if ($device->api_key !== $apiKey) {
return null;
}
// Update last_seen_at
$this->touchLastSeen($device->id);
return [
'device_id' => $device->id,
'device_code' => $device->device_code,
'device_name' => $device->device_name,
'latitude' => $device->latitude !== null ? (float) $device->latitude : null,
'longitude' => $device->longitude !== null ? (float) $device->longitude : null,
'radius_meters' => $device->radius_meters !== null ? (int) $device->radius_meters : null,
];
}
/**
* Update last_seen_at timestamp for device
*
* @param int $deviceId Device ID
* @return void
*/
public function touchLastSeen(int $deviceId): void
{
$this->deviceModel->updateLastSeen($deviceId);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Modules\Discipline\Controllers;
use App\Core\BaseApiController;
use App\Modules\Discipline\Models\DisciplineLevelModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* API untuk config level disiplin (rentang poin -> tindakan sekolah).
*/
class DisciplineLevelController extends BaseApiController
{
/**
* GET /api/discipline/levels
*/
public function index(): ResponseInterface
{
$model = new DisciplineLevelModel();
$rows = $model->where('is_active', 1)
->orderBy('min_score', 'ASC')
->findAll();
$data = array_map(static function (array $r) {
return [
'id' => (int) $r['id'],
'min_score' => (int) $r['min_score'],
'max_score' => $r['max_score'] !== null ? (int) $r['max_score'] : null,
'title' => $r['title'],
'school_action'=> $r['school_action'],
'executor' => $r['executor'],
];
}, $rows);
return $this->successResponse($data, 'Discipline levels');
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Modules\Discipline\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\ClassModel;
use App\Modules\Academic\Models\StudentModel;
use App\Modules\Auth\Services\AuthService;
use App\Modules\Discipline\Models\StudentViolationModel;
use App\Modules\Discipline\Models\ViolationModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Pencatatan pelanggaran siswa.
*/
class StudentViolationController extends BaseApiController
{
/**
* GET /api/discipline/student-violations
* Query: student_id?, class_id?, from_date?, to_date?
*/
public function index(): ResponseInterface
{
$auth = new AuthService();
$user = $auth->currentUser();
if (! $user) {
return $this->errorResponse('Unauthorized', null, null, ResponseInterface::HTTP_UNAUTHORIZED);
}
$studentId = (int) $this->request->getGet('student_id');
$classId = (int) $this->request->getGet('class_id');
$fromDate = $this->request->getGet('from_date');
$toDate = $this->request->getGet('to_date');
$db = \Config\Database::connect();
$builder = $db->table('student_violations AS sv')
->select('sv.id, sv.student_id, sv.class_id, sv.violation_id, sv.reported_by_user_id, sv.occurred_at, sv.notes,
s.name AS student_name, c.grade AS class_grade, c.major AS class_major, c.name AS class_name,
v.title AS violation_title, v.score AS violation_score,
vc.code AS category_code, vc.name AS category_name,
u.name AS reporter_name')
->join('students AS s', 's.id = sv.student_id', 'left')
->join('classes AS c', 'c.id = sv.class_id', 'left')
->join('violations AS v', 'v.id = sv.violation_id', 'left')
->join('violation_categories AS vc', 'vc.id = v.category_id', 'left')
->join('users AS u', 'u.id = sv.reported_by_user_id', 'left')
->orderBy('sv.occurred_at', 'DESC')
->orderBy('sv.id', 'DESC');
if ($studentId > 0) {
$builder->where('sv.student_id', $studentId);
}
if ($classId > 0) {
$builder->where('sv.class_id', $classId);
}
if ($fromDate) {
$builder->where('sv.occurred_at >=', $fromDate . ' 00:00:00');
}
if ($toDate) {
$builder->where('sv.occurred_at <=', $toDate . ' 23:59:59');
}
$rows = $builder->get()->getResultArray();
$data = array_map(static function (array $r) {
$classLabel = null;
if ($r['class_grade'] !== null || $r['class_major'] !== null || $r['class_name'] !== null) {
$parts = array_filter([
trim((string) ($r['class_grade'] ?? '')),
trim((string) ($r['class_major'] ?? '')),
trim((string) ($r['class_name'] ?? '')),
]);
$classLabel = implode(' ', $parts);
}
return [
'id' => (int) $r['id'],
'student_id' => (int) $r['student_id'],
'student_name' => (string) $r['student_name'],
'class_id' => $r['class_id'] !== null ? (int) $r['class_id'] : null,
'class_label' => $classLabel,
'violation_id' => (int) $r['violation_id'],
'category_code' => $r['category_code'],
'category_name' => $r['category_name'],
'violation_title' => $r['violation_title'],
'violation_score' => (int) $r['violation_score'],
'reported_by_user_id' => $r['reported_by_user_id'] !== null ? (int) $r['reported_by_user_id'] : null,
'reported_by_name' => $r['reporter_name'],
'occurred_at' => $r['occurred_at'],
'notes' => $r['notes'],
];
}, $rows);
return $this->successResponse($data, 'Student violations');
}
/**
* POST /api/discipline/student-violations
* Body: { student_id, violation_id, occurred_at?, notes? }
* Hanya ADMIN, GURU_MAPEL, WALI_KELAS.
*/
public function create(): ResponseInterface
{
$auth = new AuthService();
$user = $auth->currentUser();
if (! $user) {
return $this->errorResponse('Unauthorized', null, null, ResponseInterface::HTTP_UNAUTHORIZED);
}
$roleCodes = array_column($user['roles'], 'role_code');
$allowedRoles = ['ADMIN', 'GURU_MAPEL', 'WALI_KELAS'];
if (! array_intersect($allowedRoles, $roleCodes)) {
return $this->errorResponse('Forbidden', null, null, ResponseInterface::HTTP_FORBIDDEN);
}
$payload = $this->request->getJSON(true) ?? [];
$studentId = (int) ($payload['student_id'] ?? 0);
$violationId = (int) ($payload['violation_id'] ?? 0);
$occurredAt = $payload['occurred_at'] ?? null;
$notes = $payload['notes'] ?? null;
if ($studentId <= 0 || $violationId <= 0) {
return $this->errorResponse('student_id dan violation_id wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$studentModel = new StudentModel();
$classModel = new ClassModel();
$violationModel = new ViolationModel();
$student = $studentModel->find($studentId);
if (! $student) {
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$violation = $violationModel->find($violationId);
if (! $violation || (int) $violation['is_active'] !== 1) {
return $this->errorResponse('Pelanggaran tidak ditemukan atau tidak aktif', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$classId = $student->class_id !== null ? (int) $student->class_id : null;
if ($occurredAt === null || trim($occurredAt) === '') {
$occurredAt = date('Y-m-d H:i:s');
}
$model = new StudentViolationModel();
$model->insert([
'student_id' => $studentId,
'class_id' => $classId,
'violation_id' => $violationId,
'reported_by_user_id' => (int) $user['id'],
'occurred_at' => $occurredAt,
'notes' => $notes,
]);
if ($model->errors()) {
return $this->errorResponse(implode(' ', $model->errors()), $model->errors(), null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->successResponse(null, 'Pelanggaran siswa berhasil dicatat');
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Modules\Discipline\Controllers;
use App\Core\BaseApiController;
use App\Modules\Discipline\Models\ViolationModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Admin CRUD untuk master violations (jenis pelanggaran).
* Akses via /api/discipline/violations-admin (hanya ADMIN).
*/
class ViolationAdminController extends BaseApiController
{
/**
* POST /api/discipline/violations-admin
*/
public function create(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$categoryId = (int) ($payload['category_id'] ?? 0);
$title = trim((string) ($payload['title'] ?? ''));
$score = (int) ($payload['score'] ?? 0);
$description = $payload['description'] ?? null;
$isActive = array_key_exists('is_active', $payload) ? (int) (bool) $payload['is_active'] : 1;
if ($categoryId <= 0 || $title === '') {
return $this->errorResponse('category_id dan title wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$model = new ViolationModel();
$model->insert([
'category_id' => $categoryId,
'title' => $title,
'description' => $description,
'score' => $score,
'is_active' => $isActive,
]);
if ($model->errors()) {
return $this->errorResponse(implode(' ', $model->errors()), $model->errors(), null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->successResponse(null, 'Pelanggaran berhasil ditambahkan');
}
/**
* PUT /api/discipline/violations-admin/{id}
*/
public function update(int $id): ResponseInterface
{
$model = new ViolationModel();
$row = $model->find($id);
if (! $row) {
return $this->errorResponse('Pelanggaran tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$payload = $this->request->getJSON(true) ?? [];
$data = [];
if (isset($payload['category_id'])) {
$data['category_id'] = (int) $payload['category_id'];
}
if (isset($payload['title'])) {
$data['title'] = trim((string) $payload['title']);
}
if (isset($payload['description'])) {
$data['description'] = $payload['description'];
}
if (isset($payload['score'])) {
$data['score'] = (int) $payload['score'];
}
if (isset($payload['is_active'])) {
$data['is_active'] = (int) (bool) $payload['is_active'];
}
if ($data === []) {
return $this->successResponse(null, 'Tidak ada perubahan');
}
$model->update($id, $data);
if ($model->errors()) {
return $this->errorResponse(implode(' ', $model->errors()), $model->errors(), null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->successResponse(null, 'Pelanggaran berhasil diperbarui');
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Modules\Discipline\Controllers;
use App\Core\BaseApiController;
use App\Modules\Discipline\Models\ViolationCategoryModel;
use App\Modules\Discipline\Models\ViolationModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Master data pelanggaran (kategori + item).
* Biasanya diakses ADMIN / BK.
*/
class ViolationController extends BaseApiController
{
public function index(): ResponseInterface
{
$categoryModel = new ViolationCategoryModel();
$violationModel = new ViolationModel();
$categories = $categoryModel->orderBy('code', 'ASC')->findAll();
$violations = $violationModel
->where('is_active', 1)
->orderBy('category_id', 'ASC')
->orderBy('score', 'DESC')
->findAll();
// Group violations per category
$byCategory = [];
foreach ($categories as $cat) {
$byCategory[$cat['id']] = [
'id' => (int) $cat['id'],
'code' => $cat['code'],
'name' => $cat['name'],
'description' => $cat['description'],
'items' => [],
];
}
foreach ($violations as $v) {
$cid = (int) $v['category_id'];
if (! isset($byCategory[$cid])) {
continue;
}
$byCategory[$cid]['items'][] = [
'id' => (int) $v['id'],
'code' => $v['code'],
'title' => $v['title'],
'description' => $v['description'],
'score' => (int) $v['score'],
];
}
$out = array_values($byCategory);
return $this->successResponse($out, 'Violation master data');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Modules\Discipline\Models;
use CodeIgniter\Model;
class DisciplineLevelModel extends Model
{
protected $table = 'discipline_levels';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'min_score',
'max_score',
'title',
'school_action',
'executor',
'is_active',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Modules\Discipline\Models;
use CodeIgniter\Model;
class StudentViolationModel extends Model
{
protected $table = 'student_violations';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'student_id',
'class_id',
'violation_id',
'reported_by_user_id',
'occurred_at',
'notes',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Modules\Discipline\Models;
use CodeIgniter\Model;
class ViolationCategoryModel extends Model
{
protected $table = 'violation_categories';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'code',
'name',
'description',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Modules\Discipline\Models;
use CodeIgniter\Model;
class ViolationModel extends Model
{
protected $table = 'violations';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'category_id',
'code',
'title',
'description',
'score',
'is_active',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
}

View File

@@ -0,0 +1,22 @@
<?php
use CodeIgniter\Router\RouteCollection;
/**
* Discipline Module Routes
*
* @var RouteCollection $routes
*/
$routes->group('api/discipline', [
'namespace' => 'App\Modules\Discipline\Controllers',
'filter' => 'dashboard_auth',
], function ($routes) {
$routes->get('violations', 'ViolationController::index');
$routes->get('levels', 'DisciplineLevelController::index');
$routes->get('student-violations', 'StudentViolationController::index');
$routes->post('student-violations', 'StudentViolationController::create');
$routes->post('violations-admin', 'ViolationAdminController::create', ['filter' => 'admin_only']);
$routes->put('violations-admin/(:num)', 'ViolationAdminController::update/$1', ['filter' => 'admin_only']);
});

View File

@@ -0,0 +1,236 @@
<?php
namespace App\Modules\Face\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\StudentModel;
use App\Modules\Face\Models\StudentFaceModel;
use App\Modules\Face\Services\FaceService;
use CodeIgniter\HTTP\ResponseInterface;
class FaceController extends BaseApiController
{
/**
* POST /api/face/import-formal
* Body: {
* "items": [
* { "student_id": 123, "url": "https://..." },
* ...
* ]
* }
*
* Admin-only (protected by filter admin_only).
*/
public function importFormal(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$items = $payload['items'] ?? [];
if (! is_array($items) || $items === []) {
return $this->errorResponse('items wajib berupa array', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$studentModel = new StudentModel();
$faceModel = new StudentFaceModel();
$faceService = new FaceService();
$success = 0;
$fail = 0;
$errors = [];
foreach ($items as $item) {
$studentId = (int) ($item['student_id'] ?? 0);
$url = trim((string) ($item['url'] ?? ''));
if ($studentId < 1 || $url === '') {
$fail++;
$errors[] = [
'student_id' => $studentId,
'url' => $url,
'reason' => 'student_id dan url wajib diisi',
];
continue;
}
$student = $studentModel->find($studentId);
if (! $student) {
$fail++;
$errors[] = [
'student_id' => $studentId,
'url' => $url,
'reason' => 'Siswa tidak ditemukan',
];
continue;
}
$tmpFile = tempnam(sys_get_temp_dir(), 'face_');
try {
$imgData = @file_get_contents($url);
if ($imgData === false) {
throw new \RuntimeException('Gagal mengunduh gambar dari URL');
}
file_put_contents($tmpFile, $imgData);
$result = $faceService->extractEmbeddingWithQuality($tmpFile);
$faceModel->insert([
'student_id' => $studentId,
'embedding' => json_encode($result['embedding']),
'source' => 'formal',
'quality_score' => $result['quality_score'],
]);
// Simpan URL formal di tabel students
$studentModel->update($studentId, ['photo_formal_url' => $url]);
$success++;
} catch (\Throwable $e) {
$fail++;
$errors[] = [
'student_id' => $studentId,
'url' => $url,
'reason' => $e->getMessage(),
];
} finally {
if (is_file($tmpFile)) {
@unlink($tmpFile);
}
}
}
return $this->successResponse([
'success_count' => $success,
'fail_count' => $fail,
'errors' => $errors,
], 'Import foto formal selesai');
}
/**
* POST /api/face/enroll-live
* Body: {
* "student_id": 123,
* "images": ["data:image/jpeg;base64,...", "..."]
* }
*
* Dipanggil saat aktivasi dari device (35 frame).
*/
public function enrollLive(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$studentId = (int) ($payload['student_id'] ?? 0);
$images = $payload['images'] ?? [];
if ($studentId < 1) {
return $this->errorResponse('student_id wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
if (! is_array($images) || count($images) < 1) {
return $this->errorResponse('images wajib berisi minimal 1 item', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$studentModel = new StudentModel();
$student = $studentModel->find($studentId);
if (! $student) {
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$faceModel = new StudentFaceModel();
$faceService = new FaceService();
$embeddings = [];
$saved = 0;
$errors = [];
foreach ($images as $idx => $imgData) {
$tmpFile = tempnam(sys_get_temp_dir(), 'live_');
try {
$raw = $this->decodeBase64Image($imgData);
if ($raw === null) {
throw new \RuntimeException('Format gambar tidak valid (harus base64)');
}
file_put_contents($tmpFile, $raw);
$result = $faceService->extractEmbeddingWithQuality($tmpFile);
$embeddings[] = $result['embedding'];
$faceModel->insert([
'student_id' => $studentId,
'embedding' => json_encode($result['embedding']),
'source' => 'live',
'quality_score' => $result['quality_score'],
]);
$saved++;
} catch (\Throwable $e) {
$errors[] = [
'index' => $idx,
'reason' => $e->getMessage(),
];
} finally {
if (is_file($tmpFile)) {
@unlink($tmpFile);
}
}
}
// Optional: simpan embedding rata-rata sebagai live_avg jika ada cukup sample
if (count($embeddings) >= 2) {
$avg = $this->averageEmbedding($embeddings);
$faceModel->insert([
'student_id' => $studentId,
'embedding' => json_encode($avg),
'source' => 'live_avg',
'quality_score' => null,
]);
}
return $this->successResponse([
'student_id' => $studentId,
'saved' => $saved,
'errors' => $errors,
], 'Enrollment live selesai');
}
/**
* Hitung rata-rata beberapa embedding (elemen per elemen).
*
* @param array<int, array<int,float>> $vectors
* @return float[]
*/
protected function averageEmbedding(array $vectors): array
{
$count = count($vectors);
if ($count === 0) {
return [];
}
$dim = count($vectors[0]);
$sum = array_fill(0, $dim, 0.0);
foreach ($vectors as $vec) {
for ($i = 0; $i < $dim; $i++) {
$sum[$i] += (float) $vec[$i];
}
}
for ($i = 0; $i < $dim; $i++) {
$sum[$i] /= $count;
}
return $sum;
}
/**
* Decode string base64 (data URL atau plain base64) ke binary.
*/
protected function decodeBase64Image(string $input): ?string
{
$input = trim($input);
if ($input === '') {
return null;
}
if (strpos($input, 'base64,') !== false) {
$parts = explode('base64,', $input, 2);
$input = $parts[1];
}
$data = base64_decode($input, true);
return $data === false ? null : $data;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Modules\Face\Models;
use CodeIgniter\Model;
class StudentFaceModel extends Model
{
protected $table = 'student_faces';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $protectFields = true;
protected $allowedFields = [
'student_id',
'embedding',
'source',
'quality_score',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $validationRules = [
'student_id' => 'required|integer|is_not_unique[students.id]',
'embedding' => 'required',
'source' => 'required|in_list[formal,live,live_avg]',
'quality_score' => 'permit_empty|numeric',
];
}

View File

@@ -0,0 +1,15 @@
<?php
/**
* Face Module Routes
*
* @var \CodeIgniter\Router\RouteCollection $routes
*/
$routes->group('api/face', [
'namespace' => 'App\Modules\Face\Controllers',
], function ($routes) {
$routes->post('import-formal', 'FaceController::importFormal', ['filter' => 'admin_only']);
$routes->post('enroll-live', 'FaceController::enrollLive');
});

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Modules\Face\Services;
use CodeIgniter\HTTP\CURLRequest;
/**
* FaceService
*
* Bertugas berkomunikasi dengan engine embedding wajah eksternal (Python/ONNX),
* serta menyediakan helper cosine similarity.
*
* Konfigurasi ENV:
* - FACE_SERVICE_URL (contoh: http://localhost:5000)
* - FACE_EMBEDDING_DIM (default: 512)
* - FACE_SIM_THRESHOLD (default: 0.85)
*/
class FaceService
{
protected CURLRequest $http;
protected string $serviceUrl;
protected int $embeddingDim;
protected float $defaultThreshold;
public function __construct()
{
/** @var CURLRequest $http */
$http = service('curlrequest');
$this->http = $http;
$this->serviceUrl = rtrim((string) (env('FACE_SERVICE_URL') ?? 'http://localhost:5000'), '/');
$this->embeddingDim = (int) (env('FACE_EMBEDDING_DIM') ?? 512);
$this->defaultThreshold = (float) (env('FACE_SIM_THRESHOLD') ?? 0.85);
}
/**
* Kirim gambar ke service eksternal untuk mendapatkan embedding + metrik kualitas.
* Expected response JSON:
* {
* "embedding": [float...],
* "quality_score": float,
* "faces_count": int,
* "face_size": float,
* "blur": float,
* "brightness": float
* }
*
* @throws \RuntimeException jika gagal atau quality gate tidak lolos
*/
public function extractEmbeddingWithQuality(string $imagePath): array
{
if (! is_file($imagePath)) {
throw new \RuntimeException('File gambar tidak ditemukan: ' . $imagePath);
}
$url = $this->serviceUrl . '/embed';
$response = $this->http->post($url, [
'multipart' => [
[
'name' => 'image',
'contents' => fopen($imagePath, 'rb'),
'filename' => basename($imagePath),
],
],
'timeout' => 15,
]);
$status = $response->getStatusCode();
if ($status !== 200) {
throw new \RuntimeException('Face service HTTP error: ' . $status);
}
$data = json_decode($response->getBody(), true);
if (! is_array($data) || empty($data['embedding']) || ! is_array($data['embedding'])) {
throw new \RuntimeException('Face service response invalid');
}
$embedding = array_map('floatval', $data['embedding']);
if (count($embedding) !== $this->embeddingDim) {
throw new \RuntimeException('Embedding dimensi tidak sesuai: ' . count($embedding));
}
$facesCount = (int) ($data['faces_count'] ?? 0);
$faceSize = (float) ($data['face_size'] ?? 0);
$blur = (float) ($data['blur'] ?? 0);
$brightness = (float) ($data['brightness'] ?? 0);
// Quality gates (bisa di-tune lewat ENV nanti)
if ($facesCount !== 1) {
throw new \RuntimeException('Foto harus mengandung tepat 1 wajah (faces_count=' . $facesCount . ')');
}
if ($faceSize < (float) (env('FACE_MIN_SIZE', 80))) {
throw new \RuntimeException('Wajah terlalu kecil untuk verifikasi');
}
if ($blur < (float) (env('FACE_MIN_BLUR', 30))) {
throw new \RuntimeException('Foto terlalu blur, silakan ulangi');
}
if ($brightness < (float) (env('FACE_MIN_BRIGHTNESS', 0.2))) {
throw new \RuntimeException('Foto terlalu gelap, silakan cari cahaya lebih terang');
}
return [
'embedding' => $embedding,
'quality_score' => (float) ($data['quality_score'] ?? 1.0),
];
}
/**
* Cosine similarity antara dua embedding.
*
* @param float[] $a
* @param float[] $b
*/
public function cosineSimilarity(array $a, array $b): float
{
if (count($a) !== count($b) || count($a) === 0) {
return 0.0;
}
$dot = 0.0;
$na = 0.0;
$nb = 0.0;
$n = count($a);
for ($i = 0; $i < $n; $i++) {
$va = (float) $a[$i];
$vb = (float) $b[$i];
$dot += $va * $vb;
$na += $va * $va;
$nb += $vb * $vb;
}
if ($na <= 0.0 || $nb <= 0.0) {
return 0.0;
}
return $dot / (sqrt($na) * sqrt($nb));
}
public function getDefaultThreshold(): float
{
return $this->defaultThreshold;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Modules\Geo\Entities;
use CodeIgniter\Entity\Entity;
/**
* Zone Entity
*
* Represents a geographic zone/geofence in the system.
*/
class Zone extends Entity
{
/**
* Attributes that can be mass assigned
*
* @var array<string>
*/
protected $allowedFields = [
'zone_code',
'zone_name',
'latitude',
'longitude',
'radius_meters',
'is_active',
];
/**
* Attributes that should be cast to specific types
*
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'latitude' => 'float',
'longitude' => 'float',
'radius_meters' => 'integer',
'is_active' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Check if zone is active
*
* @return bool
*/
public function isActive(): bool
{
return (bool) $this->attributes['is_active'];
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Modules\Geo\Models;
use App\Modules\Geo\Entities\Zone;
use CodeIgniter\Model;
/**
* Zone Model
*
* Handles database operations for zones.
*/
class ZoneModel extends Model
{
protected $table = 'zones';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = Zone::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'zone_code',
'zone_name',
'latitude',
'longitude',
'radius_meters',
'is_active',
];
// Dates
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation
protected $validationRules = [
'zone_code' => 'required|max_length[100]|is_unique[zones.zone_code,id,{id}]',
'zone_name' => 'required|max_length[255]',
'latitude' => 'required|decimal',
'longitude' => 'required|decimal',
'radius_meters' => 'required|integer|greater_than[0]',
'is_active' => 'permit_empty|in_list[0,1]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
/**
* Find zone by zone_code
*
* @param string $zoneCode
* @return Zone|null
*/
public function findByZoneCode(string $zoneCode): ?Zone
{
return $this->where('zone_code', $zoneCode)->first();
}
/**
* Find active zone by zone_code
*
* @param string $zoneCode
* @return Zone|null
*/
public function findActiveByZoneCode(string $zoneCode): ?Zone
{
return $this->where('zone_code', $zoneCode)
->where('is_active', 1)
->first();
}
/**
* Get all active zones
*
* @return array
*/
public function findAllActive(): array
{
return $this->where('is_active', 1)->findAll();
}
}

View File

@@ -0,0 +1,12 @@
<?php
/**
* Geo Module Routes
*
* This file is automatically loaded by ModuleLoader.
* Define your geo/zone management routes here.
*
* @var RouteCollection $routes
*/
// Geo routes will be defined here

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Modules\Geo\Services;
/**
* Geo Fence Service
*
* Handles geofencing logic using Haversine formula for distance calculation.
*/
class GeoFenceService
{
/**
* Earth's radius in meters
*/
private const EARTH_RADIUS_METERS = 6371000;
/**
* Check if a point (latitude, longitude) is inside a zone
*
* Uses Haversine formula to calculate distance between two points
* on Earth's surface and compares it to zone radius.
*
* @param float $lat Latitude of the point to check
* @param float $lng Longitude of the point to check
* @param array $zone Zone data with 'latitude', 'longitude', and 'radius_meters'
* @return bool True if point is inside zone, false otherwise
*/
public function isInsideZone(float $lat, float $lng, array $zone): bool
{
// Validate zone data
if (!isset($zone['latitude']) || !isset($zone['longitude']) || !isset($zone['radius_meters'])) {
return false;
}
$zoneLat = (float) $zone['latitude'];
$zoneLng = (float) $zone['longitude'];
$radiusMeters = (int) $zone['radius_meters'];
// Calculate distance using Haversine formula
$distance = $this->calculateHaversineDistance($lat, $lng, $zoneLat, $zoneLng);
// Check if distance is within radius
return $distance <= $radiusMeters;
}
/**
* Calculate distance between two points using Haversine formula
*
* Haversine formula calculates the great-circle distance between
* two points on a sphere given their longitudes and latitudes.
*
* @param float $lat1 Latitude of first point
* @param float $lng1 Longitude of first point
* @param float $lat2 Latitude of second point
* @param float $lng2 Longitude of second point
* @return float Distance in meters
*/
private function calculateHaversineDistance(float $lat1, float $lng1, float $lat2, float $lng2): float
{
// Convert degrees to radians
$lat1Rad = deg2rad($lat1);
$lng1Rad = deg2rad($lng1);
$lat2Rad = deg2rad($lat2);
$lng2Rad = deg2rad($lng2);
// Calculate differences
$deltaLat = $lat2Rad - $lat1Rad;
$deltaLng = $lng2Rad - $lng1Rad;
// Haversine formula
$a = sin($deltaLat / 2) * sin($deltaLat / 2) +
cos($lat1Rad) * cos($lat2Rad) *
sin($deltaLng / 2) * sin($deltaLng / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
// Distance in meters
$distance = self::EARTH_RADIUS_METERS * $c;
return $distance;
}
/**
* Calculate distance between two points (public helper method)
*
* @param float $lat1 Latitude of first point
* @param float $lng1 Longitude of first point
* @param float $lat2 Latitude of second point
* @param float $lng2 Longitude of second point
* @return float Distance in meters
*/
public function calculateDistance(float $lat1, float $lng1, float $lat2, float $lng2): float
{
return $this->calculateHaversineDistance($lat1, $lng1, $lat2, $lng2);
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace App\Modules\Mobile\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\StudentModel;
use App\Modules\Attendance\Services\AttendanceCheckinService;
use App\Modules\Mobile\Models\StudentMobileAccountModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Check-in via QR (guru generate QR di dashboard, siswa scan di app).
* POST /api/mobile/checkin-qr
* Body: { nisn, pin, qr_token [, lat, lng ] }
*
* Absen masuk/pulang: POST /api/mobile/checkin-masuk-pulang
* Body: { nisn, pin, type: 'masuk'|'pulang', lat, lng }
*/
class CheckinController extends BaseApiController
{
/**
* Absen mapel via scan QR yang ditampilkan guru.
*/
public function checkinQr(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$nisn = trim((string) ($payload['nisn'] ?? ''));
$pin = (string) ($payload['pin'] ?? '');
$qrToken = trim((string) ($payload['qr_token'] ?? ''));
if ($nisn === '' || $pin === '' || $qrToken === '') {
return $this->errorResponse('NISN, PIN, dan qr_token wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$studentModel = new StudentModel();
$student = $studentModel->findByNisn($nisn);
if (!$student) {
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$accountModel = new StudentMobileAccountModel();
$accountRow = $accountModel->where('student_id', (int) $student->id)->first();
if (!$accountRow || empty($accountRow['pin_hash']) || !password_verify($pin, $accountRow['pin_hash'])) {
return $this->errorResponse('PIN salah', null, null, ResponseInterface::HTTP_UNAUTHORIZED);
}
$studentId = (int) $student->id;
$checkinPayload = [
'student_id' => $studentId,
'qr_token' => $qrToken,
'lat' => $payload['lat'] ?? 0,
'lng' => $payload['lng'] ?? 0,
];
$checkinService = new AttendanceCheckinService();
$result = $checkinService->checkinByQr($checkinPayload);
$messages = [
'PRESENT' => 'Absensi berhasil (Hadir)',
'LATE' => 'Absensi berhasil (Terlambat)',
'INVALID_QR_TOKEN' => 'QR tidak valid atau sudah kadaluarsa',
'STUDENT_NOT_IN_CLASS' => 'Siswa tidak termasuk kelas untuk mapel ini',
'ALREADY_CHECKED_IN' => 'Sudah absen untuk mapel ini hari ini',
'NO_SCHEDULE' => 'Jadwal tidak ditemukan',
'INVALID_DEVICE' => 'Kesalahan sistem',
];
$message = $messages[$result['status']] ?? $result['status'];
return $this->successResponse($result, $message);
}
/**
* POST /api/mobile/checkin-masuk-pulang
* Body: { nisn, pin, type: 'masuk'|'pulang', lat, lng }
* Absen masuk atau pulang pakai jam dari Pengaturan Presensi, auth NISN+PIN.
*/
public function checkinMasukPulang(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$nisn = trim((string) ($payload['nisn'] ?? ''));
$pin = (string) ($payload['pin'] ?? '');
$type = strtolower(trim((string) ($payload['type'] ?? '')));
$lat = (float) ($payload['lat'] ?? 0);
$lng = (float) ($payload['lng'] ?? 0);
if ($nisn === '' || $pin === '') {
return $this->errorResponse('NISN dan PIN wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
if ($type !== 'masuk' && $type !== 'pulang') {
return $this->errorResponse('type harus masuk atau pulang', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$studentModel = new StudentModel();
$student = $studentModel->findByNisn($nisn);
if (! $student) {
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$accountModel = new StudentMobileAccountModel();
$accountRow = $accountModel->where('student_id', (int) $student->id)->first();
if (! $accountRow || empty($accountRow['pin_hash']) || ! password_verify($pin, $accountRow['pin_hash'])) {
return $this->errorResponse('PIN salah', null, null, ResponseInterface::HTTP_UNAUTHORIZED);
}
$checkinService = new AttendanceCheckinService();
$result = $checkinService->checkinMasukPulang([
'student_id' => (int) $student->id,
'type' => $type,
'lat' => $lat,
'lng' => $lng,
]);
$messages = [
'PRESENT' => $type === 'masuk' ? 'Absen masuk berhasil.' : 'Absen pulang berhasil.',
'LATE' => 'Absensi tercatat.',
'OUTSIDE_ZONE' => 'Anda di luar jangkauan sekolah.',
'NO_SCHEDULE' => 'Data tidak valid.',
'INVALID_DEVICE' => 'Device aplikasi mobile belum dikonfigurasi. Hubungi admin untuk menambah device dengan kode MOBILE_APP.',
'ALREADY_CHECKED_IN' => $type === 'masuk' ? 'Sudah absen masuk hari ini.' : 'Sudah absen pulang hari ini.',
'ABSENCE_WINDOW_CLOSED' => 'Di luar jam absen. Cek jam masuk/pulang di pengaturan sekolah.',
];
$message = $messages[$result['status']] ?? $result['status'];
return $this->successResponse($result, $message);
}
/**
* GET /api/mobile/attendance/today?student_id=123
* Returns today's attendance status for the student (untuk UI tombol Masuk/Pulang).
*/
public function todayStatus(): ResponseInterface
{
$studentId = (int) $this->request->getGet('student_id');
if ($studentId < 1) {
return $this->errorResponse('student_id wajib', null, null, ResponseInterface::HTTP_BAD_REQUEST);
}
$db = \Config\Database::connect();
$today = date('Y-m-d');
$rows = $db->table('attendance_sessions')
->select('checkin_at, checkin_type')
->where('student_id', $studentId)
->where('attendance_date', $today)
->orderBy('checkin_at', 'ASC')
->get()
->getResultArray();
$has_masuk = false;
$has_pulang = false;
$first_at = null;
$last_at = null;
foreach ($rows as $r) {
$t = $r['checkin_at'] ?? '';
$ct = $r['checkin_type'] ?? 'mapel';
if ($t === '') {
continue;
}
if (!$first_at) {
$first_at = $t;
}
$last_at = $t;
if ($ct === 'masuk') {
$has_masuk = true;
} elseif ($ct === 'pulang') {
$has_pulang = true;
} else {
$timeOnly = date('H:i:s', strtotime($t));
if ($timeOnly < '12:00:00') {
$has_masuk = true;
}
if ($timeOnly >= '13:00:00') {
$has_pulang = true;
}
}
}
$data = [
'has_masuk' => $has_masuk,
'has_pulang' => $has_pulang,
'first_at' => $first_at,
'last_at' => $last_at,
'date' => $today,
];
return $this->successResponse($data, 'OK');
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Modules\Mobile\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\StudentModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Mengambil foto wajah referensi siswa untuk verifikasi di aplikasi mobile.
* Foto disimpan di writable/faces/{student_id}.jpg (atau .png).
* Sumber foto bisa dari sync Google Drive (folder Foto Siswa per kelas).
*/
class FacePhotoController extends BaseApiController
{
/** Subfolder di writable untuk foto wajah */
protected string $facesDir = 'faces';
/**
* GET /api/mobile/student/face-photo?student_id=123
* Mengembalikan gambar foto wajah siswa atau 404 jika belum ada.
*/
public function show(): ResponseInterface
{
$studentId = (int) $this->request->getGet('student_id');
if ($studentId < 1) {
return $this->response->setStatusCode(400)->setJSON([
'success' => false,
'message' => 'student_id wajib',
]);
}
$studentModel = new StudentModel();
$student = $studentModel->find($studentId);
if (! $student) {
return $this->response->setStatusCode(404)->setJSON([
'success' => false,
'message' => 'Siswa tidak ditemukan',
]);
}
$writable = defined('WRITEPATH') ? WRITEPATH : rtrim(realpath(FCPATH . '..' . DIRECTORY_SEPARATOR . 'writable') ?: FCPATH, DIRECTORY_SEPARATOR);
$facesPath = $writable . DIRECTORY_SEPARATOR . $this->facesDir;
$extensions = ['jpg', 'jpeg', 'png'];
$filePath = null;
foreach ($extensions as $ext) {
$p = $facesPath . DIRECTORY_SEPARATOR . $studentId . '.' . $ext;
if (is_file($p)) {
$filePath = $p;
break;
}
}
if ($filePath === null) {
return $this->response->setStatusCode(404)->setJSON([
'success' => false,
'message' => 'Foto wajah belum diunggah untuk siswa ini. Hubungi admin.',
]);
}
$mime = mime_content_type($filePath);
if (! in_array($mime, ['image/jpeg', 'image/png'], true)) {
$mime = 'image/jpeg';
}
$faceHash = isset($student->face_hash) ? (string) $student->face_hash : md5_file($filePath);
return $this->response
->setHeader('Content-Type', $mime)
->setHeader('Cache-Control', 'public, max-age=31536000')
->setHeader('X-Face-Hash', $faceHash)
->setHeader('Access-Control-Allow-Origin', '*')
->setBody(file_get_contents($filePath));
}
}

View File

@@ -0,0 +1,275 @@
<?php
namespace App\Modules\Mobile\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\ClassModel;
use App\Modules\Academic\Models\StudentModel;
use App\Modules\Mobile\Models\StudentMobileAccountModel;
use CodeIgniter\HTTP\ResponseInterface;
class RegistrationController extends BaseApiController
{
/**
* POST /api/mobile/login
*
* Body: { "nisn": "...", "pin": "..." }
*/
public function login(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$nisn = trim((string) ($payload['nisn'] ?? ''));
$pin = (string) ($payload['pin'] ?? '');
if ($nisn === '' || $pin === '') {
return $this->errorResponse('NISN dan PIN wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$studentModel = new StudentModel();
$student = $studentModel->findByNisn($nisn);
if (! $student) {
return $this->errorResponse('Siswa dengan NISN tersebut tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
if ((int) ($student->is_active ?? 1) === 0) {
return $this->errorResponse('Siswa ini tidak aktif', null, null, ResponseInterface::HTTP_FORBIDDEN);
}
$accountModel = new StudentMobileAccountModel();
$accountRow = $accountModel->where('student_id', (int) $student->id)->first();
if (! $accountRow || empty($accountRow['pin_hash']) || ! password_verify($pin, $accountRow['pin_hash'])) {
return $this->errorResponse('PIN salah', null, null, ResponseInterface::HTTP_UNAUTHORIZED);
}
$classModel = new ClassModel();
/** @var \App\Modules\Academic\Entities\ClassEntity|null $class */
$class = null;
if ($student->class_id) {
$class = $classModel->find($student->class_id);
}
$classLabel = '-';
if ($class !== null) {
$parts = array_filter([
trim((string) ($class->grade ?? '')),
trim((string) ($class->major ?? '')),
trim((string) ($class->name ?? '')),
]);
$label = implode(' ', $parts);
$classLabel = $label !== '' ? $label : ('Kelas #' . (int) $class->id);
}
$data = [
'student_id' => (int) $student->id,
'name' => (string) ($student->name ?? ''),
'nisn' => (string) ($student->nisn ?? ''),
'class_id' => $student->class_id ? (int) $student->class_id : null,
'class_label' => $classLabel,
];
return $this->successResponse($data, 'Login berhasil');
}
/**
* POST /api/mobile/register/nisn
*
* Body: { "nisn": "..." }
*/
public function checkNisn(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$nisn = trim((string) ($payload['nisn'] ?? ''));
if ($nisn === '') {
return $this->errorResponse('NISN wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$studentModel = new StudentModel();
$student = $studentModel->findByNisn($nisn);
if (! $student) {
return $this->errorResponse('Siswa dengan NISN tersebut tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
if ((int) ($student->is_active ?? 1) === 0) {
return $this->errorResponse('Siswa ini tidak aktif', null, null, ResponseInterface::HTTP_FORBIDDEN);
}
// Sudah punya akun mobile (PIN) → arahkan ke login
$accountModel = new StudentMobileAccountModel();
$accountRow = $accountModel->where('student_id', (int) $student->id)->first();
if ($accountRow && ! empty($accountRow['pin_hash'])) {
return $this->errorResponse('NISN ini sudah terdaftar. Silakan masuk.', null, null, ResponseInterface::HTTP_CONFLICT);
}
$classModel = new ClassModel();
$classes = $classModel->orderBy('grade', 'ASC')
->orderBy('major', 'ASC')
->orderBy('name', 'ASC')
->findAll();
$availableClasses = [];
foreach ($classes as $c) {
$parts = array_filter([
trim((string) ($c->grade ?? '')),
trim((string) ($c->major ?? '')),
trim((string) ($c->name ?? '')),
]);
$label = implode(' ', $parts);
$availableClasses[] = [
'id' => (int) $c->id,
'label' => $label !== '' ? $label : ('Kelas #' . (int) $c->id),
];
}
$currentClassId = $student->class_id ? (int) $student->class_id : null;
$currentClassLabel = null;
if ($currentClassId !== null) {
foreach ($availableClasses as $cls) {
if ($cls['id'] === $currentClassId) {
$currentClassLabel = $cls['label'];
break;
}
}
}
$data = [
'student_id' => (int) $student->id,
'name' => (string) ($student->name ?? ''),
'nisn' => (string) ($student->nisn ?? ''),
'current_class_id' => $currentClassId,
'current_class_label' => $currentClassLabel,
'available_classes' => $availableClasses,
];
return $this->successResponse($data, 'NISN valid');
}
/**
* POST /api/mobile/register/complete
*
* Body: { "student_id": 0, "class_id": 0, "pin": "123456" }
*/
public function complete(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$studentId = (int) ($payload['student_id'] ?? 0);
$classId = (int) ($payload['class_id'] ?? 0);
$pin = (string) ($payload['pin'] ?? '');
if ($studentId <= 0) {
return $this->errorResponse('student_id wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
if ($classId <= 0) {
return $this->errorResponse('Kelas wajib dipilih', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$pin = trim($pin);
if ($pin === '' || strlen($pin) < 4) {
return $this->errorResponse('PIN minimal 4 karakter', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$studentModel = new StudentModel();
$classModel = new ClassModel();
$student = $studentModel->find($studentId);
if (! $student) {
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$class = $classModel->find($classId);
if (! $class) {
return $this->errorResponse('Kelas tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
// Update class_id siswa (mapping pertama kali / koreksi)
$studentModel->update($studentId, ['class_id' => $classId]);
// Simpan / update akun mobile siswa
$accountModel = new StudentMobileAccountModel();
$pinHash = password_hash($pin, PASSWORD_BCRYPT);
$exists = $accountModel->where('student_id', $studentId)->first();
if ($exists) {
$accountModel->update((int) $exists['id'], [
'pin_hash' => $pinHash,
]);
} else {
$accountModel->insert([
'student_id' => $studentId,
'pin_hash' => $pinHash,
]);
}
$classLabelParts = array_filter([
trim((string) ($class->grade ?? '')),
trim((string) ($class->major ?? '')),
trim((string) ($class->name ?? '')),
]);
$classLabel = implode(' ', $classLabelParts);
$data = [
'student_id' => $studentId,
'name' => (string) ($student->name ?? ''),
'nisn' => (string) ($student->nisn ?? ''),
'class_id' => (int) $class->id,
'class_label' => $classLabel !== '' ? $classLabel : ('Kelas #' . (int) $class->id),
];
return $this->successResponse($data, 'Registrasi mobile berhasil');
}
/**
* POST /api/mobile/forgot-pin
*
* Body: { "nisn": "...", "new_pin": "1234", "new_pin_confirm": "1234" }
*/
public function forgotPin(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$nisn = trim((string) ($payload['nisn'] ?? ''));
$newPin = (string) ($payload['new_pin'] ?? '');
$newPinConfirm = (string) ($payload['new_pin_confirm'] ?? '');
if ($nisn === '') {
return $this->errorResponse('NISN wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$newPin = trim($newPin);
$newPinConfirm = trim($newPinConfirm);
if ($newPin === '' || strlen($newPin) < 4) {
return $this->errorResponse('PIN baru minimal 4 karakter', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
if ($newPin !== $newPinConfirm) {
return $this->errorResponse('PIN baru dan konfirmasi tidak sama', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$studentModel = new StudentModel();
$student = $studentModel->findByNisn($nisn);
if (! $student) {
return $this->errorResponse('Siswa dengan NISN tersebut tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
if ((int) ($student->is_active ?? 1) === 0) {
return $this->errorResponse('Siswa ini tidak aktif', null, null, ResponseInterface::HTTP_FORBIDDEN);
}
$accountModel = new StudentMobileAccountModel();
$accountRow = $accountModel->where('student_id', (int) $student->id)->first();
if (! $accountRow || empty($accountRow['pin_hash'])) {
return $this->errorResponse('NISN ini belum terdaftar. Silakan daftar dulu.', null, null, ResponseInterface::HTTP_CONFLICT);
}
$pinHash = password_hash($newPin, PASSWORD_BCRYPT);
$accountModel->update((int) $accountRow['id'], ['pin_hash' => $pinHash]);
return $this->successResponse(
['student_id' => (int) $student->id],
'PIN berhasil direset. Silakan masuk dengan PIN baru.'
);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Modules\Mobile\Models;
use CodeIgniter\Model;
class StudentMobileAccountModel extends Model
{
protected $table = 'student_mobile_accounts';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'student_id',
'pin_hash',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $validationRules = [
'student_id' => 'required|integer|is_not_unique[students.id]',
'pin_hash' => 'required|max_length[255]',
];
}

Some files were not shown because too many files have changed in this diff Show More