From a1dfd75d7da4ded93ecbe73190e16d98893aa772 Mon Sep 17 00:00:00 2001 From: Bro Date: Fri, 24 Jul 2026 12:54:09 +0700 Subject: [PATCH] feat: API penerima push presensi dari e-jurnal - AttendanceSyncController: POST /api/v1/attendance/sync Token auth, upsert per (student_id, date, lesson_hour) - Ping endpoint: GET /api/v1/attendance/ping?token= - CSRF exclusion for api/* routes - Settings: generate/regenerate E-Jurnal token + copy - Menu Sinkron: form input token E-Jurnal --- app/Http/Controllers/Admin/SyncController.php | 24 ++- .../Api/AttendanceSyncController.php | 151 ++++++++++++++++++ bootstrap/app.php | 3 + resources/views/settings/sync.blade.php | 74 ++++++++- routes/web.php | 8 + 5 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/Api/AttendanceSyncController.php diff --git a/app/Http/Controllers/Admin/SyncController.php b/app/Http/Controllers/Admin/SyncController.php index 0e47cc9..04da4a0 100644 --- a/app/Http/Controllers/Admin/SyncController.php +++ b/app/Http/Controllers/Admin/SyncController.php @@ -18,6 +18,25 @@ class SyncController extends Controller $this->syncService = $syncService; } + public function saveEjurnalToken(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'ejurnal_token' => ['required', 'string', 'min:16'], + ]); + + Setting::setValue('ejurnal_sync_token', $validated['ejurnal_token'], 'integration', 'Token untuk push presensi dari E-Jurnal Guru'); + + return back()->with('success', 'Token E-Jurnal berhasil ' . ($request->input('_regenerate') ? 'diperbarui' : 'disimpan') . '.'); + } + + public function generateEjurnalToken(): RedirectResponse + { + $token = \Illuminate\Support\Str::random(48); + Setting::setValue('ejurnal_sync_token', $token, 'integration', 'Token untuk push presensi dari E-Jurnal Guru'); + + return back()->with('success', 'Token baru berhasil digenerate. Salin dan paste di E-Jurnal Guru.'); + } + public function index(): View { $baseUrl = Setting::getValue('kesiswaan_base_url', ''); @@ -25,7 +44,10 @@ class SyncController extends Controller $hasToken = !empty($savedToken); $studentCount = \App\Models\Student::count(); - return view('settings.sync', compact('baseUrl', 'savedToken', 'hasToken', 'studentCount')); + $ejurnalToken = Setting::getValue('ejurnal_sync_token', ''); + $hasEjurnalToken = !empty($ejurnalToken); + + return view('settings.sync', compact('baseUrl', 'savedToken', 'hasToken', 'studentCount', 'ejurnalToken', 'hasEjurnalToken')); } public function syncNow(Request $request): RedirectResponse diff --git a/app/Http/Controllers/Api/AttendanceSyncController.php b/app/Http/Controllers/Api/AttendanceSyncController.php new file mode 100644 index 0000000..1f26362 --- /dev/null +++ b/app/Http/Controllers/Api/AttendanceSyncController.php @@ -0,0 +1,151 @@ +input('token'); + + if (empty($expectedToken) || $providedToken !== $expectedToken) { + return response()->json([ + 'success' => false, + 'message' => 'Token tidak valid.', + ], 401); + } + + return response()->json([ + 'success' => true, + 'message' => 'Koneksi OK.', + 'data' => [ + 'app' => config('app.name', 'Aplikasi Ketertiban'), + 'students' => Student::where('is_active', true)->count(), + 'token_valid' => true, + ], + ]); + } + /** + * Receive attendance push from E-Jurnal. + * + * Request body: + * { + * "token": "shared-secret", + * "attendances": [ + * { + * "source_student_id": 123, + * "date": "2026-07-24", + * "lesson_hour": 1, + * "status": "hadir", + * "notes": null + * } + * ] + * } + */ + public function sync(Request $request) + { + // Validate token + $expectedToken = Setting::getValue('ejurnal_sync_token', ''); + $providedToken = $request->input('token'); + + if (empty($expectedToken) || $providedToken !== $expectedToken) { + Log::warning('Attendance sync: invalid token', [ + 'ip' => $request->ip(), + 'provided' => substr((string)$providedToken, 0, 10), + ]); + return response()->json([ + 'success' => false, + 'message' => 'Invalid or missing sync token.', + ], 401); + } + + // Validate payload structure + $request->validate([ + 'attendances' => 'required|array|min:1|max:500', + 'attendances.*.source_student_id' => 'required|integer', + 'attendances.*.date' => 'required|date', + 'attendances.*.lesson_hour' => 'required|integer|min:1|max:10', + 'attendances.*.status' => 'required|in:hadir,sakit,izin,alpha', + 'attendances.*.notes' => 'nullable|string|max:255', + ]); + + $attendances = $request->input('attendances'); + $results = [ + 'created' => 0, + 'updated' => 0, + 'skipped' => 0, + 'errors' => [], + ]; + + // Lookup map: source_student_id -> local student id + $sourceIds = array_unique(array_column($attendances, 'source_student_id')); + $studentMap = Student::whereIn('source_id', $sourceIds) + ->where('is_active', true) + ->pluck('id', 'source_id'); + + foreach ($attendances as $item) { + $sourceId = $item['source_student_id']; + + // Skip if student not found in local DB + if (!isset($studentMap[$sourceId])) { + $results['skipped']++; + $results['errors'][] = "Student source_id={$sourceId} not found"; + continue; + } + + $localStudentId = $studentMap[$sourceId]; + + // Map status from e-jurnal to aplikasi-ketertiban format + $statusMap = [ + 'present' => 'hadir', + 'sick' => 'sakit', + 'permission' => 'izin', + 'absent' => 'alpha', + ]; + $localStatus = $statusMap[$item['status']] ?? $item['status']; + + try { + // Upsert: unique constraint on (student_id, date, lesson_hour) + $attendance = Attendance::updateOrCreate( + [ + 'student_id' => $localStudentId, + 'date' => $item['date'], + 'lesson_hour' => $item['lesson_hour'], + ], + [ + 'status' => $localStatus, + 'notes' => $item['notes'] ?? null, + 'recorded_by' => null, // system-generated + ] + ); + + if ($attendance->wasRecentlyCreated) { + $results['created']++; + } else { + $results['updated']++; + } + } catch (\Exception $e) { + $results['errors'][] = "student_id={$sourceId} jam={$item['lesson_hour']}: " . $e->getMessage(); + $results['skipped']++; + } + } + + return response()->json([ + 'success' => true, + 'message' => "Sync completed: {$results['created']} created, {$results['updated']} updated, {$results['skipped']} skipped.", + 'results' => $results, + ]); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 3614753..53206e2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,6 +13,9 @@ return Application::configure(basePath: dirname(__DIR__)) ) ->withMiddleware(function (Middleware $middleware): void { $middleware->trustProxies(at: '*'); + $middleware->validateCsrfTokens(except: [ + 'api/*', + ]); $middleware->alias([ 'role' => \App\Http\Middleware\CheckRole::class, 'permission' => \App\Http\Middleware\PermissionMiddleware::class, diff --git a/resources/views/settings/sync.blade.php b/resources/views/settings/sync.blade.php index c0b79f9..ccfae55 100644 --- a/resources/views/settings/sync.blade.php +++ b/resources/views/settings/sync.blade.php @@ -9,6 +9,7 @@

Sinkronkan data siswa dari Database Kesiswaan

+ {{-- Sync Database Kesiswaan --}}
@csrf @@ -75,5 +76,76 @@
+ + {{-- E-Jurnal Sync Token --}} +
+
+
+
+

Sinkronisasi Presensi E-Jurnal

+

Generate token untuk diinput di Aplikasi E-Jurnal Guru agar bisa push data presensi.

+
+ + {{-- Token display --}} + @if($hasEjurnalToken) +
+
+
+ +
+
+
Token Aktif
+
+ {{ $ejurnalToken }} + +
+

Salin token ini dan paste di menu Sinkronisasi Akademik → Aplikasi Ketertiban di E-Jurnal Guru.

+
+
+
+ + {{-- Regenerate --}} +
+ @csrf + +
+ + @else + {{-- Generate first token --}} +
+ @csrf +
+
Belum ada token. Generate satu sekarang:
+ +
+
+ @endif +
+
+ + -@endsection +@endsection \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 67d6529..ae1d4f8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -127,6 +127,8 @@ Route::middleware('auth')->group(function () { Route::middleware('permission:sync-data')->group(function () { Route::get('/settings/sync', [SyncController::class, 'index'])->name('settings.sync'); Route::post('/settings/sync', [SyncController::class, 'syncNow'])->name('settings.sync.run'); + Route::post('/settings/sync/ejurnal-token', [SyncController::class, 'saveEjurnalToken'])->name('settings.sync.ejurnal-token'); + Route::post('/settings/sync/ejurnal-token/generate', [SyncController::class, 'generateEjurnalToken'])->name('settings.sync.ejurnal-token.generate'); }); Route::middleware('permission:users-manage')->group(function () { @@ -148,3 +150,9 @@ Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'index'])->name('profile.index'); Route::post('/profile', [ProfileController::class, 'update'])->name('profile.update'); }); + +// ===== API: Internal Sync (token auth, not session) ===== +Route::post('/api/v1/attendance/sync', [App\Http\Controllers\Api\AttendanceSyncController::class, 'sync']) + ->name('api.attendance.sync'); +Route::get('/api/v1/attendance/ping', [App\Http\Controllers\Api\AttendanceSyncController::class, 'ping']) + ->name('api.attendance.ping');