diff --git a/app/Http/Controllers/Admin/ParentVerificationController.php b/app/Http/Controllers/Admin/ParentVerificationController.php new file mode 100644 index 0000000..c20ef5f --- /dev/null +++ b/app/Http/Controllers/Admin/ParentVerificationController.php @@ -0,0 +1,77 @@ +where('status', 'pending') + ->with(['user:id,name,username,phone,created_at', 'student:id,nisn,full_name,class_name,department_name']) + ->orderByDesc('created_at') + ->get(); + + $recent = ParentStudent::query() + ->where('status', '!=', 'pending') + ->with(['user:id,name,username', 'student:id,nisn,full_name,class_name']) + ->orderByDesc('updated_at') + ->limit(20) + ->get(); + + return view('parents.verification', compact('pending', 'recent')); + } + + public function approve(Request $request, int $linkId): RedirectResponse + { + $link = ParentStudent::findOrFail($linkId); + + if ($link->status !== 'pending') { + return back()->with('error', 'Tautan sudah diproses sebelumnya.'); + } + + $link->update([ + 'status' => 'active', + 'verified_at' => now(), + 'verified_by' => $request->user()->id, + ]); + + // Isi data kontak wali ke siswa bila masih kosong + if ($link->student && empty($link->student->parent_phone)) { + $link->student->update([ + 'parent_name' => $link->user->name, + 'parent_phone' => $link->user->phone ?: ($link->student->parent_phone), + ]); + } + + return back()->with('success', 'Akun wali "' . $link->user->name . '" untuk ' . $link->student->full_name . ' disetujui.'); + } + + public function reject(Request $request, int $linkId): RedirectResponse + { + $validated = $request->validate([ + 'reason' => ['nullable', 'string', 'max:255'], + ]); + + $link = ParentStudent::findOrFail($linkId); + + if ($link->status !== 'pending') { + return back()->with('error', 'Tautan sudah diproses sebelumnya.'); + } + + $link->update([ + 'status' => 'rejected', + 'verified_at' => now(), + 'verified_by' => $request->user()->id, + 'rejection_reason' => $validated['reason'] ?? null, + ]); + + return back()->with('success', 'Tautan ditolak.'); + } +} diff --git a/app/Http/Controllers/Api/ParentAuthController.php b/app/Http/Controllers/Api/ParentAuthController.php new file mode 100644 index 0000000..eae5409 --- /dev/null +++ b/app/Http/Controllers/Api/ParentAuthController.php @@ -0,0 +1,223 @@ +validate([ + 'nisn' => ['required', 'string', 'max:30'], + 'parent_phone' => ['required', 'string', 'min:9', 'max:20'], + 'name' => ['required', 'string', 'min:3', 'max:150'], + 'password' => ['required', 'string', 'min:6', 'max:100'], + 'relation' => ['nullable', Rule::in(['father', 'mother', 'guardian'])], + ]); + + $nisn = trim($validated['nisn']); + $phone = trim($validated['parent_phone']); + + $student = Student::query() + ->where('nisn', $nisn) + ->where('is_active', true) + ->first(); + + if (! $student) { + return response()->json([ + 'message' => 'NISN tidak ditemukan. Periksa kembali NISN anak Anda.', + 'errors' => ['nisn' => ['NISN tidak ditemukan atau siswa tidak aktif.']], + ], 422); + } + + // Akun wali memakai NISN sebagai username (satu akun per siswa pertama, + // anak berikutnya ditautkan lewat endpoint link). + $user = User::where('username', $nisn)->first(); + + if ($user && ! $user->isParent()) { + return response()->json([ + 'message' => 'NISN ini sudah terdaftar sebagai akun lain.', + 'errors' => ['nisn' => ['NISN sudah digunakan akun lain.']], + ], 422); + } + + if ($user) { + $already = ParentStudent::where('user_id', $user->id) + ->where('student_id', $student->id) + ->exists(); + + if ($already) { + return response()->json([ + 'message' => 'Akun wali untuk siswa ini sudah terdaftar. Silakan login.', + 'errors' => ['nisn' => ['Sudah terdaftar.']], + ], 422); + } + } else { + $user = User::create([ + 'name' => $validated['name'], + 'username' => $nisn, + 'email' => $nisn . '@parent.local', + 'password' => $validated['password'], + 'role' => 'parent', + 'roles' => ['parent'], + 'is_active' => true, + ]); + } + + // Verifikasi otomatis jika HP cocok dengan data orang tua di database + $autoVerified = PhoneHelper::matches($phone, $student->parent_phone); + + $link = ParentStudent::create([ + 'user_id' => $user->id, + 'student_id' => $student->id, + 'relation' => $validated['relation'] ?? null, + 'status' => $autoVerified ? 'active' : 'pending', + 'verified_at' => $autoVerified ? now() : null, + ]); + + $token = $user->createToken('parent-mobile')->plainTextToken; + + return response()->json([ + 'message' => $autoVerified + ? 'Registrasi berhasil. Data anak Anda sudah aktif.' + : 'Registrasi berhasil. Akun menunggu verifikasi pihak sekolah (biasanya 1×24 jam).', + 'token' => $token, + 'user' => $this->userPayload($user), + 'students' => $this->studentPayloads($user), + 'needs_verification' => ! $autoVerified, + 'link_id' => $link->id, + ], 201); + } + + /** + * Login wali murid (username = NISN anak, password = milik akun wali). + */ + public function login(Request $request): JsonResponse + { + $validated = $request->validate([ + 'username' => ['required', 'string'], + 'password' => ['required', 'string'], + ]); + + $user = User::where('username', trim($validated['username']))->first(); + + if (! $user || ! $user->isParent() || ! Hash::check($validated['password'], $user->password)) { + return response()->json([ + 'message' => 'NISN atau password salah.', + 'errors' => ['username' => ['Kredensial tidak valid.']], + ], 401); + } + + if (! $user->is_active) { + return response()->json([ + 'message' => 'Akun Anda dinonaktifkan. Hubungi pihak sekolah.', + ], 403); + } + + $token = $user->createToken('parent-mobile')->plainTextToken; + + return response()->json([ + 'message' => 'Login berhasil.', + 'token' => $token, + 'user' => $this->userPayload($user), + 'students' => $this->studentPayloads($user), + ]); + } + + public function me(Request $request): JsonResponse + { + $user = $request->user(); + + return response()->json([ + 'user' => $this->userPayload($user), + 'students' => $this->studentPayloads($user), + ]); + } + + public function logout(Request $request): JsonResponse + { + $request->user()->currentAccessToken()->delete(); + + return response()->json(['message' => 'Logout berhasil.']); + } + + protected function userPayload(User $user): array + { + return [ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + 'phone' => $user->phone, + 'role' => 'parent', + ]; + } + + /** + * Daftar anak + status tautan (active/pending) + ringkasan poin & SP. + */ + protected function studentPayloads(User $user): array + { + return $user->parentStudents() + ->with('student.class') + ->orderByDesc('created_at') + ->get() + ->map(function (ParentStudent $link) { + $student = $link->student; + $active = $link->status === 'active'; + + $payload = [ + 'link_id' => $link->id, + 'link_status' => $link->status, + 'relation' => $link->relation, + 'student' => [ + 'id' => $student->id, + 'nisn' => $student->nisn, + 'student_number' => $student->student_number, + 'full_name' => $student->full_name, + 'gender' => $student->gender, + 'class_name' => $student->class_name, + 'class_level' => $student->class_level, + 'department_name' => $student->department_name, + 'photo_url' => $student->photo_path ? url($student->photo_path) : null, + ], + ]; + + if ($active) { + $latestSp = $student->spLetters() + ->where('status', '!=', 'draft') + ->orderByDesc('created_at') + ->first(); + + $payload['summary'] = [ + 'total_points' => $student->total_points, + 'latest_sp' => $latestSp ? [ + 'letter_number' => $latestSp->letter_number, + 'title' => $latestSp->title, + 'status' => $latestSp->status, + 'total_points_at_time' => $latestSp->total_points_at_time, + 'date' => $latestSp->created_at?->toDateString(), + ] : null, + ]; + } + + return $payload; + }) + ->all(); + } +} diff --git a/app/Http/Controllers/Api/ParentNotificationController.php b/app/Http/Controllers/Api/ParentNotificationController.php new file mode 100644 index 0000000..d1cb0d7 --- /dev/null +++ b/app/Http/Controllers/Api/ParentNotificationController.php @@ -0,0 +1,87 @@ +user(); + + $studentIds = ParentStudent::where('user_id', $user->id) + ->where('status', 'active') + ->pluck('student_id') + ->all(); + + if (empty($studentIds)) { + return response()->json(['notifications' => []]); + } + + $notifications = ViolationNotification::query() + ->whereIn('student_id', $studentIds) + ->with('student:id,full_name,class_name') + ->orderByDesc('created_at') + ->limit(50) + ->get(); + + $items = $notifications->map(function (ViolationNotification $n) { + return [ + 'id' => $n->id, + 'student_name' => $n->student?->full_name, + 'class_name' => $n->student?->class_name, + 'channel' => $n->channel, + 'recipient' => $n->recipient, + 'message' => $n->message, + 'status' => $n->status, + 'created_at' => $n->created_at?->toISOString(), + ]; + }); + + return response()->json([ + 'notifications' => $items, + 'unread_count' => $notifications->where('status', '!=', 'sent')->count(), + ]); + } + + /** + * Daftarkan perangkat (token FCM) untuk push notification. + */ + public function registerDevice(Request $request): JsonResponse + { + $validated = $request->validate([ + 'platform' => ['required', Rule::in(['android', 'ios'])], + 'fcm_token' => ['required', 'string', 'max:255'], + 'device_name' => ['nullable', 'string', 'max:100'], + ]); + + $user = $request->user(); + + $device = ParentDevice::updateOrCreate( + [ + 'user_id' => $user->id, + 'fcm_token' => $validated['fcm_token'], + ], + [ + 'platform' => $validated['platform'], + 'device_name' => $validated['device_name'] ?? null, + 'last_active_at' => now(), + ] + ); + + return response()->json([ + 'message' => 'Perangkat terdaftar.', + 'device_id' => $device->id, + ], 201); + } +} diff --git a/app/Http/Controllers/Api/ParentStudentController.php b/app/Http/Controllers/Api/ParentStudentController.php new file mode 100644 index 0000000..43e2aac --- /dev/null +++ b/app/Http/Controllers/Api/ParentStudentController.php @@ -0,0 +1,206 @@ +studentPayloads($request->user()); + + return response()->json(['students' => $payload]); + } + + /** + * Tautkan anak tambahan (kakak/adik) ke akun wali. + */ + public function link(Request $request): JsonResponse + { + $validated = $request->validate([ + 'nisn' => ['required', 'string', 'max:30'], + 'parent_phone' => ['required', 'string', 'min:9', 'max:20'], + 'relation' => ['nullable', Rule::in(['father', 'mother', 'guardian'])], + ]); + + $user = $request->user(); + $nisn = trim($validated['nisn']); + + $student = Student::query() + ->where('nisn', $nisn) + ->where('is_active', true) + ->first(); + + if (! $student) { + return response()->json([ + 'message' => 'NISN tidak ditemukan. Periksa kembali NISN anak Anda.', + 'errors' => ['nisn' => ['NISN tidak ditemukan atau siswa tidak aktif.']], + ], 422); + } + + $exists = ParentStudent::where('user_id', $user->id) + ->where('student_id', $student->id) + ->exists(); + + if ($exists) { + return response()->json([ + 'message' => 'Siswa ini sudah tertaut ke akun Anda.', + 'errors' => ['nisn' => ['Sudah tertaut.']], + ], 422); + } + + $autoVerified = PhoneHelper::matches(trim($validated['parent_phone']), $student->parent_phone); + + $link = ParentStudent::create([ + 'user_id' => $user->id, + 'student_id' => $student->id, + 'relation' => $validated['relation'] ?? null, + 'status' => $autoVerified ? 'active' : 'pending', + 'verified_at' => $autoVerified ? now() : null, + ]); + + return response()->json([ + 'message' => $autoVerified + ? 'Siswa berhasil ditautkan dan langsung aktif.' + : 'Siswa ditautkan, menunggu verifikasi pihak sekolah.', + 'link_id' => $link->id, + 'link_status' => $link->status, + 'needs_verification' => ! $autoVerified, + ], 201); + } + + /** + * Riwayat pelanggaran anak (hanya untuk tautan aktif). + */ + public function violations(Request $request, int $student): JsonResponse + { + $user = $request->user(); + $student = $this->assertActiveChild($user, $student); + if ($student instanceof JsonResponse) { + return $student; + } + + $violations = Violation::query() + ->where('student_id', $student->id) + ->whereNull('deleted_at') + ->with(['violationType.category', 'evidences']) + ->orderByDesc('violation_date') + ->orderByDesc('created_at') + ->paginate(20); + + $items = collect($violations->items())->map(function (Violation $v) { + return [ + 'id' => $v->id, + 'violation_date' => $v->violation_date?->toDateString(), + 'violation_time' => $v->violation_time?->format('H:i'), + 'description' => $v->description, + 'location' => $v->location, + 'points' => $v->points, + 'sanction' => $v->sanction, + 'handling_status' => $v->handling_status, + 'is_verified' => (bool) $v->is_verified, + 'type' => $v->violationType ? [ + 'name' => $v->violationType->name, + 'category' => $v->violationType->category?->name, + ] : null, + 'evidences' => $v->evidences->map(fn ($e) => [ + 'id' => $e->id, + 'url' => url($e->file_path), + 'mime_type' => $e->mime_type, + ])->all(), + ]; + }); + + return response()->json([ + 'student' => $this->studentBrief($student), + 'total_points' => $student->total_points, + 'violations' => $items, + 'pagination' => [ + 'current_page' => $violations->currentPage(), + 'last_page' => $violations->lastPage(), + 'per_page' => $violations->perPage(), + 'total' => $violations->total(), + ], + ]); + } + + /** + * Daftar surat SP anak (selain draft). + */ + public function spLetters(Request $request, int $student): JsonResponse + { + $user = $request->user(); + $student = $this->assertActiveChild($user, $student); + if ($student instanceof JsonResponse) { + return $student; + } + + $letters = SpLetter::query() + ->where('student_id', $student->id) + ->where('status', '!=', 'draft') + ->orderByDesc('created_at') + ->get(); + + $items = $letters->map(function (SpLetter $sp) { + return [ + 'id' => $sp->id, + 'letter_number' => $sp->letter_number, + 'title' => $sp->title, + 'body' => $sp->body, + 'status' => $sp->status, + 'total_points_at_time' => $sp->total_points_at_time, + 'violations_included' => $sp->violations_included, + 'file_url' => $sp->file_path ? url($sp->file_path) : null, + 'created_at' => $sp->created_at?->toISOString(), + 'delivered_at' => $sp->delivered_at?->toISOString(), + ]; + }); + + return response()->json([ + 'student' => $this->studentBrief($student), + 'sp_letters' => $items, + ]); + } + + /** + * Pastikan siswa adalah anak aktif dari user. Return model atau JsonResponse 403. + */ + protected function assertActiveChild($user, int $studentId): Student|JsonResponse + { + $link = ParentStudent::where('user_id', $user->id) + ->where('student_id', $studentId) + ->where('status', 'active') + ->first(); + + if (! $link) { + return response()->json([ + 'message' => 'Akses ditolak. Siswa tidak tertaut atau masih menunggu verifikasi.', + ], 403); + } + + return $link->student; + } + + protected function studentBrief(Student $student): array + { + return [ + 'id' => $student->id, + 'nisn' => $student->nisn, + 'full_name' => $student->full_name, + 'class_name' => $student->class_name, + 'department_name' => $student->department_name, + ]; + } +} diff --git a/app/Models/ParentDevice.php b/app/Models/ParentDevice.php new file mode 100644 index 0000000..ddf6213 --- /dev/null +++ b/app/Models/ParentDevice.php @@ -0,0 +1,24 @@ + 'datetime', + ]; + } + + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/ParentStudent.php b/app/Models/ParentStudent.php new file mode 100644 index 0000000..1294879 --- /dev/null +++ b/app/Models/ParentStudent.php @@ -0,0 +1,31 @@ + 'datetime', + ]; + } + + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function student() + { + return $this->belongsTo(Student::class, 'student_id'); + } +} diff --git a/app/Models/Student.php b/app/Models/Student.php index bea40cc..3b406dd 100644 --- a/app/Models/Student.php +++ b/app/Models/Student.php @@ -10,7 +10,7 @@ class Student extends Model protected $fillable = [ 'source_id', 'nisn', 'student_number', 'full_name', 'gender', 'place_of_birth', 'date_of_birth', 'address', - 'phone_number', 'parent_phone', 'email', 'class_name', 'class_level', + 'phone_number', 'parent_phone', 'parent_name', 'email', 'class_name', 'class_level', 'department_code', 'department_name', 'academic_year_name', 'status', 'photo_path', 'class_id', 'is_active', 'metadata', 'synced_at', @@ -41,6 +41,11 @@ class Student extends Model return $this->belongsTo(Classes::class, 'class_id'); } + public function parentLinks() + { + return $this->hasMany(\App\Models\ParentStudent::class, 'student_id'); + } + public function getTotalPointsAttribute(): int { return $this->violations() diff --git a/app/Models/User.php b/app/Models/User.php index f059f1e..30ffa58 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,12 +5,13 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; - public const ROLES = ['admin', 'bk', 'wali_kelas', 'staff', 'other', 'kepala_sekolah', 'waka_kesiswaan', 'ketua_tim']; + public const ROLES = ['admin', 'bk', 'wali_kelas', 'staff', 'other', 'kepala_sekolah', 'waka_kesiswaan', 'ketua_tim', 'parent']; protected $fillable = [ 'name', 'username', 'email', 'phone', 'password', 'role', 'roles', 'is_active', 'active_session_token', @@ -66,6 +67,7 @@ class User extends Authenticatable public function isKepalaSekolah(): bool { return $this->hasRole('kepala_sekolah'); } public function isWakaKesiswaan(): bool { return $this->hasRole('waka_kesiswaan'); } public function isKetuaTim(): bool { return $this->hasRole('ketua_tim'); } + public function isParent(): bool { return $this->hasRole('parent'); } /** * Role dengan visibilitas data GLOBAL (semua kelas/siswa). @@ -136,6 +138,25 @@ class User extends Authenticatable return $this->hasMany(Violation::class, 'recorded_by'); } + /** + * Relasi akun wali murid → anak (pivot parent_students). + */ + public function parentStudents() + { + return $this->hasMany(\App\Models\ParentStudent::class, 'user_id'); + } + + /** + * Anak-anak (siswa) milik akun wali, hanya yang statusnya aktif. + */ + public function children() + { + return $this->belongsToMany(\App\Models\Student::class, 'parent_students', 'user_id', 'student_id') + ->withPivot(['relation', 'status', 'verified_at']) + ->withTimestamps() + ->wherePivot('status', 'active'); + } + public function appNotifications() { return $this->hasMany(AppNotification::class, 'user_id'); diff --git a/app/Services/StudentSyncService.php b/app/Services/StudentSyncService.php index f6fbb42..238833b 100644 --- a/app/Services/StudentSyncService.php +++ b/app/Services/StudentSyncService.php @@ -45,6 +45,13 @@ class StudentSyncService continue; } + // Data wali/orang tua: pilih kontak utama (guardian > father > mother) + $parents = collect($item['parents'] ?? []); + $primaryParent = $parents->first(fn ($p) => ($p['relation'] ?? '') === 'guardian') + ?? $parents->first(fn ($p) => ($p['relation'] ?? '') === 'father') + ?? $parents->first(fn ($p) => ($p['relation'] ?? '') === 'mother') + ?? $parents->first(); + // Use NISN as primary key, fallback to student_number if NISN is null $nisn = $item['nisn'] ?? null; if (!$nisn) { @@ -89,6 +96,8 @@ class StudentSyncService 'date_of_birth' => $item['date_of_birth'] ?: null, 'address' => $item['address'], 'phone_number' => $item['phone_number'], + 'parent_name' => $primaryParent ? trim((string) $primaryParent['name']) : null, + 'parent_phone' => $primaryParent ? ($primaryParent['phone_number'] ?: null) : null, 'email' => $item['email'], 'class_name' => $className, 'class_level' => $classData['level'] ?? $item['class_level'] ?? 'X', diff --git a/app/Support/PhoneHelper.php b/app/Support/PhoneHelper.php new file mode 100644 index 0000000..4dff865 --- /dev/null +++ b/app/Support/PhoneHelper.php @@ -0,0 +1,40 @@ +withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + apiPrefix: 'api/v1', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/composer.json b/composer.json index 6314533..78cbd1e 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "barryvdh/laravel-dompdf": "^3.1", "laravel/framework": "^13.8", "laravel/reverb": "^1.10", + "laravel/sanctum": "^4.3", "laravel/tinker": "^3.0", "phpoffice/phpspreadsheet": "^5.9" }, diff --git a/composer.lock b/composer.lock index 855f113..8619c32 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "afdc1f6e4dfd50c7b92edd5a03dbc47e", + "content-hash": "6e14b812ea81c92915b57125d300c527", "packages": [ { "name": "barryvdh/laravel-dompdf", @@ -1907,6 +1907,69 @@ }, "time": "2026-05-10T15:47:52+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-06-23T18:26:55+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.13", diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..cde73cf --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/database/migrations/2026_08_05_185341_create_personal_access_tokens_table.php b/database/migrations/2026_08_05_185341_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_08_05_185341_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_08_05_185343_add_parent_name_to_students_table.php b/database/migrations/2026_08_05_185343_add_parent_name_to_students_table.php new file mode 100644 index 0000000..5082de6 --- /dev/null +++ b/database/migrations/2026_08_05_185343_add_parent_name_to_students_table.php @@ -0,0 +1,26 @@ +string('parent_name', 150)->nullable()->after('parent_phone'); + } + }); + } + + public function down(): void + { + Schema::table('students', function (Blueprint $table) { + if (Schema::hasColumn('students', 'parent_name')) { + $table->dropColumn('parent_name'); + } + }); + } +}; diff --git a/database/migrations/2026_08_05_185344_create_parent_students_table.php b/database/migrations/2026_08_05_185344_create_parent_students_table.php new file mode 100644 index 0000000..3bad52e --- /dev/null +++ b/database/migrations/2026_08_05_185344_create_parent_students_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('student_id')->constrained('students')->cascadeOnDelete(); + // relation: father | mother | guardian (sesuai student_parents di kesiswaan) + $table->string('relation', 20)->nullable(); + // status: pending (menunggu verifikasi admin) | active | rejected + $table->string('status', 20)->default('pending')->index(); + $table->timestamp('verified_at')->nullable(); + $table->unsignedBigInteger('verified_by')->nullable(); + $table->string('rejection_reason', 255)->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'student_id']); + $table->index(['student_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('parent_students'); + } +}; diff --git a/database/migrations/2026_08_05_185345_create_parent_devices_table.php b/database/migrations/2026_08_05_185345_create_parent_devices_table.php new file mode 100644 index 0000000..c8873e9 --- /dev/null +++ b/database/migrations/2026_08_05_185345_create_parent_devices_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('platform', 20)->default('android'); // android | ios + $table->string('fcm_token', 255)->nullable(); + $table->string('device_name', 100)->nullable(); + $table->timestamp('last_active_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'fcm_token']); + }); + } + + public function down(): void + { + Schema::dropIfExists('parent_devices'); + } +}; diff --git a/database/migrations/2026_08_05_190000_add_parents_verify_permission.php b/database/migrations/2026_08_05_190000_add_parents_verify_permission.php new file mode 100644 index 0000000..0d3f8d5 --- /dev/null +++ b/database/migrations/2026_08_05_190000_add_parents_verify_permission.php @@ -0,0 +1,38 @@ +insertGetId([ + 'key' => 'parents-verify', + 'group' => 'wali', + 'label' => 'Verifikasi Akun Wali Murid', + 'description' => 'Menyetujui/menolak akun wali murid yang mendaftar dari aplikasi mobile', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + // Role yang berhak memverifikasi + foreach (['admin', 'bk', 'waka_kesiswaan', 'kepala_sekolah', 'ketua_tim'] as $role) { + DB::table('role_permissions')->insertOrIgnore([ + 'role' => $role, + 'permission_id' => $id, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + public function down(): void + { + $ids = DB::table('permissions')->where('key', 'parents-verify')->pluck('id'); + DB::table('role_permissions')->whereIn('permission_id', $ids)->delete(); + DB::table('permissions')->where('key', 'parents-verify')->delete(); + } +}; diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index d49b7b3..90d5687 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -197,6 +197,9 @@ @canPermission('view-point-audit') Riwayat Poin @endcanPermission + @canPermission('parents-verify') + Verifikasi Wali + @endcanPermission @canPermission('sync-data') Sinkronisasi @endcanPermission diff --git a/resources/views/parents/verification.blade.php b/resources/views/parents/verification.blade.php new file mode 100644 index 0000000..64776f0 --- /dev/null +++ b/resources/views/parents/verification.blade.php @@ -0,0 +1,132 @@ +@extends('layouts.app') + +@section('title', 'Verifikasi Wali Murid') + +@section('content') +
+ + {{-- Header --}} +
+
+
+
+
+ +
+

Verifikasi Akun Wali Murid

+

Akun wali yang mendaftar lewat aplikasi mobile & belum terverifikasi otomatis

+
+
+
+ + + {{ $pending->count() }} menunggu + +
+
+
+ + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + + @if(session('error')) +
+ {{ session('error') }} +
+ @endif + + {{-- Antrean pending --}} +
+
+

Antrean Verifikasi

+ Wali daftar via app → cocokkan data → verifikasi di sini +
+ + @if($pending->isEmpty()) +
+
+ +
+

Tidak ada antrean 🎉

+

Semua permintaan verifikasi sudah diproses.

+
+ @else +
+ @foreach($pending as $link) +
+
+
+ {{ strtoupper(substr($link->user->name, 0, 1)) }} +
+
+

{{ $link->user->name }}

+

+ NISN {{ $link->student->nisn }} · {{ $link->student->full_name }} · {{ $link->student->class_name }} +

+

Daftar: {{ $link->created_at?->diffForHumans() }}

+
+
+
+
+ @csrf + +
+
+ @csrf + +
+
+
+ @endforeach +
+ @endif +
+ + {{-- Riwayat terakhir --}} + @if($recent->isNotEmpty()) +
+
+

Terakhir Diproses

+
+
+ + + + + + + + + + + @foreach($recent as $link) + + + + + + + @endforeach + +
WaliSiswaStatusDiproses
{{ $link->user->name }}{{ $link->student->full_name }} ({{ $link->student->class_name }}) + @if($link->status === 'active') + Aktif + @else + Ditolak + @endif + {{ $link->verified_at?->diffForHumans() }}
+
+
+ @endif + +
+@endsection diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..ffaa806 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,40 @@ +middleware('throttle:5,1') + ->name('api.parent.register'); + +Route::post('/parent/login', [ParentAuthController::class, 'login']) + ->middleware('throttle:10,1') + ->name('api.parent.login'); + +// ===== Terproteksi: Bearer token ===== +Route::middleware('auth:sanctum')->prefix('parent')->group(function () { + // Akun + Route::get('/me', [ParentAuthController::class, 'me'])->name('api.parent.me'); + Route::post('/logout', [ParentAuthController::class, 'logout'])->name('api.parent.logout'); + + // Anak (wali) + Route::get('/students', [ParentStudentController::class, 'index'])->name('api.parent.students'); + Route::post('/students/link', [ParentStudentController::class, 'link'])->middleware('throttle:5,10')->name('api.parent.students.link'); + Route::get('/students/{student}/violations', [ParentStudentController::class, 'violations'])->name('api.parent.students.violations'); + Route::get('/students/{student}/sp-letters', [ParentStudentController::class, 'spLetters'])->name('api.parent.students.sp-letters'); + + // Notifikasi & perangkat + Route::get('/notifications', [ParentNotificationController::class, 'index'])->name('api.parent.notifications'); + Route::post('/devices', [ParentNotificationController::class, 'registerDevice'])->name('api.parent.devices'); +}); diff --git a/routes/web.php b/routes/web.php index 390e97b..b3574fe 100644 --- a/routes/web.php +++ b/routes/web.php @@ -140,6 +140,12 @@ Route::middleware('auth')->group(function () { Route::get('/point-audit', [\App\Http\Controllers\Admin\PointAuditController::class, 'index'])->name('point-audit.index'); }); + Route::middleware('permission:parents-verify')->group(function () { + Route::get('/parents/verification', [\App\Http\Controllers\Admin\ParentVerificationController::class, 'index'])->name('parents.verification'); + Route::post('/parents/verification/{link}/approve', [\App\Http\Controllers\Admin\ParentVerificationController::class, 'approve'])->name('parents.verification.approve'); + Route::post('/parents/verification/{link}/reject', [\App\Http\Controllers\Admin\ParentVerificationController::class, 'reject'])->name('parents.verification.reject'); + }); + // ===== Administrasi (permission-based) ===== Route::middleware('permission:settings-manage')->group(function () { Route::get('/settings', [SettingsController::class, 'index'])->name('settings.index'); diff --git a/tests/Feature/ParentApiTest.php b/tests/Feature/ParentApiTest.php new file mode 100644 index 0000000..94f5a76 --- /dev/null +++ b/tests/Feature/ParentApiTest.php @@ -0,0 +1,218 @@ + '0099990001', + 'student_number' => 'TEST/001', + 'full_name' => 'SISWA TEST', + 'class_name' => 'X TKJ 1', + 'class_level' => 'X', + 'department_name' => 'TKJ', + 'status' => 'active', + 'is_active' => true, + ], $overrides)); + } + + public function test_register_auto_verifies_when_phone_matches(): void + { + $this->makeStudent([ + 'nisn' => '0099990001', + 'parent_phone' => '081234567890', + 'parent_name' => 'Orang Tua Test', + ]); + + $response = $this->postJson('/api/v1/parent/register', [ + 'nisn' => '0099990001', + 'parent_phone' => '081234567890', + 'name' => 'Wali Test', + 'password' => 'rahasia123', + ]); + + $response->assertStatus(201) + ->assertJsonPath('needs_verification', false) + ->assertJsonPath('students.0.link_status', 'active') + ->assertJsonStructure(['token', 'user' => ['id', 'name', 'username']]); + + $this->assertDatabaseHas('parent_students', [ + 'status' => 'active', + ]); + } + + public function test_register_creates_pending_when_phone_not_match(): void + { + $this->makeStudent(['nisn' => '0099990001']); // parent_phone null + + $response = $this->postJson('/api/v1/parent/register', [ + 'nisn' => '0099990001', + 'parent_phone' => '081298765432', + 'name' => 'Wali Test', + 'password' => 'rahasia123', + ]); + + $response->assertStatus(201) + ->assertJsonPath('needs_verification', true) + ->assertJsonPath('students.0.link_status', 'pending'); + + $this->assertDatabaseHas('parent_students', [ + 'status' => 'pending', + ]); + } + + public function test_register_rejects_unknown_nisn(): void + { + $response = $this->postJson('/api/v1/parent/register', [ + 'nisn' => '0000000000', + 'parent_phone' => '081298765432', + 'name' => 'Wali Test', + 'password' => 'rahasia123', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors('nisn'); + } + + public function test_login_and_me(): void + { + $this->makeStudent(['nisn' => '0099990001']); + $this->postJson('/api/v1/parent/register', [ + 'nisn' => '0099990001', + 'parent_phone' => '081298765432', + 'name' => 'Wali Test', + 'password' => 'rahasia123', + ]); + + $response = $this->postJson('/api/v1/parent/login', [ + 'username' => '0099990001', + 'password' => 'rahasia123', + ]); + + $response->assertOk() + ->assertJsonStructure(['token', 'user' => ['username'], 'students']); + + $token = $response->json('token'); + + $this->withToken($token) + ->getJson('/api/v1/parent/me') + ->assertOk() + ->assertJsonPath('user.username', '0099990001'); + } + + public function test_pending_link_cannot_access_violations(): void + { + $student = $this->makeStudent(['nisn' => '0099990001']); + $user = User::factory()->create(['username' => '0099990001', 'role' => 'parent', 'roles' => ['parent']]); + ParentStudent::create([ + 'user_id' => $user->id, + 'student_id' => $student->id, + 'status' => 'pending', + ]); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/v1/parent/students/{$student->id}/violations") + ->assertForbidden(); + } + + public function test_active_link_can_access_violations(): void + { + $student = $this->makeStudent(['nisn' => '0099990001']); + $user = User::factory()->create(['username' => '0099990001', 'role' => 'parent', 'roles' => ['parent']]); + ParentStudent::create([ + 'user_id' => $user->id, + 'student_id' => $student->id, + 'status' => 'active', + 'verified_at' => now(), + ]); + + $category = \App\Models\ViolationCategory::create([ + 'name' => 'Kedisiplinan', + 'slug' => 'kedisiplinan', + ]); + + $type = ViolationType::create([ + 'category_id' => $category->id, + 'name' => 'Terlambat', + 'slug' => 'terlambat', + 'points' => 5, + 'is_active' => true, + ]); + + Violation::create([ + 'student_id' => $student->id, + 'student_class' => 'X TKJ 1', + 'violation_type_id' => $type->id, + 'points' => 5, + 'description' => 'Terlambat datang', + 'violation_date' => now()->toDateString(), + ]); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/v1/parent/students/{$student->id}/violations") + ->assertOk() + ->assertJsonPath('total_points', 5) + ->assertJsonCount(1, 'violations'); + } + + public function test_link_second_child(): void + { + $this->makeStudent(['nisn' => '0099990001']); + $this->makeStudent([ + 'nisn' => '0099990002', + 'student_number' => 'TEST/002', + 'full_name' => 'SISWA KEDUA', + 'parent_phone' => '081234567890', + ]); + + $token = $this->postJson('/api/v1/parent/register', [ + 'nisn' => '0099990001', + 'parent_phone' => '081298765432', + 'name' => 'Wali Test', + 'password' => 'rahasia123', + ])->json('token'); + + $this->withToken($token) + ->postJson('/api/v1/parent/students/link', [ + 'nisn' => '0099990002', + 'parent_phone' => '081234567890', + ]) + ->assertStatus(201) + ->assertJsonPath('link_status', 'active'); + } + + public function test_register_device(): void + { + $this->makeStudent(['nisn' => '0099990001']); + $token = $this->postJson('/api/v1/parent/register', [ + 'nisn' => '0099990001', + 'parent_phone' => '081298765432', + 'name' => 'Wali Test', + 'password' => 'rahasia123', + ])->json('token'); + + $this->withToken($token) + ->postJson('/api/v1/parent/devices', [ + 'platform' => 'android', + 'fcm_token' => 'fcm-abc-123', + 'device_name' => 'Pixel 8', + ]) + ->assertStatus(201); + + $this->assertDatabaseHas('parent_devices', [ + 'fcm_token' => 'fcm-abc-123', + ]); + } +}