# AI Vision Attendance — Central (Workstream A)

Laravel 13 / PHP 8.3 application implementing API-CONTRACT.md v1 (edge sync API) plus the
web UI: dashboard, employee roster, attendance register, manual entry, corrections, daily
reports, device health, and user administration.

## Environment

- PHP 8.3.30 + Composer + MySQL run on the Windows VM (Laragon), reached via `ssh ilqms-vm`.
- This Mac folder is shared to the VM as
  `\\Mac\Home\Documents\BTH Files\Vaibhav Work 2010\Rai\BTHPL - 1476\Project Drone\VS Code by Claude\15 AI Vision\central`.
- Wrapper batch files live in `C:\av-setup\` on the VM (separate from `C:\ilqms-setup\`, which
  belongs to the unrelated iLQMS project on port 8000):
  - `C:\av-setup\art.bat` → `php artisan %*`
  - `C:\av-setup\composer.bat` → `composer %*`
  - `C:\av-setup\serve.bat` → `php artisan serve --host=0.0.0.0 --port=8001`
- App database: MySQL, database `av_attendance`, user `root`, empty password (Laragon
  default). Tests run on SQLite `:memory:` (see `phpunit.xml`, already configured by Laravel's
  default scaffold).
- Timezone: `Asia/Kolkata` everywhere (`config/app.php` `timezone`, `.env` `APP_TIMEZONE`).

## Common commands (run from the Mac, over ssh)

```bash
# Run any artisan command
ssh ilqms-vm 'C:\av-setup\art.bat migrate'
ssh ilqms-vm 'C:\av-setup\art.bat migrate:fresh --seed'
ssh ilqms-vm 'C:\av-setup\art.bat db:seed'

# Run the full test suite (SQLite :memory:)
ssh ilqms-vm 'C:\av-setup\art.bat test'

# Serve on :8001 (reachable from the Mac at http://10.211.55.7:8001)
ssh ilqms-vm 'C:\av-setup\serve.bat'
# (the ssh session must stay open / be run in the background — the PHP dev server
# is single-process and blocks in the foreground)

# Composer
ssh ilqms-vm 'C:\av-setup\composer.bat install'
```

Login at `http://10.211.55.7:8001/login` with `admin@bth.local` / `password` (seeded).

> Note: the PHP built-in dev server (`php artisan serve`) occasionally returns "Empty reply
> from server" / "Malformed HTTP request" for POST requests made **from the Mac** across the
> Parallels virtual network bridge — this is a quirk of the single-threaded dev server over
> that specific network path, not an application bug. The same POST works correctly when run
> from inside the VM (`ssh ilqms-vm 'curl ... http://127.0.0.1:8001/...'`) and all HTTP-level
> feature tests (which use Laravel's in-process test client, not a real socket) pass cleanly.
> If this matters for the edge-agent E2E handshake, prefer testing from the VM itself, or move
> to a non-dev server (`php artisan serve` replaced by nginx+php-fpm, or Laragon's own vhost)
> for anything beyond casual manual checks.

## What's built

**Data model** (`database/migrations/2026_07_07_09000*`, `app/Models/*`): `sites`, `devices`
(token_hash sha256, last_seen, config_version, camera_ok/last_queue_depth/agent_version for
device health), `employees` (emp_id unique, soft-deletes, consent_on), `face_templates`,
`attendance_events` (event_uuid unique, `corrected_by` + `corrects_event_id` self-referencing
FKs, snapshot_path), `daily_attendance` (unique emp+date), `users` (role enum + nullable
site_id scope, added via a migration that runs after `sites` exists — MySQL FK-order safe),
`audit_logs`, `app_settings` (single-row runtime config + config_version).

**Sync API** (`app/Http/Controllers/Api/V1/*`, `routes/api.php`, all behind
`app/Http/Middleware/AuthenticateDevice.php` which validates `X-Device-Token` by sha256
comparison against `devices.token_hash`):
- `POST /api/v1/punches` — batch ≤200, idempotent on `event_uuid`, unknown `emp_id` → rejected
  (rest of batch still processed), `corrects_event_uuid` sets `corrected_by` on the original
  and `corrects_event_id` on the new event, `snapshot_b64` → `storage/app/snapshots/YYYY-MM-DD/{uuid}.jpg`.
- `GET /api/v1/roster?since=` — full snapshot or delta (`updated_at >= since`), plus `deleted`
  emp_ids (soft-deleted since `since`).
- `GET /api/v1/templates?since=` / `POST /api/v1/templates` — delta sync (includes inactive
  templates in delta mode so the edge can drop them from its gallery); enrollment upload
  returns `template_id`/`version`, 422 `unknown_emp_id` / `consent_missing`.
- `POST /api/v1/heartbeat` — updates `devices.last_seen`/`camera_ok`/`last_queue_depth`,
  returns `config_version`.
- `GET /api/v1/config` — all tunables from `app_settings` (single row, `AppSetting::current()`).

All API errors use the `{"error":{"code":"...","message":"..."}}` envelope with proper HTTP
status (401/404/422/500), wired via `bootstrap/app.php` exception renderers.

**Web UI** (`app/Http/Controllers/Web/*`, `resources/views/*`, hand-rolled session auth via
`AuthController` — no npm/Vite; single CSS file at `public/css/app.css`):
- Login/logout, role-gated navigation (`app/Http/Middleware/EnsureUserHasRole.php`, alias `role:`).
- Dashboard: present count, who's-in, late list, recent punches feed; site filter for
  admin/hr, auto-scoped to own site for security.
- Employees: list/search/paginate, create/edit/soft-delete, CSV import
  (`emp_id,name,active,consent_on`, upsert by emp_id), enrollment status (template count).
- Attendance register: filter by date/site/employee; corrected rows struck through; "Correct"
  action per row.
- Manual entry: creates `attendance_events` with `source=manual`, `operator=<user name>`,
  `confidence=1.0`; security role is restricted to their own `site_id` (403 otherwise).
- Corrections: marks the original `corrected_by` and inserts a replacement event
  (`corrects_event_id` back-reference); both actions audit-logged via `AuditLog::record()`.
- Daily attendance report: date-range view that recomputes `daily_attendance` on the fly via
  `App\Services\AttendanceComputer` before rendering, plus CSV export.
- Device health: online (last_seen < 5 min) / offline, camera_ok, queue depth, agent version.
- Users: full CRUD, admin-only.

**Role gates**: `admin` — everything. `hr` — everything except `/devices` and `/users`.
`security` — only `/dashboard` (own site) and `/manual-entry` (own site; enforced both by
route middleware and a controller-level site check).

**Daily computation** (`app/Services/AttendanceComputer.php`): per employee per date, first
`IN`/last `OUT` from non-corrected events, `late` if first IN after `shift_start`, `early_out`
if last OUT before `shift_end`, `absent` if no events that date, `manual` status if any event
that day has `source=manual`. Artisan commands:
- `php artisan attendance:compute {--date=}` — recompute a single date (defaults to today).
- `php artisan attendance:purge-snapshots {--days=60}` — deletes punch snapshot files older
  than the retention window and clears `snapshot_path`.

**Seeders** (`database/seeders/DatabaseSeeder.php`): admin user (`admin@bth.local` /
`password`), 5 sites with 1 device each (plaintext tokens printed to console **and** written
to `central/DEVICE-TOKENS.txt`, dev-only), 10 sample employees `EMP0001`–`EMP0010` (first 8
have `consent_on` set, last 2 do not — for exercising the enrollment 422 paths).

## Tests

35 tests / 110 assertions, all green (`ssh ilqms-vm 'C:\av-setup\art.bat test'`), covering:
punch idempotency (resend → all duplicate, row count unchanged), unknown-emp rejection with
rest-of-batch processed, corrects-event flow, batch >200 rejected, roster/template full +
delta sync (incl. deleted employees and inactive templates), enrollment 422s
(`unknown_emp_id` / `consent_missing`), heartbeat updating `last_seen`/`config_version`,
`/config` shape, device-token 401 (missing/invalid), CSV import (create + update-in-place),
daily computation (late/early/absent/corrected-ignored), manual entry + audit log + site
scoping, corrections creating a linked replacement, and role-based page access (security
blocked from `/users` and `/employees`, hr blocked from `/devices` and `/users`, admin
unrestricted).

`migrate:fresh --seed` was run twice against the real MySQL database (`av_attendance`) and
succeeded both times with no FK-ordering errors.

## Deployment notes (Turbify shared hosting)

Shipped and QA'd — see **`README-DEPLOY-TURBIFY.md`** for the full guide. Summary of what
exists so a plain PHP/MySQL host with **no SSH, no Composer, no artisan CLI, and possibly no
cron** can run this app entirely via file upload + browser:

- **`public/check.php`** — standalone pure-PHP preflight (no Laravel bootstrap): PHP version,
  required extensions, storage/bootstrap-cache writability, .htaccess presence. Upload and
  browse to this first.
- **`/install`** — browser wizard (`App\Http\Controllers\Web\InstallController` +
  `App\Services\InstallerService`): re-checks preflight, takes DB + admin + site-count input,
  tests the DB connection, writes a fresh `.env` (generates `APP_KEY`, `APP_ENV=production`,
  `APP_DEBUG=false`), runs migrations via `Artisan::call('migrate', ['--force'=>true])`, seeds
  **only** production-essential data (admin user, N sites + 1 device each with freshly
  generated tokens, `app_settings`) — deliberately **not** the sample `EMP0001`-`EMP0010`
  employees the dev seeder creates — then shows the device tokens once (client-side `.txt`
  download, nothing written to server disk) and writes `storage/app/installed.lock`.
- **Install gating** (`App\Http\Middleware\EnsureInstalled` / `EnsureApiInstalled`, wired in
  `bootstrap/app.php`): every web route redirects to `/install` and every API route returns
  JSON `503 not_installed` until the lock file exists (or `APP_ENV=testing`, so the test suite
  is never affected). Re-visiting `/install` once installed shows an "already installed" page
  instead of silently wiping data.
- **No-cron accommodations**: dashboard/reports already compute on the fly (verified, no
  change needed). `App\Http\Controllers\Web\TaskController` exposes token-protected GET
  endpoints (`/tasks/purge-snapshots`, `/tasks/compute-attendance`) for a free external pinger
  (cron-job.org) on hosts with zero cron access; `App\Http\Controllers\Web\MaintenanceController`
  (`/maintenance`, admin-only) has a manual "purge now" button and shows the same URLs.
- Production `.env` (written by the wizard) deliberately uses `SESSION_DRIVER=file`,
  `CACHE_STORE=file`, `QUEUE_CONNECTION=sync` instead of this dev environment's `database`
  drivers — one less moving part on shared hosting (no session/cache/queue table dependency),
  safe because this app has zero queued jobs.
- `DEVICE-TOKENS.txt` (this dev seeder's output) is dev-only (plaintext tokens) — never
  shipped in the deploy ZIP; production tokens come only from the `/install` wizard's
  one-time-shown screen, hashed at rest exactly like dev tokens.

## Known deviations / ambiguities (flagged per contract §"if ambiguous, simplest reading")

1. **`devices.config_version`**: the contract lists it as a device field but doesn't say
   whether it's the device's *last-acknowledged* version or an admin-set target. Implemented
   as "last version communicated to this device at its last heartbeat" (updated every
   heartbeat to the current global `app_settings.config_version`) — purely informational for
   the device-health page; the actual value served by `/config` always comes from the single
   global `app_settings` row (v1 has no per-site config variation, matching the spec's
   "5 sites, one shared gallery" design).
2. **Batch size limit (`max: 200`)**: a batch of exactly 201 events is rejected with a single
   `422 validation_error` for the whole request (contract doesn't specify per-event vs
   whole-batch behavior for this case; whole-batch rejection was the simplest reading, distinct
   from per-event `rejected` entries which are for content-level issues like unknown emp_id).
3. **Roster/template delta `deleted` semantics**: only populated when `since` is given (a full
   snapshot returns `deleted: []`, since a fresh device has no baseline to diff against).
4. **Daily report has no site filter**: `daily_attendance` has no `site_id` column (matching
   the contract's minimum schema exactly, and the product decision that employees can punch at
   any site) — only the Attendance Register (raw `attendance_events`) is filterable by site.
5. **`corrects_event_id`**: added as an extra column beyond the contract's minimum schema
   (which only lists `corrected_by`) purely so the correction relationship is queryable in
   both directions; does not change the wire contract.
