# Spesifikasi Teknis — Aplikasi Presensi Kegiatan NFC/RFID

## 1. Ringkasan

Aplikasi web untuk mencatat kehadiran peserta asrama pada kegiatan (apel, kajian, rapat, dsb.) dengan memindai kartu NFC/RFID. Satu server melayani banyak perangkat: laptop dengan reader USB di pintu masuk, ponsel Android dengan NFC bawaan, dan perangkat admin untuk rekap.

Sasaran utama: satu scan selesai dalam kurang dari 1 detik, umpan balik visual dan suara yang jelas, serta tampilan yang stabil (tanpa layout shift) dari ponsel 320 px hingga desktop lebar.

## 2. Arsitektur

```
┌───────────────────────────── Peramban ─────────────────────────────┐
│  index.html  +  css/app.css  +  js/app.js (router hash)            │
│  pages/: dashboard · peserta · scan · kegiatan · rekap              │
│  vendor lokal: Lucide · SheetJS (xlsx) · jsPDF + autotable          │
│  Input: reader USB (keyboard wedge)  |  Web NFC (NDEFReader)        │
└──────────────────────────────┬──────────────────────────────────────┘
                               │ REST JSON (fetch)
┌──────────────────────────────▼──────────────────────────────────────┐
│  server.js — Node.js + Express                                      │
│  validasi · normalisasi UID · transaksi scan · rekap               │
└──────────────────────────────┬──────────────────────────────────────┘
                               │ mysql2 (connection pool) — config.js
┌──────────────────────────────▼──────────────────────────────────────┐
│  MySQL 8 / MariaDB 10.4+ (mis. XAMPP + phpMyAdmin) — presensi_nfc   │
└─────────────────────────────────────────────────────────────────────┘
```

| Lapisan | Teknologi | Alasan |
|---|---|---|
| Frontend | HTML, CSS, JavaScript (ES modules), tanpa build step | Ringan, mudah diubah, langsung jalan |
| Sistem desain | Token & komponen bergaya shadcn/ui (palet zinc), ditulis ulang dalam CSS | shadcn/ui asli adalah library React; versi CSS ini mempertahankan tampilannya tanpa React |
| Ikon | Lucide (UMD) | Set ikon resmi yang dipakai shadcn/ui |
| Excel | SheetJS `xlsx` | Baca template impor & tulis ekspor di sisi klien |
| PDF | jsPDF + jspdf-autotable | Ekspor tabel rekap |
| Backend | Node.js ≥ 20, Express 4 | Sederhana, stabil |
| Database | MySQL 8 / MariaDB 10.4+ via mysql2 (pool 10 koneksi) | Umum di hosting & XAMPP, bisa dikelola lewat phpMyAdmin, aman untuk banyak perangkat scan bersamaan |
| Konfigurasi | `config.js` (bisa ditimpa variabel lingkungan) | Host, port, user, password, nama database di satu tempat |

Semua library frontend disajikan dari `node_modules` oleh server, jadi aplikasi tetap berfungsi di lokasi tanpa internet selama perangkat terhubung ke jaringan lokal yang sama dengan server.

## 3. Arsitektur Database

### 3.1 Diagram relasi

```
asrama 1 ──── * peserta 1 ──── * presensi * ──── 1 kegiatan
                                                     │
                                  scan_log * ────────┘ (nullable)
```

### 3.2 Tabel

**asrama** — master nama asrama.

| Kolom | Tipe | Keterangan |
|---|---|---|
| id | INT UNSIGNED PK | |
| nama | VARCHAR(100) UNIQUE | collation utf8mb4_unicode_ci → "Al-Fatih" dan "al-fatih" dianggap sama |
| created_at | DATETIME | |

**peserta**

| Kolom | Tipe | Keterangan |
|---|---|---|
| id | INT UNSIGNED PK | |
| nama_lengkap | VARCHAR(150) NOT NULL | |
| nomor_kamar | VARCHAR(20) NOT NULL | teks, mendukung "A-101" |
| asrama_id | INT FK → asrama | ON DELETE RESTRICT |
| uid | VARCHAR(64) UNIQUE NOT NULL | ternormalisasi (lihat 3.3) |
| created_at | DATETIME | = Tanggal Didaftarkan |
| updated_at | DATETIME | ON UPDATE CURRENT_TIMESTAMP |

**kegiatan**

| Kolom | Tipe | Keterangan |
|---|---|---|
| id | INT UNSIGNED PK | |
| nama | VARCHAR(150) NOT NULL | |
| tanggal | DATE | |
| jam_mulai, jam_selesai | TIME | opsional |
| keterangan | TEXT | opsional |
| is_active | TINYINT(1) | 0/1 |
| active_flag | generated (1 atau NULL) + UNIQUE | menjamin maksimal satu kegiatan aktif |

**presensi**

| Kolom | Tipe | Keterangan |
|---|---|---|
| id | INT UNSIGNED PK | |
| kegiatan_id | FK → kegiatan | ON DELETE CASCADE |
| peserta_id | FK → peserta | ON DELETE CASCADE |
| waktu_scan | DATETIME | |
| metode | ENUM `usb` / `nfc` / `manual` | |
| | UNIQUE(kegiatan_id, peserta_id) | anti presensi ganda di level database |

**pengguna** — akun login: `username` (unik), `nama`, `password_hash` (scrypt + salt acak), `peran` (`admin` / `petugas`), `last_login`.

**sesi** — sesi login aktif: `token_hash` (SHA-256 dari token cookie; token asli tidak pernah disimpan), `pengguna_id`, `expires_at`.

**scan_log** — semua percobaan scan untuk audit: `berhasil`, `duplikat`, `tidak_terdaftar`, `tanpa_kegiatan`.

**v_rekap (view)** — `kegiatan CROSS JOIN peserta LEFT JOIN presensi`. Status "Belum Hadir" tidak disimpan, melainkan dihitung, sehingga peserta baru otomatis muncul sebagai belum hadir.

### 3.3 Normalisasi UID

Semua UID diubah ke huruf besar dan dibuang karakter `:`, `-`, dan spasi, baik saat pendaftaran, impor, maupun scan. Contoh: `04:a2:3f:1b` → `04A23F1B`. Format yang diterima: 4–32 karakter `0-9 A-F` (mencakup UID heksadesimal dan desimal).

Catatan penting: reader USB murah sering mengeluarkan UID dalam angka **desimal** (mis. `0012345678`), sedangkan Web NFC selalu mengeluarkan **heksadesimal** (`04:A2:...`). Kartu yang sama bisa menghasilkan dua UID berbeda. Daftarkan kartu memakai jenis reader yang sama dengan yang dipakai saat scan, atau atur reader agar mengeluarkan heksadesimal.

### 3.4 Pembuatan skema & konfigurasi

Skema ada di `database/schema.mysql.sql` dan memakai `IF NOT EXISTS`, jadi aman dijalankan berulang. Ada dua cara membuatnya: otomatis saat server start (`autoCreateSchema: true` di `config.js`, butuh izin CREATE DATABASE), atau manual lewat phpMyAdmin → buat database → Import file tersebut.

Koneksi diatur di `config.js`: `host`, `port`, `user`, `password`, `database`. Setiap nilai bisa ditimpa variabel lingkungan `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, dan `PORT`. Saat start, server menguji koneksi lebih dulu; bila gagal, server berhenti dengan pesan penyebab (MySQL mati, password salah, database/tabel belum ada).

## 4. REST API

Semua endpoint kecuali `/api/auth/login` wajib login. Endpoint **petugas** (boleh diakses peran petugas & admin): `/api/dashboard`, `/api/scan*`, `/api/auth/*`. Selebihnya khusus **admin**. Setiap permintaan selain GET wajib membawa header `X-Requested-With: fetch` (perlindungan CSRF).

| Metode | Endpoint | Fungsi |
|---|---|---|
| POST | `/api/auth/login` | `{username, password}` → set cookie sesi |
| POST | `/api/auth/logout` | hapus sesi |
| GET | `/api/auth/me` | pengguna yang sedang masuk |
| POST | `/api/auth/password` | `{lama, baru}` ubah password sendiri |
| GET/POST | `/api/pengguna` | daftar / tambah pengguna (admin) |
| PUT/DELETE | `/api/pengguna/:id` | ubah / hapus pengguna (admin) |
| GET | `/api/dashboard` | kegiatan aktif, total, hadir, belum, persen, rekap per asrama |
| GET | `/api/asrama` | daftar asrama |
| GET | `/api/peserta` | semua peserta |
| POST | `/api/peserta` | tambah (409 bila UID dipakai) |
| PUT | `/api/peserta/:id` | ubah |
| DELETE | `/api/peserta/:id` | hapus (riwayat presensi ikut terhapus) |
| POST | `/api/peserta/import` | `{rows:[{baris,nama_lengkap,nomor_kamar,asrama,uid}]}` → `{inserted, rejected[]}` |
| GET | `/api/kegiatan` | daftar + jumlah hadir |
| POST | `/api/kegiatan` | buat (`aktifkan: true` opsional) |
| PUT | `/api/kegiatan/:id` | ubah |
| POST | `/api/kegiatan/:id/aktifkan` | jadikan satu-satunya kegiatan aktif |
| POST | `/api/kegiatan/:id/nonaktifkan` | |
| DELETE | `/api/kegiatan/:id` | |
| POST | `/api/scan` | `{uid, metode}` → `status: success / duplicate / not_found / no_active` |
| GET | `/api/scan/terbaru` | 8 presensi terakhir kegiatan aktif |
| GET | `/api/rekap` | filter `tanggal, kegiatan_id, asrama_id, status, q` |

Kode galat: 401 (belum masuk), 403 (tidak punya akses), 404 (tidak ditemukan), 409 (bentrok), 422 (validasi), 429 (terlalu banyak percobaan login), 500 (server).

## 5. Alur Fitur

### 5.1 Dashboard
Empat kartu metrik berukuran tetap (Total Peserta, Hadir, Belum Hadir, Persentase), grafik batang horizontal per asrama (CSS murni, tanpa library grafik), dan tombol cepat ke layar scan. Data disegarkan otomatis tiap 15 detik.

### 5.2 Manajemen peserta
- Tabel: Nama Lengkap, Nomor Kamar, Nama Asrama, UID, Tanggal Didaftarkan; pencarian dan filter asrama; 20 baris per halaman.
- Tambah/ubah lewat dialog. Kolom UID bisa diisi dengan menempelkan kartu ke reader; Enter dari reader tidak mengirim form.
- Template Excel: sheet "Peserta" (judul kolom + 3 contoh) dan sheet "Petunjuk".
- Impor: file dibaca di peramban, judul kolom dikenali secara fleksibel, setiap baris divalidasi (kolom kosong, format UID, UID duplikat di file, UID sudah ada di database) dan ditampilkan sebagai pratinjau. Hanya baris valid yang dikirim; server memvalidasi ulang dan menyimpan dalam satu transaksi.

### 5.3 Scan presensi
1. Kolom UID otomatis fokus dan kembali fokus setelah blur, kecuali saat dialog terbuka atau pengguna mengetik di kontrol lain.
2. Reader USB (keyboard wedge): dikirim saat Enter/Tab. Bila reader tidak mengirim Enter, ketikan yang sangat cepat (rata-rata < 45 ms per karakter) dikirim otomatis setelah 180 ms diam.
3. Web NFC: tombol "Aktifkan NFC ponsel" memanggil `NDEFReader.scan()`; `serialNumber` kartu dikirim dengan metode `nfc`.
4. Kolom langsung dikosongkan begitu UID dikirim. UID yang sama dalam 2,5 detik diabaikan (pantulan baca ganda).
5. Server: cek kegiatan aktif → cari peserta → INSERT presensi. Bila INSERT ditolak UNIQUE (`ER_DUP_ENTRY`), hasilnya "sudah hadir" — aman walau dua perangkat men-scan kartu yang sama bersamaan. Setiap percobaan dicatat di `scan_log`.
6. Hasil tampil di area berukuran tetap:
   - hijau **BERHASIL** + nama, asrama, kamar, jam (1,8 detik, nada naik, getar pendek);
   - oranye **SUDAH HADIR** + jam scan pertama (2,2 detik, nada ganda);
   - merah **TIDAK TERDAFTAR** + UID terbaca (2,2 detik, nada rendah, getar panjang, animasi goyang);
   - oranye **BELUM ADA KEGIATAN** bila tidak ada kegiatan aktif;
   - merah **GAGAL TERHUBUNG** bila server tidak merespons.
7. Penghitung hadir/total dan daftar "Baru saja hadir" diperbarui; daftar juga disegarkan tiap 10 detik untuk menangkap scan dari perangkat lain.

### 5.4 Rekap absen
Filter tanggal, kegiatan, asrama, status, dan pencarian nama (diproses di server). Default menampilkan kegiatan aktif. Ekspor mengikuti filter yang sedang dipakai:
- Excel: sheet "Rekap" (judul, keterangan filter, ringkasan, tabel ber-autofilter) dan sheet "Per Asrama".
- PDF: A4 lanskap, judul, filter, ringkasan, tabel dengan status berwarna, nomor halaman.

## 6. Responsivitas

| Lebar | Navigasi | Tabel | Layar scan |
|---|---|---|---|
| ≥ 1024 px | Sidebar tetap 256 px | Penuh | Dua kolom: panel scan + riwayat |
| 768–1023 px | Top bar + drawer hamburger | Penuh; kolom sekunder disembunyikan di ≤ 1100 px | Satu kolom |
| < 768 px | Top bar + drawer + bottom navigation (tombol Scan menonjol di tengah) | Mode kartu | Layar penuh, tanpa top bar/bottom nav, tombol keluar |

Pencegahan layout shift: skeleton berukuran sama dengan konten akhir, kartu metrik dan area hasil scan berukuran tetap, keadaan idle/hasil scan bertumpuk di sel grid yang sama, `scrollbar-gutter: stable`, font sistem (tanpa unduhan font), angka `tabular-nums`, tombol mempertahankan lebar saat loading. Hasil uji Playwright: CLS ≤ 0,04 di semua halaman, tanpa scroll horizontal pada lebar 320–1366 px.

Lainnya: mode gelap mengikuti sistem dan bisa diganti manual, `safe-area-inset` untuk ponsel berponi, input 16 px di ponsel (mencegah zoom iOS), `inputmode="none"` di perangkat sentuh agar keyboard virtual tidak menutupi layar scan, `prefers-reduced-motion` dihormati.

## 7. Keamanan

- **Login & peran.** Admin (akses penuh) dan petugas scan (dashboard + layar scan). Akun admin pertama dibuat dari `adminAwal` di `config.js` hanya bila tabel pengguna kosong; server menolak start bila password-nya belum diisi.
- **Password** di-hash dengan scrypt (bawaan Node.js) + salt acak 16 byte; verifikasi memakai perbandingan waktu-konstan, dan tetap dihitung walau username tidak ada (mencegah penebakan username lewat waktu respons).
- **Sesi**: token acak 256-bit di cookie `HttpOnly`, `SameSite=Lax`, dan `Secure` saat HTTPS. Database hanya menyimpan hash SHA-256 token. Berlaku 12 jam (`sesiJam`), diperpanjang otomatis saat dipakai. Ganti password / ubah peran / reset oleh admin mengeluarkan sesi lain milik akun itu.
- **Batas percobaan login**: 5 gagal per IP+username dan 30 gagal per IP dalam 15 menit → dikunci sementara (disimpan di memori; reset saat server restart).
- **CSRF**: cookie `SameSite=Lax` + header wajib `X-Requested-With: fetch` pada setiap permintaan pengubah data.
- **Aturan data**: tidak bisa menghapus akun sendiri; minimal selalu ada satu admin.
- **Header**: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`, `Strict-Transport-Security` (saat HTTPS). `trust proxy` hanya untuk proxy di mesin yang sama (Nginx / Passenger cPanel).
- Semua data yang dirender di-escape (anti-XSS); semua query memakai parameter (anti-SQL injection).
- **Zona waktu**: setiap koneksi MySQL menjalankan `SET time_zone = config.timezone` (bawaan `+07:00`), sehingga jam scan benar walau server hosting memakai UTC.
- Web NFC hanya berjalan di Chrome Android dan mensyaratkan HTTPS — terpenuhi otomatis setelah online dengan sertifikat SSL (lihat `docs/HOSTING.md`).
- Gunakan user MySQL khusus aplikasi dengan password kuat, bukan `root`.
- `xlsx@0.18.5` dari npm memiliki advisori keamanan saat membaca file tak tepercaya. File hanya dibaca di peramban admin yang sudah login, tetapi disarankan memperbarui ke rilis resmi SheetJS: `npm i https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz`.

## 8. Pengembangan lanjutan

Batas waktu scan sesuai jam kegiatan (status Terlambat), cetak kartu peserta dengan QR cadangan, sinkronisasi realtime via Server-Sent Events, serta pencadangan database terjadwal dari dalam aplikasi.
