init backend presensi
This commit is contained in:
158
app/Modules/Academic/Controllers/ClassController.php
Normal file
158
app/Modules/Academic/Controllers/ClassController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
185
app/Modules/Academic/Controllers/DapodikSyncController.php
Normal file
185
app/Modules/Academic/Controllers/DapodikSyncController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
129
app/Modules/Academic/Controllers/LessonSlotController.php
Normal file
129
app/Modules/Academic/Controllers/LessonSlotController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 1–5, 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
195
app/Modules/Academic/Controllers/StudentController.php
Normal file
195
app/Modules/Academic/Controllers/StudentController.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
121
app/Modules/Academic/Controllers/SubjectController.php
Normal file
121
app/Modules/Academic/Controllers/SubjectController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
63
app/Modules/Academic/Controllers/TeacherController.php
Normal file
63
app/Modules/Academic/Controllers/TeacherController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Modules/Academic/Entities/ClassEntity.php
Normal file
37
app/Modules/Academic/Entities/ClassEntity.php
Normal 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',
|
||||
];
|
||||
}
|
||||
24
app/Modules/Academic/Entities/LessonSlot.php
Normal file
24
app/Modules/Academic/Entities/LessonSlot.php
Normal 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',
|
||||
];
|
||||
}
|
||||
50
app/Modules/Academic/Entities/Schedule.php
Normal file
50
app/Modules/Academic/Entities/Schedule.php
Normal 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',
|
||||
];
|
||||
}
|
||||
44
app/Modules/Academic/Entities/Student.php
Normal file
44
app/Modules/Academic/Entities/Student.php
Normal 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',
|
||||
];
|
||||
}
|
||||
34
app/Modules/Academic/Entities/Subject.php
Normal file
34
app/Modules/Academic/Entities/Subject.php
Normal 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',
|
||||
];
|
||||
}
|
||||
57
app/Modules/Academic/Models/ClassModel.php
Normal file
57
app/Modules/Academic/Models/ClassModel.php
Normal 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 = [];
|
||||
}
|
||||
30
app/Modules/Academic/Models/DapodikRombelMappingModel.php
Normal file
30
app/Modules/Academic/Models/DapodikRombelMappingModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
36
app/Modules/Academic/Models/DapodikSyncJobModel.php
Normal file
36
app/Modules/Academic/Models/DapodikSyncJobModel.php
Normal 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';
|
||||
}
|
||||
91
app/Modules/Academic/Models/LessonSlotModel.php
Normal file
91
app/Modules/Academic/Models/LessonSlotModel.php
Normal 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);
|
||||
}
|
||||
}
|
||||
195
app/Modules/Academic/Models/ScheduleModel.php
Normal file
195
app/Modules/Academic/Models/ScheduleModel.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
77
app/Modules/Academic/Models/StudentModel.php
Normal file
77
app/Modules/Academic/Models/StudentModel.php
Normal 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();
|
||||
}
|
||||
}
|
||||
53
app/Modules/Academic/Models/SubjectModel.php
Normal file
53
app/Modules/Academic/Models/SubjectModel.php
Normal 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 = [];
|
||||
}
|
||||
56
app/Modules/Academic/Models/TeacherSubjectModel.php
Normal file
56
app/Modules/Academic/Models/TeacherSubjectModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
45
app/Modules/Academic/Routes.php
Normal file
45
app/Modules/Academic/Routes.php
Normal 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');
|
||||
});
|
||||
120
app/Modules/Academic/Services/DapodikClient.php
Normal file
120
app/Modules/Academic/Services/DapodikClient.php
Normal 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;
|
||||
}
|
||||
}
|
||||
312
app/Modules/Academic/Services/DapodikSyncService.php
Normal file
312
app/Modules/Academic/Services/DapodikSyncService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
78
app/Modules/Academic/Services/ScheduleResolverService.php
Normal file
78
app/Modules/Academic/Services/ScheduleResolverService.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
1
app/Modules/Attendance/Controllers/.gitkeep
Normal file
1
app/Modules/Attendance/Controllers/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Attendance Module - Controllers
|
||||
89
app/Modules/Attendance/Controllers/AttendanceController.php
Normal file
89
app/Modules/Attendance/Controllers/AttendanceController.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
93
app/Modules/Attendance/Controllers/FaceLinkController.php
Normal file
93
app/Modules/Attendance/Controllers/FaceLinkController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
105
app/Modules/Attendance/Controllers/FaceVerifyController.php
Normal file
105
app/Modules/Attendance/Controllers/FaceVerifyController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
1
app/Modules/Attendance/Entities/.gitkeep
Normal file
1
app/Modules/Attendance/Entities/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Attendance Module - Entities
|
||||
63
app/Modules/Attendance/Entities/AttendanceSession.php
Normal file
63
app/Modules/Attendance/Entities/AttendanceSession.php
Normal 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';
|
||||
}
|
||||
1
app/Modules/Attendance/Models/.gitkeep
Normal file
1
app/Modules/Attendance/Models/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Attendance Module - Models
|
||||
88
app/Modules/Attendance/Models/AttendanceSessionModel.php
Normal file
88
app/Modules/Attendance/Models/AttendanceSessionModel.php
Normal 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 = [];
|
||||
}
|
||||
66
app/Modules/Attendance/Models/QrAttendanceTokenModel.php
Normal file
66
app/Modules/Attendance/Models/QrAttendanceTokenModel.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Modules/Attendance/Routes.php
Normal file
20
app/Modules/Attendance/Routes.php
Normal 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');
|
||||
});
|
||||
1
app/Modules/Attendance/Services/.gitkeep
Normal file
1
app/Modules/Attendance/Services/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Attendance Module - Services
|
||||
776
app/Modules/Attendance/Services/AttendanceCheckinService.php
Normal file
776
app/Modules/Attendance/Services/AttendanceCheckinService.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
189
app/Modules/Attendance/Services/AttendanceReportService.php
Normal file
189
app/Modules/Attendance/Services/AttendanceReportService.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
1
app/Modules/Auth/Controllers/.gitkeep
Normal file
1
app/Modules/Auth/Controllers/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Auth Module - Controllers
|
||||
65
app/Modules/Auth/Controllers/AuthController.php
Normal file
65
app/Modules/Auth/Controllers/AuthController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
232
app/Modules/Auth/Controllers/UserController.php
Normal file
232
app/Modules/Auth/Controllers/UserController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
1
app/Modules/Auth/Entities/.gitkeep
Normal file
1
app/Modules/Auth/Entities/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Auth Module - Entities
|
||||
26
app/Modules/Auth/Entities/Role.php
Normal file
26
app/Modules/Auth/Entities/Role.php
Normal 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',
|
||||
];
|
||||
}
|
||||
30
app/Modules/Auth/Entities/User.php
Normal file
30
app/Modules/Auth/Entities/User.php
Normal 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);
|
||||
}
|
||||
}
|
||||
1
app/Modules/Auth/Models/.gitkeep
Normal file
1
app/Modules/Auth/Models/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Auth Module - Models
|
||||
39
app/Modules/Auth/Models/RoleModel.php
Normal file
39
app/Modules/Auth/Models/RoleModel.php
Normal 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();
|
||||
}
|
||||
}
|
||||
46
app/Modules/Auth/Models/UserModel.php
Normal file
46
app/Modules/Auth/Models/UserModel.php
Normal 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();
|
||||
}
|
||||
}
|
||||
42
app/Modules/Auth/Models/UserRoleModel.php
Normal file
42
app/Modules/Auth/Models/UserRoleModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
17
app/Modules/Auth/Routes.php
Normal file
17
app/Modules/Auth/Routes.php
Normal 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');
|
||||
});
|
||||
1
app/Modules/Auth/Services/.gitkeep
Normal file
1
app/Modules/Auth/Services/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Auth Module - Services
|
||||
108
app/Modules/Auth/Services/AuthService.php
Normal file
108
app/Modules/Auth/Services/AuthService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
1
app/Modules/Dashboard/Controllers/.gitkeep
Normal file
1
app/Modules/Dashboard/Controllers/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Dashboard Module - Controllers
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
55
app/Modules/Dashboard/Controllers/DashboardController.php
Normal file
55
app/Modules/Dashboard/Controllers/DashboardController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
33
app/Modules/Dashboard/Controllers/HealthController.php
Normal file
33
app/Modules/Dashboard/Controllers/HealthController.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
121
app/Modules/Dashboard/Controllers/PresenceSettingsController.php
Normal file
121
app/Modules/Dashboard/Controllers/PresenceSettingsController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
108
app/Modules/Dashboard/Controllers/QrAttendanceController.php
Normal file
108
app/Modules/Dashboard/Controllers/QrAttendanceController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
105
app/Modules/Dashboard/Controllers/RealtimeStreamController.php
Normal file
105
app/Modules/Dashboard/Controllers/RealtimeStreamController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
1
app/Modules/Dashboard/Entities/.gitkeep
Normal file
1
app/Modules/Dashboard/Entities/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Dashboard Module - Entities
|
||||
1
app/Modules/Dashboard/Models/.gitkeep
Normal file
1
app/Modules/Dashboard/Models/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Dashboard Module - Models
|
||||
81
app/Modules/Dashboard/Models/SchoolPresenceSettingsModel.php
Normal file
81
app/Modules/Dashboard/Models/SchoolPresenceSettingsModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
28
app/Modules/Dashboard/Routes.php
Normal file
28
app/Modules/Dashboard/Routes.php
Normal 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');
|
||||
});
|
||||
1
app/Modules/Dashboard/Services/.gitkeep
Normal file
1
app/Modules/Dashboard/Services/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Dashboard Module - Services
|
||||
176
app/Modules/Dashboard/Services/DashboardRealtimeService.php
Normal file
176
app/Modules/Dashboard/Services/DashboardRealtimeService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
271
app/Modules/Dashboard/Services/DashboardScheduleService.php
Normal file
271
app/Modules/Dashboard/Services/DashboardScheduleService.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
171
app/Modules/Dashboard/Services/DashboardService.php
Normal file
171
app/Modules/Dashboard/Services/DashboardService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
1
app/Modules/Devices/Controllers/.gitkeep
Normal file
1
app/Modules/Devices/Controllers/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Devices Module - Controllers
|
||||
72
app/Modules/Devices/Controllers/DeviceAuthController.php
Normal file
72
app/Modules/Devices/Controllers/DeviceAuthController.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
80
app/Modules/Devices/Controllers/DeviceController.php
Normal file
80
app/Modules/Devices/Controllers/DeviceController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
71
app/Modules/Devices/Controllers/MobileController.php
Normal file
71
app/Modules/Devices/Controllers/MobileController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
1
app/Modules/Devices/Entities/.gitkeep
Normal file
1
app/Modules/Devices/Entities/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Devices Module - Entities
|
||||
49
app/Modules/Devices/Entities/Device.php
Normal file
49
app/Modules/Devices/Entities/Device.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
1
app/Modules/Devices/Models/.gitkeep
Normal file
1
app/Modules/Devices/Models/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Devices Module - Models
|
||||
101
app/Modules/Devices/Models/DeviceModel.php
Normal file
101
app/Modules/Devices/Models/DeviceModel.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
38
app/Modules/Devices/Routes.php
Normal file
38
app/Modules/Devices/Routes.php
Normal 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');
|
||||
// });
|
||||
1
app/Modules/Devices/Services/.gitkeep
Normal file
1
app/Modules/Devices/Services/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Devices Module - Services
|
||||
66
app/Modules/Devices/Services/DeviceAuthService.php
Normal file
66
app/Modules/Devices/Services/DeviceAuthService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
59
app/Modules/Discipline/Controllers/ViolationController.php
Normal file
59
app/Modules/Discipline/Controllers/ViolationController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
28
app/Modules/Discipline/Models/DisciplineLevelModel.php
Normal file
28
app/Modules/Discipline/Models/DisciplineLevelModel.php
Normal 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';
|
||||
}
|
||||
|
||||
28
app/Modules/Discipline/Models/StudentViolationModel.php
Normal file
28
app/Modules/Discipline/Models/StudentViolationModel.php
Normal 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';
|
||||
}
|
||||
|
||||
25
app/Modules/Discipline/Models/ViolationCategoryModel.php
Normal file
25
app/Modules/Discipline/Models/ViolationCategoryModel.php
Normal 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';
|
||||
}
|
||||
|
||||
28
app/Modules/Discipline/Models/ViolationModel.php
Normal file
28
app/Modules/Discipline/Models/ViolationModel.php
Normal 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';
|
||||
}
|
||||
|
||||
22
app/Modules/Discipline/Routes.php
Normal file
22
app/Modules/Discipline/Routes.php
Normal 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']);
|
||||
});
|
||||
|
||||
236
app/Modules/Face/Controllers/FaceController.php
Normal file
236
app/Modules/Face/Controllers/FaceController.php
Normal 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 (3–5 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;
|
||||
}
|
||||
}
|
||||
|
||||
32
app/Modules/Face/Models/StudentFaceModel.php
Normal file
32
app/Modules/Face/Models/StudentFaceModel.php
Normal 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',
|
||||
];
|
||||
}
|
||||
|
||||
15
app/Modules/Face/Routes.php
Normal file
15
app/Modules/Face/Routes.php
Normal 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');
|
||||
});
|
||||
|
||||
144
app/Modules/Face/Services/FaceService.php
Normal file
144
app/Modules/Face/Services/FaceService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
52
app/Modules/Geo/Entities/Zone.php
Normal file
52
app/Modules/Geo/Entities/Zone.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
95
app/Modules/Geo/Models/ZoneModel.php
Normal file
95
app/Modules/Geo/Models/ZoneModel.php
Normal 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();
|
||||
}
|
||||
}
|
||||
12
app/Modules/Geo/Routes.php
Normal file
12
app/Modules/Geo/Routes.php
Normal 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
|
||||
96
app/Modules/Geo/Services/GeoFenceService.php
Normal file
96
app/Modules/Geo/Services/GeoFenceService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
187
app/Modules/Mobile/Controllers/CheckinController.php
Normal file
187
app/Modules/Mobile/Controllers/CheckinController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
76
app/Modules/Mobile/Controllers/FacePhotoController.php
Normal file
76
app/Modules/Mobile/Controllers/FacePhotoController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
275
app/Modules/Mobile/Controllers/RegistrationController.php
Normal file
275
app/Modules/Mobile/Controllers/RegistrationController.php
Normal 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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
29
app/Modules/Mobile/Models/StudentMobileAccountModel.php
Normal file
29
app/Modules/Mobile/Models/StudentMobileAccountModel.php
Normal 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
Reference in New Issue
Block a user