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
This commit is contained in:
@@ -18,6 +18,25 @@ class SyncController extends Controller
|
|||||||
$this->syncService = $syncService;
|
$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
|
public function index(): View
|
||||||
{
|
{
|
||||||
$baseUrl = Setting::getValue('kesiswaan_base_url', '');
|
$baseUrl = Setting::getValue('kesiswaan_base_url', '');
|
||||||
@@ -25,7 +44,10 @@ class SyncController extends Controller
|
|||||||
$hasToken = !empty($savedToken);
|
$hasToken = !empty($savedToken);
|
||||||
$studentCount = \App\Models\Student::count();
|
$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
|
public function syncNow(Request $request): RedirectResponse
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Attendance;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Student;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class AttendanceSyncController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Test connection — validate token and return simple status.
|
||||||
|
* GET /api/v1/attendance/ping?token=xxx
|
||||||
|
*/
|
||||||
|
public function ping(Request $request)
|
||||||
|
{
|
||||||
|
$expectedToken = Setting::getValue('ejurnal_sync_token', '');
|
||||||
|
$providedToken = $request->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->trustProxies(at: '*');
|
$middleware->trustProxies(at: '*');
|
||||||
|
$middleware->validateCsrfTokens(except: [
|
||||||
|
'api/*',
|
||||||
|
]);
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'role' => \App\Http\Middleware\CheckRole::class,
|
'role' => \App\Http\Middleware\CheckRole::class,
|
||||||
'permission' => \App\Http\Middleware\PermissionMiddleware::class,
|
'permission' => \App\Http\Middleware\PermissionMiddleware::class,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<p class="text-sm text-gray-500">Sinkronkan data siswa dari Database Kesiswaan</p>
|
<p class="text-sm text-gray-500">Sinkronkan data siswa dari Database Kesiswaan</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Sync Database Kesiswaan --}}
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
<form action="{{ route('settings.sync.run') }}" method="POST" class="p-6 space-y-5">
|
<form action="{{ route('settings.sync.run') }}" method="POST" class="p-6 space-y-5">
|
||||||
@csrf
|
@csrf
|
||||||
@@ -75,5 +76,76 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- E-Jurnal Sync Token --}}
|
||||||
|
<div class="mt-8"></div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mt-4">
|
||||||
|
<div class="p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Sinkronisasi Presensi E-Jurnal</h2>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Generate token untuk diinput di Aplikasi E-Jurnal Guru agar bisa push data presensi.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Token display --}}
|
||||||
|
@if($hasEjurnalToken)
|
||||||
|
<div class="p-4 bg-emerald-50 border border-emerald-200 rounded-xl">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="mt-0.5">
|
||||||
|
<i class="fa-solid fa-check-circle text-emerald-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-emerald-800">Token Aktif</div>
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<code class="flex-1 block bg-white border border-emerald-200 rounded-lg px-3 py-2 text-sm font-mono text-emerald-900 select-all break-all" id="ejurnalTokenDisplay">{{ $ejurnalToken }}</code>
|
||||||
|
<button type="button" onclick="copyToken()"
|
||||||
|
class="shrink-0 px-3 py-2 text-xs font-medium text-emerald-700 bg-emerald-100 border border-emerald-200 rounded-lg hover:bg-emerald-200 transition whitespace-nowrap">
|
||||||
|
<i class="fa-regular fa-copy"></i> Salin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-emerald-700">Salin token ini dan paste di menu Sinkronisasi Akademik → Aplikasi Ketertiban di E-Jurnal Guru.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Regenerate --}}
|
||||||
|
<form action="{{ route('settings.sync.ejurnal-token.generate') }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" onclick="return confirm('Regenerate token? Token lama akan langsung tidak berlaku.')"
|
||||||
|
class="w-full px-4 py-3 text-sm font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-xl hover:bg-amber-100 transition flex items-center justify-center gap-2">
|
||||||
|
<i class="fa-solid fa-rotate"></i>
|
||||||
|
Regenerate Token
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@else
|
||||||
|
{{-- Generate first token --}}
|
||||||
|
<form action="{{ route('settings.sync.ejurnal-token.generate') }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<div class="p-4 bg-slate-50 border border-slate-200 rounded-xl text-center">
|
||||||
|
<div class="text-sm text-slate-500 mb-3">Belum ada token. Generate satu sekarang:</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-2 px-6 py-3 text-sm font-medium text-white bg-emerald-600 rounded-xl hover:bg-emerald-700 transition shadow-sm">
|
||||||
|
<i class="fa-solid fa-key"></i>
|
||||||
|
Generate Token Sekarang
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyToken() {
|
||||||
|
const el = document.getElementById('ejurnalTokenDisplay');
|
||||||
|
if (!el) return;
|
||||||
|
navigator.clipboard.writeText(el.textContent).then(() => {
|
||||||
|
const btn = el.nextElementSibling;
|
||||||
|
if (btn) {
|
||||||
|
btn.innerHTML = '<i class="fa-regular fa-check"></i> Tersalin';
|
||||||
|
setTimeout(() => { btn.innerHTML = '<i class="fa-regular fa-copy"></i> Salin'; }, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
@@ -127,6 +127,8 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::middleware('permission:sync-data')->group(function () {
|
Route::middleware('permission:sync-data')->group(function () {
|
||||||
Route::get('/settings/sync', [SyncController::class, 'index'])->name('settings.sync');
|
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', [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 () {
|
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::get('/profile', [ProfileController::class, 'index'])->name('profile.index');
|
||||||
Route::post('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
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');
|
||||||
|
|||||||
Reference in New Issue
Block a user