# ZKTeco ADMS — Agent Guide

## What this project is

A Laravel 13 + Filament 4 application deployed at **https://adms.elmntointernet.com** that:
1. Receives real-time biometric attendance logs from a ZKTeco cloud API via webhooks
2. Stores them in a DigitalOcean managed MySQL database
3. Provides an admin panel at `/admin` for device/user management

---

## Stack

- **Framework**: Laravel 13, PHP 8.4
- **Admin UI**: Filament 4 (panel at `/admin`)
- **Database**: MySQL 8.4 on DigitalOcean (`private-db-mysql-sgp1-98316-do-user-26530715-0.d.db.ondigitalocean.com:25060`)
- **Queue**: Database-backed queue worker (Supervisor in production)
- **Package**: `syofyanzuhad/filament-zkteco-adms ^2.0`

---

## Key files

| File | Purpose |
|------|---------|
| `app/Models/AttendanceLog.php` | Extends package model — cloud sync scopes (`pending()`, `failed()`, `success()`, `today()`, `dateRange()`, `dueForRetry()`) and helpers (`markSynced()`, `markFailed()`, `incrementRetry()`) |
| `app/Http/Controllers/Api/BiometricLogWebhookController.php` | Receives webhook from cloud: verifies HMAC-SHA256 signature, validates, creates `AttendanceLog`, dispatches job |
| `app/Jobs/ProcessBiometricLogSync.php` | Async job: validates device/PIN, marks log synced or failed with retry |
| `app/Services/BiometricLogSyncService.php` | Business logic: `pullLogsFromCloud()`, `pushLogsToCloud()`, `cleanupOldLogs()`, `getStatistics()` |
| `app/Console/Commands/SyncBiometricLogs.php` | `php artisan biometric:sync` with `--pull`, `--push`, `--retry`, `--cleanup`, `--all` |
| `config/biometric-cloud.php` | All biometric config values (reads from `.env`) |
| `routes/api.php` | Webhook routes — registered via `bootstrap/app.php` `api:` key |
| `bootstrap/app.php` | **Must include** `api: __DIR__.'/../routes/api.php'` in `withRouting()` — without this, all API routes return 404 |
| `database/migrations/2026_06_09_120000_add_cloud_sync_to_attendance_logs.php` | Adds `cloud_log_id`, `cloud_sync_time`, `sync_status`, `retry_count`, `error_message` to `zkteco_attendance_logs` |

---

## Database schema

### `zkteco_attendance_logs` (primary table)

| Column | Type | Description |
|--------|------|-------------|
| `id` | bigint | Primary key |
| `device_id` | bigint FK | References `zkteco_devices.id` |
| `pin` | varchar | Employee PIN (used for user lookup, not user_id) |
| `punched_at` | timestamp | Attendance punch time |
| `status` | tinyint | 0=Check In, 1=Check Out, 2=Break Out, 3=Break In, 4=OT In, 5=OT Out |
| `verify_type` | tinyint | 0=Password, 1=Fingerprint, 2=Card, 15=Face |
| `work_code` | tinyint, nullable | Optional work code |
| `raw_data` | json, nullable | Full raw payload from cloud |
| `cloud_log_id` | varchar, unique, nullable | Cloud API idempotency key |
| `cloud_sync_time` | timestamp, nullable | When synced from cloud |
| `sync_status` | enum | `pending`, `success`, `failed` |
| `retry_count` | int | Processing attempts |
| `error_message` | text, nullable | Last error if failed |

### `zkteco_devices`

| Column | Type | Description |
|--------|------|-------------|
| `id` | bigint | Primary key |
| `serial_number` | varchar, unique | Device serial |
| `name` | varchar, nullable | Friendly name |
| `ip_address` | varchar, nullable | Device IP |
| `status` | enum | `online`, `offline`, `unknown` |
| `last_activity_at` | timestamp, nullable | Last seen |

### `zkteco_users`

| Column | Type | Description |
|--------|------|-------------|
| `id` | bigint | Primary key |
| `pin` | varchar, unique | Employee PIN (joins to attendance_logs.pin) |
| `name` | varchar, nullable | Employee name |
| `card_number` | varchar, nullable | RFID card |
| `is_enabled` | boolean | Active flag |

---

## Webhook endpoint

```
POST https://adms.elmntointernet.com/api/webhooks/biometric-logs
GET  https://adms.elmntointernet.com/api/webhooks/biometric-logs/health
POST https://adms.elmntointernet.com/api/webhooks/biometric-logs/retry  (auth required)
```

### Payload format
```json
{
  "log_id": "cloud_uuid_12345",
  "device_id": 1,
  "pin": "111111",
  "punched_at": "2026-06-09 14:30:00",
  "status": 0,
  "verify_type": 1,
  "work_code": null
}
```

### Required headers
```
X-Webhook-Signature: <HMAC-SHA256 of raw JSON body with BIOMETRIC_WEBHOOK_SECRET>
X-Webhook-Timestamp: <ISO-8601, must be within 10 minutes of server time>
```

---

## Required `.env` variables (not set by default — must be added)

```env
BIOMETRIC_SYNC_ENABLED=true
BIOMETRIC_API_ENDPOINT=https://your-cloud-api.com
BIOMETRIC_CLOUD_API_KEY=your_api_key_here
BIOMETRIC_WEBHOOK_SECRET=your_shared_secret_here
```

---

## Known gotchas

1. **`bootstrap/app.php` must register API routes** — `api: __DIR__.'/../routes/api.php'` in `withRouting()`. Without it, all `/api/*` routes return 404. This was a bug that has been fixed.
2. **`routes/api.php` must NOT have `.prefix('api')`** — Laravel auto-adds the `/api` prefix; a manual prefix causes `/api/api/` double prefix. Already fixed.
3. **Employee lookup uses `pin`, not `user_id`** — joins between `zkteco_attendance_logs.pin` and `zkteco_users.pin` are the canonical way to get employee names.
4. **Queue worker is required** — webhook controller dispatches a background job immediately. Without a running worker, logs stay at `sync_status=pending` forever.
5. **SSL required for DigitalOcean DB** — `MYSQL_ATTR_SSL_CA=/etc/ssl/certs/ca-certificates.crt` must be set when connecting from outside DO.

---

## Building a local client app (direct DB sync)

The recommended approach for a separate local Laravel app to consume this data is **direct read access to the same DigitalOcean MySQL database**.

### Connection config for the local app's `.env`

```env
DB_CONNECTION=mysql
DB_HOST=private-db-mysql-sgp1-98316-do-user-26530715-0.d.db.ondigitalocean.com
DB_PORT=25060
DB_DATABASE=adms
DB_USERNAME=<read-only user>
DB_PASSWORD=<password>
MYSQL_ATTR_SSL_CA=/etc/ssl/certs/ca-certificates.crt
```

> Create a read-only MySQL user on DigitalOcean so the local app cannot accidentally modify ADMS data:
> ```sql
> CREATE USER 'adms_reader'@'%' IDENTIFIED BY 'strong_password';
> GRANT SELECT ON adms.* TO 'adms_reader'@'%';
> FLUSH PRIVILEGES;
> ```

### Tables to read from

| Table | Use |
|-------|-----|
| `zkteco_attendance_logs` | All attendance punches — filter by `sync_status = 'success'` for confirmed records |
| `zkteco_users` | Employee lookup by `pin` |
| `zkteco_devices` | Device info |

### Recommended Eloquent models for the local app

```php
// app/Models/AttendanceLog.php
class AttendanceLog extends Model
{
    protected $table = 'zkteco_attendance_logs';
    protected $casts = ['punched_at' => 'datetime', 'raw_data' => 'array'];

    // Status labels
    const STATUSES = [
        0 => 'Check In', 1 => 'Check Out',
        2 => 'Break Out', 3 => 'Break In',
        4 => 'OT In', 5 => 'OT Out',
    ];

    public function device() { return $this->belongsTo(Device::class, 'device_id'); }
    public function employee() { return $this->belongsTo(ZktecoUser::class, 'pin', 'pin'); }

    public function scopeSuccess($q) { return $q->where('sync_status', 'success'); }
    public function scopeToday($q) { return $q->whereDate('punched_at', today()); }
    public function scopeForEmployee($q, $pin) { return $q->where('pin', $pin); }
}

// app/Models/ZktecoUser.php
class ZktecoUser extends Model
{
    protected $table = 'zkteco_users';
    public function logs() { return $this->hasMany(AttendanceLog::class, 'pin', 'pin'); }
}

// app/Models/Device.php
class Device extends Model
{
    protected $table = 'zkteco_devices';
}
```

### Useful queries

```php
// Today's check-ins for all employees
AttendanceLog::with('employee')
    ->success()->today()->where('status', 0)
    ->orderBy('punched_at')
    ->get();

// Attendance summary for payroll (date range)
AttendanceLog::with('employee')
    ->success()
    ->whereBetween('punched_at', ['2026-06-01', '2026-06-30'])
    ->get()
    ->groupBy('pin');

// First check-in and last check-out per employee per day
AttendanceLog::success()
    ->selectRaw('pin, DATE(punched_at) as date, MIN(punched_at) as first_in, MAX(punched_at) as last_out')
    ->groupBy('pin', DB::raw('DATE(punched_at)'))
    ->orderBy('date')
    ->get();

// Employees currently inside (last punch was a check-in)
AttendanceLog::success()
    ->selectRaw('pin, MAX(punched_at) as last_punch, status')
    ->groupBy('pin')
    ->having('status', 0)
    ->get();
```

### Ubuntu + Apache setup for the local app (summary)

1. `composer create-project laravel/laravel local-attendance`
2. Set `.env` DB credentials to DigitalOcean (read-only user)
3. Copy models above into `app/Models/`
4. Build routes/views/Filament resources as needed
5. Apache virtual host pointing to `local-attendance/public`

Full Apache + Ubuntu setup steps are in `README.md`.

---

## CLI reference

```bash
php artisan biometric:sync              # Show statistics
php artisan biometric:sync --pull       # Pull from cloud API
php artisan biometric:sync --retry      # Retry failed logs
php artisan biometric:sync --cleanup    # Remove old logs
php artisan queue:work                  # Start queue worker
php artisan route:list | grep webhook   # Verify routes
```
