feat: API wali murid (Sanctum) + verifikasi admin
- Pasang laravel/sanctum, routes/api.php (prefix api/v1, 10 endpoint) - Migrasi: parent_name di students, parent_students (pivot wali↔anak), parent_devices (token FCM), permission parents-verify - Auth wali: registrasi NISN+HP (auto-verify jika HP cocok data siswa, pending jika tidak) + login token Sanctum - Endpoint: me, logout, students, link anak, violations, sp-letters, notifications, register device - Halaman admin Verifikasi Wali (approve/reject) + menu sidebar - Sync kesiswaan: tarik data orang tua (student_parents) ke students - 8 feature test baru (ParentApiTest) — total suite 39 test hijau
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\ParentStudent;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class ParentVerificationController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$pending = ParentStudent::query()
|
||||||
|
->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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\ParentStudent;
|
||||||
|
use App\Models\Student;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\PhoneHelper;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ParentAuthController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Registrasi wali murid.
|
||||||
|
*
|
||||||
|
* Wali memasukkan NISN anak + nomor HP sendiri. Jika nomor HP cocok dengan
|
||||||
|
* data orang tua siswa → langsung aktif. Jika tidak/belum ada data →
|
||||||
|
* status pending menunggu verifikasi admin.
|
||||||
|
*/
|
||||||
|
public function register(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\ParentDevice;
|
||||||
|
use App\Models\ParentStudent;
|
||||||
|
use App\Models\ViolationNotification;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ParentNotificationController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Notifikasi untuk anak-anak wali (SP baru, dll).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\ParentStudent;
|
||||||
|
use App\Models\SpLetter;
|
||||||
|
use App\Models\Student;
|
||||||
|
use App\Models\Violation;
|
||||||
|
use App\Support\PhoneHelper;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ParentStudentController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Daftar anak milik wali (termasuk yang statusnya pending verifikasi).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = app(ParentAuthController::class)->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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ParentDevice extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id', 'platform', 'fcm_token', 'device_name', 'last_active_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'last_active_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ParentStudent extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'parent_students';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id', 'student_id', 'relation', 'status', 'verified_at', 'verified_by', 'rejection_reason',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'verified_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function student()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Student::class, 'student_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ class Student extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'source_id', 'nisn', 'student_number', 'full_name',
|
'source_id', 'nisn', 'student_number', 'full_name',
|
||||||
'gender', 'place_of_birth', 'date_of_birth', 'address',
|
'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',
|
'department_code', 'department_name', 'academic_year_name',
|
||||||
'status', 'photo_path', 'class_id', 'is_active',
|
'status', 'photo_path', 'class_id', 'is_active',
|
||||||
'metadata', 'synced_at',
|
'metadata', 'synced_at',
|
||||||
@@ -41,6 +41,11 @@ class Student extends Model
|
|||||||
return $this->belongsTo(Classes::class, 'class_id');
|
return $this->belongsTo(Classes::class, 'class_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function parentLinks()
|
||||||
|
{
|
||||||
|
return $this->hasMany(\App\Models\ParentStudent::class, 'student_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function getTotalPointsAttribute(): int
|
public function getTotalPointsAttribute(): int
|
||||||
{
|
{
|
||||||
return $this->violations()
|
return $this->violations()
|
||||||
|
|||||||
+23
-2
@@ -5,12 +5,13 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
class User extends Authenticatable
|
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 = [
|
protected $fillable = [
|
||||||
'name', 'username', 'email', 'phone', 'password', 'role', 'roles', 'is_active', 'active_session_token',
|
'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 isKepalaSekolah(): bool { return $this->hasRole('kepala_sekolah'); }
|
||||||
public function isWakaKesiswaan(): bool { return $this->hasRole('waka_kesiswaan'); }
|
public function isWakaKesiswaan(): bool { return $this->hasRole('waka_kesiswaan'); }
|
||||||
public function isKetuaTim(): bool { return $this->hasRole('ketua_tim'); }
|
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).
|
* Role dengan visibilitas data GLOBAL (semua kelas/siswa).
|
||||||
@@ -136,6 +138,25 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(Violation::class, 'recorded_by');
|
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()
|
public function appNotifications()
|
||||||
{
|
{
|
||||||
return $this->hasMany(AppNotification::class, 'user_id');
|
return $this->hasMany(AppNotification::class, 'user_id');
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ class StudentSyncService
|
|||||||
continue;
|
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
|
// Use NISN as primary key, fallback to student_number if NISN is null
|
||||||
$nisn = $item['nisn'] ?? null;
|
$nisn = $item['nisn'] ?? null;
|
||||||
if (!$nisn) {
|
if (!$nisn) {
|
||||||
@@ -89,6 +96,8 @@ class StudentSyncService
|
|||||||
'date_of_birth' => $item['date_of_birth'] ?: null,
|
'date_of_birth' => $item['date_of_birth'] ?: null,
|
||||||
'address' => $item['address'],
|
'address' => $item['address'],
|
||||||
'phone_number' => $item['phone_number'],
|
'phone_number' => $item['phone_number'],
|
||||||
|
'parent_name' => $primaryParent ? trim((string) $primaryParent['name']) : null,
|
||||||
|
'parent_phone' => $primaryParent ? ($primaryParent['phone_number'] ?: null) : null,
|
||||||
'email' => $item['email'],
|
'email' => $item['email'],
|
||||||
'class_name' => $className,
|
'class_name' => $className,
|
||||||
'class_level' => $classData['level'] ?? $item['class_level'] ?? 'X',
|
'class_level' => $classData['level'] ?? $item['class_level'] ?? 'X',
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper normalisasi nomor HP Indonesia.
|
||||||
|
*/
|
||||||
|
class PhoneHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Normalisasi: buang semua non-digit, konversi 62 → 0.
|
||||||
|
* Contoh: "+62 812-3456-7890" → "081234567890"
|
||||||
|
*/
|
||||||
|
public static function normalize(?string $phone): string
|
||||||
|
{
|
||||||
|
if ($phone === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$digits = preg_replace('/\D+/', '', (string) $phone);
|
||||||
|
if (str_starts_with($digits, '62')) {
|
||||||
|
$digits = '0' . substr($digits, 2);
|
||||||
|
}
|
||||||
|
return $digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apakah dua nomor HP merujuk ke nomor yang sama (toleran terhadap 0/62 & format)?
|
||||||
|
*/
|
||||||
|
public static function matches(?string $a, ?string $b): bool
|
||||||
|
{
|
||||||
|
$na = self::normalize($a);
|
||||||
|
$nb = self::normalize($b);
|
||||||
|
|
||||||
|
if ($na === '' || $nb === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $na === $nb;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
|
apiPrefix: 'api/v1',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"barryvdh/laravel-dompdf": "^3.1",
|
"barryvdh/laravel-dompdf": "^3.1",
|
||||||
"laravel/framework": "^13.8",
|
"laravel/framework": "^13.8",
|
||||||
"laravel/reverb": "^1.10",
|
"laravel/reverb": "^1.10",
|
||||||
|
"laravel/sanctum": "^4.3",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"phpoffice/phpspreadsheet": "^5.9"
|
"phpoffice/phpspreadsheet": "^5.9"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+64
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "afdc1f6e4dfd50c7b92edd5a03dbc47e",
|
"content-hash": "6e14b812ea81c92915b57125d300c527",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "barryvdh/laravel-dompdf",
|
"name": "barryvdh/laravel-dompdf",
|
||||||
@@ -1907,6 +1907,69 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-05-10T15:47:52+00:00"
|
"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",
|
"name": "laravel/serializable-closure",
|
||||||
"version": "v2.0.13",
|
"version": "v2.0.13",
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||||
|
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Stateful Domains
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Requests from the following domains / hosts will receive stateful API
|
||||||
|
| authentication cookies. Typically, these should include your local
|
||||||
|
| and production domains which access your API via a frontend SPA.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stateful' => 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,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('students', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('students', 'parent_name')) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('parent_students', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('parent_devices', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
$id = DB::table('permissions')->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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -197,6 +197,9 @@
|
|||||||
@canPermission('view-point-audit')
|
@canPermission('view-point-audit')
|
||||||
<x-nav-item href="{{ route('point-audit.index') }}" icon="clock-rotate-left" :active="request()->routeIs('point-audit.*')">Riwayat Poin</x-nav-item>
|
<x-nav-item href="{{ route('point-audit.index') }}" icon="clock-rotate-left" :active="request()->routeIs('point-audit.*')">Riwayat Poin</x-nav-item>
|
||||||
@endcanPermission
|
@endcanPermission
|
||||||
|
@canPermission('parents-verify')
|
||||||
|
<x-nav-item href="{{ route('parents.verification') }}" icon="user-check" :active="request()->routeIs('parents.verification')">Verifikasi Wali</x-nav-item>
|
||||||
|
@endcanPermission
|
||||||
@canPermission('sync-data')
|
@canPermission('sync-data')
|
||||||
<x-nav-item href="{{ route('settings.sync') }}" icon="refresh" :active="request()->routeIs('settings.sync*')">Sinkronisasi</x-nav-item>
|
<x-nav-item href="{{ route('settings.sync') }}" icon="refresh" :active="request()->routeIs('settings.sync*')">Sinkronisasi</x-nav-item>
|
||||||
@endcanPermission
|
@endcanPermission
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Verifikasi Wali Murid')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
{{-- Header --}}
|
||||||
|
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-600 via-teal-700 to-slate-900 shadow-lg">
|
||||||
|
<div class="absolute inset-0 opacity-10" style="background-image: radial-gradient(circle at 20% 30%, white 1.5px, transparent 1.5px); background-size: 22px 22px;"></div>
|
||||||
|
<div class="absolute -right-16 -top-16 w-64 h-64 rounded-full bg-white/10 blur-2xl"></div>
|
||||||
|
<div class="relative px-6 py-6 lg:px-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="hidden sm:flex items-center justify-center w-14 h-14 rounded-2xl bg-white/15 backdrop-blur border border-white/20">
|
||||||
|
<i class="fa-solid fa-user-check text-xl text-white"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white tracking-tight">Verifikasi Akun Wali Murid</h1>
|
||||||
|
<p class="text-sm text-slate-300 mt-1">Akun wali yang mendaftar lewat aplikasi mobile & belum terverifikasi otomatis</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-white/15 backdrop-blur border border-white/20 text-white text-sm font-semibold">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
|
||||||
|
{{ $pending->count() }} menunggu
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="rounded-xl bg-emerald-50 border border-emerald-200 text-emerald-800 px-4 py-3 text-sm flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-circle-check text-emerald-500"></i> {{ session('success') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="rounded-xl bg-red-50 border border-red-200 text-red-800 px-4 py-3 text-sm flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-circle-xmark text-red-500"></i> {{ session('error') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Antrean pending --}}
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<h2 class="font-semibold text-gray-900">Antrean Verifikasi</h2>
|
||||||
|
<span class="text-xs font-medium text-slate-500">Wali daftar via app → cocokkan data → verifikasi di sini</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($pending->isEmpty())
|
||||||
|
<div class="px-6 py-14 text-center">
|
||||||
|
<div class="w-16 h-16 mx-auto rounded-2xl bg-emerald-50 flex items-center justify-center mb-3">
|
||||||
|
<i class="fa-solid fa-check-double text-2xl text-emerald-500"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-700 font-medium">Tidak ada antrean 🎉</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">Semua permintaan verifikasi sudah diproses.</p>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="divide-y divide-slate-100">
|
||||||
|
@foreach($pending as $link)
|
||||||
|
<div class="px-5 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
|
||||||
|
<div class="flex items-center gap-4 flex-1 min-w-0">
|
||||||
|
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-slate-600 to-slate-800 text-white flex items-center justify-center font-bold text-sm shrink-0">
|
||||||
|
{{ strtoupper(substr($link->user->name, 0, 1)) }}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="font-semibold text-gray-900 truncate">{{ $link->user->name }}</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">
|
||||||
|
NISN <span class="font-mono">{{ $link->student->nisn }}</span> · {{ $link->student->full_name }} · {{ $link->student->class_name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">Daftar: {{ $link->created_at?->diffForHumans() }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<form method="POST" action="{{ route('parents.verification.approve', $link->id) }}" onsubmit="return confirm('Setujui akun wali ini?')">
|
||||||
|
@csrf
|
||||||
|
<button class="px-4 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold transition">
|
||||||
|
<i class="fa-solid fa-check mr-1"></i> Setujui
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{ route('parents.verification.reject', $link->id) }}" onsubmit="return confirm('Tolak akun wali ini?')">
|
||||||
|
@csrf
|
||||||
|
<button class="px-4 py-2 rounded-xl bg-white border border-red-200 text-red-600 hover:bg-red-50 text-sm font-semibold transition">
|
||||||
|
<i class="fa-solid fa-xmark mr-1"></i> Tolak
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Riwayat terakhir --}}
|
||||||
|
@if($recent->isNotEmpty())
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<div class="px-5 py-4 border-b border-slate-100">
|
||||||
|
<h2 class="font-semibold text-gray-900">Terakhir Diproses</h2>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-xs text-gray-400 uppercase tracking-wider border-b border-slate-100">
|
||||||
|
<th class="px-5 py-3">Wali</th>
|
||||||
|
<th class="px-5 py-3">Siswa</th>
|
||||||
|
<th class="px-5 py-3">Status</th>
|
||||||
|
<th class="px-5 py-3">Diproses</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50">
|
||||||
|
@foreach($recent as $link)
|
||||||
|
<tr>
|
||||||
|
<td class="px-5 py-3 font-medium text-gray-900">{{ $link->user->name }}</td>
|
||||||
|
<td class="px-5 py-3 text-gray-600">{{ $link->student->full_name }} <span class="text-gray-400">({{ $link->student->class_name }})</span></td>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
@if($link->status === 'active')
|
||||||
|
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-emerald-50 text-emerald-700 text-xs font-semibold"><i class="fa-solid fa-check"></i> Aktif</span>
|
||||||
|
@else
|
||||||
|
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-red-50 text-red-700 text-xs font-semibold"><i class="fa-solid fa-xmark"></i> Ditolak</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3 text-gray-500">{{ $link->verified_at?->diffForHumans() }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\ParentAuthController;
|
||||||
|
use App\Http\Controllers\Api\ParentNotificationController;
|
||||||
|
use App\Http\Controllers\Api\ParentStudentController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| API Routes — Aplikasi Wali Murid (Mobile iOS & Android)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Base URL: https://tatib.smkn1-wonorejo.sch.id/api/v1
|
||||||
|
| Auth: Laravel Sanctum (Bearer token)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== Publik: registrasi & login wali murid =====
|
||||||
|
Route::post('/parent/register', [ParentAuthController::class, 'register'])
|
||||||
|
->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');
|
||||||
|
});
|
||||||
@@ -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::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) =====
|
// ===== Administrasi (permission-based) =====
|
||||||
Route::middleware('permission:settings-manage')->group(function () {
|
Route::middleware('permission:settings-manage')->group(function () {
|
||||||
Route::get('/settings', [SettingsController::class, 'index'])->name('settings.index');
|
Route::get('/settings', [SettingsController::class, 'index'])->name('settings.index');
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\ParentStudent;
|
||||||
|
use App\Models\Student;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Violation;
|
||||||
|
use App\Models\ViolationType;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ParentApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function makeStudent(array $overrides = []): Student
|
||||||
|
{
|
||||||
|
return Student::create(array_merge([
|
||||||
|
'nisn' => '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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user