init backend presensi
This commit is contained in:
1
app/Modules/Attendance/Controllers/.gitkeep
Normal file
1
app/Modules/Attendance/Controllers/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Attendance Module - Controllers
|
||||
89
app/Modules/Attendance/Controllers/AttendanceController.php
Normal file
89
app/Modules/Attendance/Controllers/AttendanceController.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Attendance\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Attendance\Services\AttendanceCheckinService;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Attendance Controller
|
||||
*
|
||||
* Handles attendance check-in endpoints.
|
||||
*/
|
||||
class AttendanceController extends BaseApiController
|
||||
{
|
||||
protected AttendanceCheckinService $checkinService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->checkinService = new AttendanceCheckinService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attendance check-in endpoint
|
||||
*
|
||||
* POST /api/attendance/checkin
|
||||
* Body: {
|
||||
* "device_code": "",
|
||||
* "api_key": "",
|
||||
* "student_id": 0,
|
||||
* "datetime": "Y-m-d H:i:s",
|
||||
* "lat": 0.0,
|
||||
* "lng": 0.0,
|
||||
* "confidence": 0.0 (optional)
|
||||
* }
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function checkin(): ResponseInterface
|
||||
{
|
||||
// Get JSON input
|
||||
$input = $this->request->getJSON(true);
|
||||
|
||||
// Validate required fields
|
||||
$requiredFields = ['device_code', 'api_key', 'student_id', 'datetime', 'lat', 'lng'];
|
||||
foreach ($requiredFields as $field) {
|
||||
if (!isset($input[$field])) {
|
||||
return $this->errorResponse(
|
||||
"Field '{$field}' is required",
|
||||
null,
|
||||
null,
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Process check-in
|
||||
try {
|
||||
$result = $this->checkinService->checkin($input);
|
||||
|
||||
// Determine message based on status
|
||||
$messages = [
|
||||
'PRESENT' => 'Attendance recorded successfully',
|
||||
'LATE' => 'Attendance recorded but marked as late',
|
||||
'OUTSIDE_ZONE' => 'Check-in failed: Location outside school zone',
|
||||
'NO_SCHEDULE' => 'Check-in failed: No active schedule found',
|
||||
'INVALID_DEVICE' => 'Check-in failed: Invalid device credentials',
|
||||
'ALREADY_CHECKED_IN' => 'Already checked in for this schedule today',
|
||||
'ABSENCE_WINDOW_CLOSED' => 'Check-in failed: Outside attendance window',
|
||||
'SESSION_CLOSED' => 'Attendance session closed',
|
||||
];
|
||||
|
||||
$message = $messages[$result['status']] ?? 'Attendance check-in processed';
|
||||
|
||||
// Return success response (even for failures, as the record is saved)
|
||||
return $this->successResponse(
|
||||
$result,
|
||||
$message
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->errorResponse(
|
||||
'An error occurred while processing check-in',
|
||||
['error' => $e->getMessage()],
|
||||
null,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Attendance\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Auth\Entities\Role;
|
||||
use App\Modules\Auth\Services\AuthService;
|
||||
use App\Modules\Attendance\Services\AttendanceReportService;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Attendance Report Controller
|
||||
*
|
||||
* On-the-fly schedule attendance reports (expected, present, late, absent).
|
||||
*/
|
||||
class AttendanceReportController extends BaseApiController
|
||||
{
|
||||
protected AttendanceReportService $reportService;
|
||||
protected AuthService $authService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->reportService = new AttendanceReportService();
|
||||
$this->authService = new AuthService();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/attendance/reports
|
||||
*
|
||||
* Query:
|
||||
* - from_date (YYYY-MM-DD, optional, default: today if both empty)
|
||||
* - to_date (YYYY-MM-DD, optional, default: today if both empty)
|
||||
* - class_id (int, optional)
|
||||
* - student_id (int, optional)
|
||||
* - status (PRESENT,LATE,OUTSIDE_ZONE,NO_SCHEDULE,INVALID_DEVICE; optional)
|
||||
*
|
||||
* Returns recap per hari/per kelas + daftar detail kehadiran.
|
||||
*/
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
$user = $this->authService->currentUser();
|
||||
if (! $user) {
|
||||
return $this->errorResponse('Unauthorized', null, null, ResponseInterface::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$roles = $user['roles'] ?? [];
|
||||
$roleCodes = array_column($roles, 'role_code');
|
||||
|
||||
$isAdmin = in_array(Role::CODE_ADMIN, $roleCodes, true);
|
||||
$isGuruBk = in_array(Role::CODE_GURU_BK, $roleCodes, true);
|
||||
$isWali = in_array(Role::CODE_WALI_KELAS, $roleCodes, true);
|
||||
$isGuruMap = in_array(Role::CODE_GURU_MAPEL, $roleCodes, true);
|
||||
|
||||
// ORANG_TUA pakai Portal Orang Tua, bukan endpoint ini
|
||||
if (! $isAdmin && ! $isGuruBk && ! $isWali && ! $isGuruMap) {
|
||||
return $this->errorResponse('Forbidden', null, null, ResponseInterface::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$from = $this->request->getGet('from_date');
|
||||
$to = $this->request->getGet('to_date');
|
||||
|
||||
$today = date('Y-m-d');
|
||||
if (($from === null || $from === '') && ($to === null || $to === '')) {
|
||||
$from = $today;
|
||||
$to = $today;
|
||||
}
|
||||
|
||||
if ($from !== null && $from !== '' && ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
|
||||
return $this->errorResponse('from_date must be YYYY-MM-DD', null, null, ResponseInterface::HTTP_BAD_REQUEST);
|
||||
}
|
||||
if ($to !== null && $to !== '' && ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
|
||||
return $this->errorResponse('to_date must be YYYY-MM-DD', null, null, ResponseInterface::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$classId = (int) $this->request->getGet('class_id');
|
||||
$studentId = (int) $this->request->getGet('student_id');
|
||||
$status = $this->request->getGet('status');
|
||||
|
||||
if ($status !== null && $status !== '') {
|
||||
$allowedStatus = ['PRESENT', 'LATE', 'OUTSIDE_ZONE', 'NO_SCHEDULE', 'INVALID_DEVICE'];
|
||||
if (! in_array($status, $allowedStatus, true)) {
|
||||
return $this->errorResponse('Invalid status value', null, null, ResponseInterface::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $db->table('attendance_sessions AS att')
|
||||
->select(
|
||||
'att.id, att.attendance_date, att.status, att.checkin_at, ' .
|
||||
's.id AS student_id, s.nisn, s.name AS student_name, ' .
|
||||
'c.id AS class_id, c.grade, c.major, c.name AS class_name, ' .
|
||||
'sch.id AS schedule_id, sub.id AS subject_id, sub.name AS subject_name, ' .
|
||||
'u.id AS teacher_user_id, u.name AS teacher_name'
|
||||
)
|
||||
->join('students AS s', 's.id = att.student_id', 'inner')
|
||||
->join('classes AS c', 'c.id = s.class_id', 'left')
|
||||
->join('schedules AS sch', 'sch.id = att.schedule_id', 'left')
|
||||
->join('subjects AS sub', 'sub.id = sch.subject_id', 'left')
|
||||
->join('users AS u', 'u.id = sch.teacher_user_id', 'left');
|
||||
|
||||
if ($from) {
|
||||
$builder->where('att.attendance_date >=', $from);
|
||||
}
|
||||
if ($to) {
|
||||
$builder->where('att.attendance_date <=', $to);
|
||||
}
|
||||
if ($classId > 0) {
|
||||
$builder->where('c.id', $classId);
|
||||
}
|
||||
if ($studentId > 0) {
|
||||
$builder->where('s.id', $studentId);
|
||||
}
|
||||
if ($status) {
|
||||
$builder->where('att.status', $status);
|
||||
}
|
||||
|
||||
// RBAC batasan data
|
||||
if ($isWali) {
|
||||
$builder->where('c.wali_user_id', (int) $user['id']);
|
||||
} elseif ($isGuruMap) {
|
||||
$builder->where('sch.teacher_user_id', (int) $user['id']);
|
||||
} elseif (! $isAdmin && ! $isGuruBk) {
|
||||
// Should not reach here, tetapi jaga-jaga
|
||||
return $this->errorResponse('Forbidden', null, null, ResponseInterface::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$builder->orderBy('att.attendance_date', 'DESC')
|
||||
->orderBy('c.grade', 'ASC')
|
||||
->orderBy('c.major', 'ASC')
|
||||
->orderBy('c.name', 'ASC')
|
||||
->orderBy('s.name', 'ASC');
|
||||
|
||||
$rows = $builder->get()->getResultArray();
|
||||
|
||||
// Detail records
|
||||
$records = array_map(static function (array $r): array {
|
||||
$classLabel = null;
|
||||
if ($r['grade'] !== null || $r['major'] !== null || $r['class_name'] !== null) {
|
||||
$parts = array_filter([
|
||||
trim((string) ($r['grade'] ?? '')),
|
||||
trim((string) ($r['major'] ?? '')),
|
||||
trim((string) ($r['class_name'] ?? '')),
|
||||
]);
|
||||
$classLabel = implode(' ', $parts);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $r['id'],
|
||||
'attendance_date' => $r['attendance_date'],
|
||||
'status' => $r['status'],
|
||||
'checkin_at' => $r['checkin_at'],
|
||||
'student_id' => (int) $r['student_id'],
|
||||
'student_name' => $r['student_name'],
|
||||
'nisn' => $r['nisn'],
|
||||
'class_id' => $r['class_id'] !== null ? (int) $r['class_id'] : null,
|
||||
'class_label' => $classLabel,
|
||||
'schedule_id' => $r['schedule_id'] !== null ? (int) $r['schedule_id'] : null,
|
||||
'subject_id' => $r['subject_id'] !== null ? (int) $r['subject_id'] : null,
|
||||
'subject_name' => $r['subject_name'],
|
||||
'teacher_user_id' => $r['teacher_user_id'] !== null ? (int) $r['teacher_user_id'] : null,
|
||||
'teacher_name' => $r['teacher_name'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
// Rekap per hari & kelas
|
||||
$summaryMap = [];
|
||||
foreach ($records as $rec) {
|
||||
$date = $rec['attendance_date'];
|
||||
$cid = $rec['class_id'] ?? 0;
|
||||
$label = $rec['class_label'] ?? '-';
|
||||
$key = $date . '|' . $cid;
|
||||
|
||||
if (! isset($summaryMap[$key])) {
|
||||
$summaryMap[$key] = [
|
||||
'attendance_date' => $date,
|
||||
'class_id' => $cid,
|
||||
'class_label' => $label,
|
||||
'total' => 0,
|
||||
'present' => 0,
|
||||
'late' => 0,
|
||||
'outside_zone' => 0,
|
||||
'no_schedule' => 0,
|
||||
'invalid_device' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$summaryMap[$key]['total']++;
|
||||
switch ($rec['status']) {
|
||||
case 'PRESENT':
|
||||
$summaryMap[$key]['present']++;
|
||||
break;
|
||||
case 'LATE':
|
||||
$summaryMap[$key]['late']++;
|
||||
break;
|
||||
case 'OUTSIDE_ZONE':
|
||||
$summaryMap[$key]['outside_zone']++;
|
||||
break;
|
||||
case 'NO_SCHEDULE':
|
||||
$summaryMap[$key]['no_schedule']++;
|
||||
break;
|
||||
case 'INVALID_DEVICE':
|
||||
$summaryMap[$key]['invalid_device']++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$summary = array_values($summaryMap);
|
||||
|
||||
usort($summary, static function (array $a, array $b): int {
|
||||
if ($a['attendance_date'] === $b['attendance_date']) {
|
||||
return strcmp((string) $a['class_label'], (string) $b['class_label']);
|
||||
}
|
||||
return strcmp($a['attendance_date'], $b['attendance_date']);
|
||||
});
|
||||
|
||||
$payload = [
|
||||
'filters' => [
|
||||
'from_date' => $from,
|
||||
'to_date' => $to,
|
||||
'class_id' => $classId > 0 ? $classId : null,
|
||||
'student_id' => $studentId > 0 ? $studentId : null,
|
||||
'status' => $status ?: null,
|
||||
],
|
||||
'summary' => $summary,
|
||||
'records' => $records,
|
||||
];
|
||||
|
||||
return $this->successResponse($payload, 'Attendance reports');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/attendance/report/schedule/{scheduleId}?date=YYYY-MM-DD
|
||||
*
|
||||
* @param int|string $scheduleId From route (:num)
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function scheduleReport($scheduleId): ResponseInterface
|
||||
{
|
||||
$scheduleId = (int) $scheduleId;
|
||||
$date = $this->request->getGet('date');
|
||||
|
||||
if ($date === null || $date === '') {
|
||||
return $this->errorResponse('Query parameter date (YYYY-MM-DD) is required', null, null, 400);
|
||||
}
|
||||
|
||||
$date = (string) $date;
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $this->errorResponse('Parameter date must be YYYY-MM-DD', null, null, 400);
|
||||
}
|
||||
|
||||
$userContext = $this->authService->currentUser();
|
||||
$report = $this->reportService->getScheduleAttendanceReport($scheduleId, $date, $userContext);
|
||||
|
||||
if ($report === null) {
|
||||
return $this->errorResponse('Schedule not found or access denied', null, null, 404);
|
||||
}
|
||||
|
||||
return $this->successResponse($report, 'Schedule attendance report');
|
||||
}
|
||||
}
|
||||
93
app/Modules/Attendance/Controllers/FaceLinkController.php
Normal file
93
app/Modules/Attendance/Controllers/FaceLinkController.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Attendance\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Academic\Models\StudentModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Face link API: menghubungkan ID wajah eksternal dengan siswa.
|
||||
*
|
||||
* Catatan:
|
||||
* - Engine AI / OpenCV bertugas menghasilkan face_external_id.
|
||||
* - Backend hanya menyimpan mapping face_external_id -> student_id.
|
||||
*/
|
||||
class FaceLinkController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* POST /api/attendance/face/enroll
|
||||
* Body: { student_id: int, face_external_id: string }
|
||||
*/
|
||||
public function enroll(): ResponseInterface
|
||||
{
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$studentId = (int) ($payload['student_id'] ?? 0);
|
||||
$faceId = trim((string) ($payload['face_external_id'] ?? ''));
|
||||
|
||||
if ($studentId <= 0 || $faceId === '') {
|
||||
return $this->errorResponse(
|
||||
'student_id dan face_external_id wajib diisi',
|
||||
null,
|
||||
null,
|
||||
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
$studentModel = new StudentModel();
|
||||
$student = $studentModel->find($studentId);
|
||||
if (! $student) {
|
||||
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Pastikan face_external_id belum dipakai siswa lain
|
||||
$existing = $studentModel
|
||||
->where('face_external_id', $faceId)
|
||||
->where('id !=', $studentId)
|
||||
->first();
|
||||
if ($existing) {
|
||||
return $this->errorResponse('face_external_id sudah terpakai siswa lain', null, null, ResponseInterface::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
$studentModel->skipValidation(true);
|
||||
$studentModel->update($studentId, ['face_external_id' => $faceId]);
|
||||
|
||||
return $this->successResponse([
|
||||
'student_id' => $studentId,
|
||||
'face_external_id' => $faceId,
|
||||
], 'Face ID berhasil dihubungkan dengan siswa');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/attendance/face/resolve
|
||||
* Body: { face_external_id: string }
|
||||
* Return: { student_id, name, class_id } atau 404.
|
||||
*/
|
||||
public function resolve(): ResponseInterface
|
||||
{
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$faceId = trim((string) ($payload['face_external_id'] ?? ''));
|
||||
|
||||
if ($faceId === '') {
|
||||
return $this->errorResponse(
|
||||
'face_external_id wajib diisi',
|
||||
null,
|
||||
null,
|
||||
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
$studentModel = new StudentModel();
|
||||
$student = $studentModel->where('face_external_id', $faceId)->first();
|
||||
if (! $student) {
|
||||
return $this->errorResponse('Siswa untuk face_external_id ini tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->successResponse([
|
||||
'student_id' => (int) $student->id,
|
||||
'name' => (string) $student->name,
|
||||
'class_id' => $student->class_id !== null ? (int) $student->class_id : null,
|
||||
], 'Face ID berhasil dikenali');
|
||||
}
|
||||
}
|
||||
|
||||
105
app/Modules/Attendance/Controllers/FaceVerifyController.php
Normal file
105
app/Modules/Attendance/Controllers/FaceVerifyController.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Attendance\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Face\Models\StudentFaceModel;
|
||||
use App\Modules\Face\Services\FaceService;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* FaceVerifyController
|
||||
*
|
||||
* POST /api/attendance/verify-face
|
||||
* Body: { "student_id": 123, "image": "data:image/jpeg;base64,..." }
|
||||
*
|
||||
* Strategi: Option 1 — bandingkan probe hanya dengan embedding milik student_id kandidat.
|
||||
*/
|
||||
class FaceVerifyController extends BaseApiController
|
||||
{
|
||||
public function verify(): ResponseInterface
|
||||
{
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$studentId = (int) ($payload['student_id'] ?? 0);
|
||||
$imageData = (string) ($payload['image'] ?? '');
|
||||
|
||||
if ($studentId < 1) {
|
||||
return $this->errorResponse('student_id wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
if ($imageData === '') {
|
||||
return $this->errorResponse('image (base64) wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'probe_');
|
||||
|
||||
try {
|
||||
$raw = $this->decodeBase64Image($imageData);
|
||||
if ($raw === null) {
|
||||
throw new \RuntimeException('Format gambar tidak valid (harus base64)');
|
||||
}
|
||||
file_put_contents($tmpFile, $raw);
|
||||
|
||||
$faceService = new FaceService();
|
||||
$probe = $faceService->extractEmbeddingWithQuality($tmpFile)['embedding'];
|
||||
|
||||
$faceModel = new StudentFaceModel();
|
||||
$rows = $faceModel->where('student_id', $studentId)->findAll();
|
||||
if (empty($rows)) {
|
||||
return $this->errorResponse('Belum ada embedding wajah untuk siswa ini', null, null, ResponseInterface::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$bestSim = -1.0;
|
||||
$bestSource = null;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$embedding = json_decode($row['embedding'] ?? '[]', true);
|
||||
if (! is_array($embedding) || $embedding === []) {
|
||||
continue;
|
||||
}
|
||||
$sim = $faceService->cosineSimilarity($probe, array_map('floatval', $embedding));
|
||||
if ($sim > $bestSim) {
|
||||
$bestSim = $sim;
|
||||
$bestSource = $row['source'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestSim < 0) {
|
||||
return $this->errorResponse('Tidak ada embedding valid untuk siswa ini', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$threshold = $faceService->getDefaultThreshold();
|
||||
$status = $bestSim >= $threshold ? 'match' : 'no_match';
|
||||
|
||||
$data = [
|
||||
'student_id' => $studentId,
|
||||
'similarity' => $bestSim,
|
||||
'threshold' => $threshold,
|
||||
'matched_source'=> $bestSource,
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
return $this->successResponse($data, 'Face verification processed');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->errorResponse('Gagal verifikasi wajah: ' . $e->getMessage(), null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
||||
} finally {
|
||||
if (is_file($tmpFile)) {
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function decodeBase64Image(string $input): ?string
|
||||
{
|
||||
$input = trim($input);
|
||||
if ($input === '') {
|
||||
return null;
|
||||
}
|
||||
if (strpos($input, 'base64,') !== false) {
|
||||
$parts = explode('base64,', $input, 2);
|
||||
$input = $parts[1];
|
||||
}
|
||||
$data = base64_decode($input, true);
|
||||
return $data === false ? null : $data;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user