diff --git a/docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md b/docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md new file mode 100644 index 00000000..b329c54f --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md @@ -0,0 +1,300 @@ +# NFC Clock Kiosk — Design + +**Date:** 2026-05-13 +**Module:** `fusion_clock` +**Status:** Approved design — pending implementation plan +**Pilot scope:** 1 station per company + +## Problem + +`fusion_clock` already supports shared-device clock-in/out via a PIN kiosk at `/fusion_clock/kiosk`. Shop-floor employees find name search + PIN entry slow, and shared PINs make buddy-punching trivial. The company is rolling out Ubiquiti UniFi Access NFC readers for door entry, so every employee already carries an NFC card. We want a "tap-and-go" kiosk that: + +- Takes ~2 seconds (vs ~10 seconds for name search + PIN) +- Reuses the same physical Ubiquiti-issued card the employee uses for doors +- Works with gloves, dirty hands, or wet hands (touchscreens fail here) +- Captures a silent photo at every tap so managers can spot-check buddy-punching attempts + +## Goals + +1. **Tap-to-clock**: NFC card tap on a wall-mounted Android tablet → attendance state toggles in Odoo within ~1 second of the tap +2. **Single-credential**: same card the employee uses for door access also clocks them in +3. **Silent photo verification**: front camera snaps a frame on every tap; manager dashboard shows photos for spot-check +4. **Self-contained kiosk**: lockable into a single-purpose device, no escape, auto-restart on crash, no Odoo navbar visible +5. **Reuses existing fusion_clock backend**: geofencing, penalty rules, activity log, attendance lifecycle — all unchanged +6. **One-time setup**: enroll once, then employees never touch a setup flow again + +## Non-goals + +- Multi-station / multi-zone clocking (future — pilot is 1 station per company) +- Per-station geolocation (one location per company; tablet is implicitly at the company location) +- Offline mode (v1 fails loudly on network loss; offline replay is future work) +- Phone-as-credential support (NFC HCE on Android is fragile; iPhone NFC is closed) +- QR code alternate credential (deferred to v1.1 if iPhone-only employees push back) +- Native Android kiosk app (overkill for a 1-2 station pilot; Web NFC is sufficient) + +## Architecture decision + +**Option B: Separate kiosk page, shared backend.** + +A new route `/fusion_clock/kiosk/nfc` and a new lean template optimized for tap-and-go. The new controller (`controllers/clock_nfc_kiosk.py`) calls into the existing `FusionClockAPI` helpers (`_verify_location`, `_attendance_action_change`, `_log_activity`, `_check_and_create_penalty`, `_apply_break_deduction`) so all geofencing/penalty/activity logic is shared with the PIN kiosk. The existing `/fusion_clock/kiosk` route is untouched. + +**Why not extend the existing kiosk (Option A):** existing PIN kiosk page would get tap-mode JS interleaved with PIN-mode JS, increasing the regression surface for both modes. + +**Why not native Android app (Option C):** maintaining a Kotlin app + Play Console signing/distribution doubles the dev effort for marginal UX gain. Web NFC + Chrome kiosk is production-proven (gyms, warehouses, healthcare check-in). + +## Hardware decision + +**Per company:** 1× Samsung Galaxy Tab Active 5 Pro (10.1") on an official Samsung Pogo charging dock, wall-mounted. Reasoning: + +- Built-in NFC antenna on the back, dead-center +- IP68, MIL-STD-810H, drop-resistant (shop-floor durable) +- Replaceable battery (avoids battery-swelling failure mode in 24/7-tethered devices) +- Knox enables true kiosk lockdown +- Pogo dock = magnetic constant power, no cable to yank +- 10.1" screen visible from a few feet away (vs 8" on regular Active 5) + +Cards: same Ubiquiti-issued NFC cards employees already carry. Web NFC reads the card's UID via `NDEFReader`'s `serialNumber` field, which works on raw MIFARE access cards even though they have no NDEF data. + +## Data model + +### `hr.employee` — new field +- `x_fclk_nfc_card_uid` — `Char`, indexed, unique constraint when not null + - Stores card UID as canonical hex (uppercase, colon-separated, MSB first), e.g., `04:A2:B5:62:C1:80` + - Editable by HR managers; visible on the employee form in the existing "Clock Settings" section near the existing PIN field + +### `res.company` — new field +- `x_fclk_nfc_kiosk_location_id` — `Many2one` to `fusion.clock.location` + - Designates which fusion.clock.location is bound to the NFC kiosk for this company + - Required when `fusion_clock.enable_nfc_kiosk = True`; the tap endpoint returns `no_location_configured` if it's empty + - Editable in the NFC Clock Kiosk settings section (per-company since this is multi-company-aware) + +### `hr.attendance` — new fields +- `x_fclk_check_in_photo` — `Binary`, `attachment=True`. Frame captured at clock-in. +- `x_fclk_check_out_photo` — `Binary`, `attachment=True`. Frame captured at clock-out. +- `x_fclk_clock_source` — extend existing `Selection` field to include `'nfc_kiosk'`. + +### `ir.config_parameter` — new entries +- `fusion_clock.enable_nfc_kiosk` — Boolean, default `False`. Master switch. +- `fusion_clock.nfc_photo_required` — Boolean, default `True`. If False, photo is best-effort and tap still succeeds without one. +- `fusion_clock.nfc_enroll_password` — Char, default empty. Short password the manager types to enter Enroll Mode on the kiosk. If empty, falls back to manager-group membership of the kiosk service user. +- `fusion_clock.nfc_kiosk_debug` — Boolean, default `False`. Enables a hidden mock-tap keyboard shortcut for development. + +### `res.config.settings` — new view section +"NFC Clock Kiosk" section in the Clock settings page exposing the four `ir.config_parameter` toggles above. + +**No new models.** All data piggybacks on existing `hr.employee`, `hr.attendance`, `fusion.clock.activity.log`. + +## Backend — controller and endpoints + +**New file:** `controllers/clock_nfc_kiosk.py` + +All endpoints under `/fusion_clock/kiosk/nfc/...`. All require `fusion_clock.group_fusion_clock_manager` on the logged-in kiosk service user. All gated on `fusion_clock.enable_nfc_kiosk == 'True'`. + +**Kiosk service user:** an Odoo `res.users` record created per-company specifically for the tablet to log in as. Member of `fusion_clock.group_fusion_clock_manager`. Long random password stored in the tablet's saved-credentials. Distinct from any human user so its session can be revoked independently if the tablet is stolen. Setup is documented in the provisioning script below; no new code creates this user (it's a manual one-time creation in HR Settings). + +### `GET /fusion_clock/kiosk/nfc` — page render +- Renders the NFC kiosk QWeb template +- Resolves the kiosk's location from `request.env.company.x_fclk_nfc_kiosk_location_id` and passes its name to the template for display ("Clock at: Westin Plant 1") +- Returns redirect to `/my` if the kiosk is disabled or the user lacks the manager group + +### `POST /fusion_clock/kiosk/nfc/tap` — clock toggle +- `type='jsonrpc'`, `auth='user'` +- Input: `{ card_uid: "04:A2:B5:62:C1:80", photo_b64: "data:image/jpeg;base64,..." (optional) }` +- Logic: + 1. Normalize UID (uppercase, colon-separated, reject malformed input) + 2. Lookup `hr.employee` by `x_fclk_nfc_card_uid` (sudo). Not found → `{error: "card_unknown", message: "Card not enrolled"}`. Log to `fusion.clock.activity.log` with the unknown UID. + 3. If `x_fclk_enable_clock` is False → `{error: "clock_disabled"}` + 4. Resolve location from `request.env.company.x_fclk_nfc_kiosk_location_id`. If empty → `{error: "no_location_configured"}` + 5. Server-side debounce: if same UID was tapped within the last 5 seconds, return `{error: "debounce"}` silently + 6. Call `FusionClockAPI._attendance_action_change(geo_info)` with `geo_info = { browser: 'nfc_kiosk', ip_address: , latitude: 0, longitude: 0 }` to toggle attendance state + 7. Write `x_fclk_clock_source = 'nfc_kiosk'`, `x_fclk_location_id = `, distance fields = 0 + 8. If `photo_b64` present, decode and save to `x_fclk_check_in_photo` (clock-in) or `x_fclk_check_out_photo` (clock-out) + 9. If `nfc_photo_required = True` and photo is missing/decode-failed → reject the tap with `{error: "photo_required"}` + 10. Reuse `_check_and_create_penalty`, `_apply_break_deduction`, `_log_activity` calls (same as PIN kiosk) + 11. Return `{ success: true, action: 'clock_in' | 'clock_out', employee_name, employee_avatar_url, message, net_hours_today }` + +### `POST /fusion_clock/kiosk/nfc/enroll` — card enrollment +- `type='jsonrpc'`, `auth='user'` +- Input: `{ employee_id: 42, card_uid: "04:A2:B5:62:C1:80", enroll_password: "1234" }` +- Logic: + 1. Verify `enroll_password` matches `fusion_clock.nfc_enroll_password` (or accept if config is empty AND caller is in manager group) + 2. Normalize UID + 3. Check no other employee has this UID → `{error: "card_already_assigned", existing_employee: ""}` + 4. Write `x_fclk_nfc_card_uid` on the target employee + 5. Log to `fusion.clock.activity.log` ("Manager X enrolled card UID Y to employee Z") + 6. Return `{ success: true, employee_name, card_uid }` + +### `POST /fusion_clock/kiosk/nfc/employee_search` — pick employee for enroll +- Reuses the existing `/fusion_clock/kiosk/search` controller method by importing it; does not duplicate logic. + +## Frontend — kiosk page UX + +**Files:** +- `views/kiosk_nfc_templates.xml` — QWeb template for the page +- `static/src/js/fusion_clock_nfc_kiosk.js` — Web NFC + camera + state machine +- `static/src/css/nfc_kiosk.css` — high-contrast shop-floor styling (always dark) + +**Visual:** always-dark, high-contrast, no Odoo navbar. Shop-floor lighting washes out light backgrounds. + +### State machine + +``` + ┌─── (3s timeout) ─────────────────────────┐ + ▼ │ + ┌─────────────────────────┐ tap detected ┌────────────────────┐ + │ IDLE │ ────────────────► │ PROCESSING │ + │ "Tap card to clock │ │ spinner, "Reading"│ + │ in or out" │ └────────────────────┘ + │ big clock, date, │ │ + │ company name │ success / error + └─────────────────────────┘ ▼ + ▲ ┌─────────────────────────┐ + │ │ RESULT │ + │ │ green: "Welcome John, │ + └─── (3s) ──────────────────│ CLOCKED IN, 8:02 AM" │ + │ red: "Card not │ + │ enrolled" │ + └─────────────────────────┘ +``` + +### IDLE state +- Top: company name + current time (HH:MM, updates every second) + date +- Center: large NFC icon + "Tap your card to clock in or out", subtle pulse animation +- Bottom-right corner: tiny "⚙" icon (gateway to Enroll Mode) + +### PROCESSING state +- Brief spinner + "Reading card…" +- Mostly imperceptible at typical network latency + +### RESULT state — success +- Green panel +- Large employee avatar on the left +- "John Smith" — name in big text +- "CLOCKED IN at 8:02 AM" or "CLOCKED OUT — 8.1h today" +- Auto-return to IDLE after 3s + +### RESULT state — error +- Red panel +- `card_unknown` → "Card not recognized. See your manager." +- `network_error` → "No connection. Please try again." +- `debounce` → silent (no UI change to avoid double-tap confusion) +- `photo_required` → "Camera unavailable. Ask IT to check the kiosk." +- Auto-return to IDLE after 4s + +### Web NFC implementation +- One-time activation button on first page load: "Tap here to enable NFC reader" (Web NFC requires a user gesture before `scan()` is permitted) +- After activation, `NDEFReader.scan()` runs continuously +- `reading` event fires for any tap; we extract `event.serialNumber` (works for raw MIFARE access cards even with no NDEF data) +- UID format: hex bytes joined by colons, uppercased +- If `scan()` throws, restart with a 1-second backoff + +### Camera implementation +- `getUserMedia({ video: { facingMode: 'user' } })` activated alongside NFC +- Hidden `