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')
Akun wali yang mendaftar lewat aplikasi mobile & belum terverifikasi otomatis
+Tidak ada antrean 🎉
+Semua permintaan verifikasi sudah diproses.
+{{ $link->user->name }}
++ NISN {{ $link->student->nisn }} · {{ $link->student->full_name }} · {{ $link->student->class_name }} +
+Daftar: {{ $link->created_at?->diffForHumans() }}
+| Wali | +Siswa | +Status | +Diproses | +
|---|---|---|---|
| {{ $link->user->name }} | +{{ $link->student->full_name }} ({{ $link->student->class_name }}) | ++ @if($link->status === 'active') + Aktif + @else + Ditolak + @endif + | +{{ $link->verified_at?->diffForHumans() }} | +