# Devices (Manage)

**Portal:** Manage · **Routes:** `manage.devices.*` · **Nav:** Others → **System → Devices** (Operations suite, between Roles and AI Requests) — the device registry is platform admin. Also reached in context as the settings of the two modules that use it (**Phone Call** and **Showroom F2F**) ·
**Label:** a device's Admin owner is shown as **"Agent"** (outward label only — the
`admin_id` column and the `admin` filter key are unchanged, the same way the module
keeps its `F2f` code names behind the "Showroom F2F" nav label)

## What it does
A registry that maps a **recording source** to the **agent (Admin)** who owns it, so
recordings ingested from that source are **auto-attributed** to `admin_id` — no
manual tagging. A badge write also **backfills the recordings that source already
sent** (see *Backfill* below), so registering late doesn't strand a pile of untagged
rows. One row per device, discriminated by `type`:

- **Badge** (`TYPE_BADGE`) — `identifier` = the yhy smart-badge serial (`device_sn`);
  used by **[Showroom F2F](/docs/modules_handbook/manage/f2f/readMe.md)**.
- **Dowayai** (`TYPE_DOWAYAI`) — `identifier` = the dowayai account **username**, plus
  the account **password** in the encrypted `secret` column. Used by
  **[Phone Call](/docs/modules_handbook/manage/call-history/readMe.md)**: the device
  registry **IS the account source** — [`PollDowayaiRecordings`](/app/Console/Commands/PollDowayaiRecordings.php)
  iterates the active `TYPE_DOWAYAI` devices, decrypts `secret`, logs in, and pulls
  each one's recordings (attributed to that device's `admin_id`). Adding a rep is a
  UI action (add a Dowayai device with username + password) — **no env, no code**.
  Only the shared per-deploy settings (`DOWAYAI_BASE_URL`, min duration, poll gate)
  stay in `config('calls.dowayai')`.

## How it works
- **`Src\Device\Device`** — key model (uuid + blame + soft delete) on `devices`.
  `TYPE_BADGE` (1) / `TYPE_DOWAYAI` (2) with the `TYPES` metadata array. A unique
  index on `(type, identifier)` means one active mapping per serial / account. The
  `secret` column (dowayai password) is cast **`encrypted`** + `$hidden`; `hasSecret()`
  reports presence (via `getRawOriginal`) without exposing the value.
- **Resolution — `Device::adminIdFor(int $type, ?string $identifier): ?int`.** The
  single place both ingest paths look up attribution (so they never drift): returns
  the owning `admin_id` for an **active** device, or `NULL` when the identifier is
  unregistered / inactive / empty. Used by the F2f yhy jobs
  ([`IngestYhyAudioChunk`](/app/Jobs/F2f/IngestYhyAudioChunk.php),
  [`DownloadYhyRecording`](/app/Jobs/F2f/DownloadYhyRecording.php)) and the Phone
  Call poll ([`PollDowayaiRecordings`](/app/Console/Commands/PollDowayaiRecordings.php))
  when they build their recording row — an unregistered badge / account simply lands
  `admin_id` NULL (untagged, as before). `adminIdFor()` is the **forward** pass
  (ingest time, one row being born); the **backward** pass — rows that already exist
  because they arrived before their badge did — is the badge backfill below. Both
  resolve to the same fact (`devices.admin_id`), so they cannot drift.
- **CRUD** — the §14 admin index pattern: a DataTable list with `type` / `active`
  filters and a **create/edit modal**. The **agent** is a searchable **ComboBox**
  (type a name / email / phone → the `manage.devices.agents` typeahead, backed by the
  shared `Admin::search()`; there is no per-page agent list). A new device
  **pre-selects its type** from the page's deep-link — F2f links to `?type[]=1`
  (Badge), Phone Call to `?type[]=2` (Dowayai), so "Add device" opens on the right
  type. A **password** field shows for the Dowayai type — required on create,
  blank-on-edit keeps the stored one via the repository. (The old **Agent filter**
  in the drawer was removed — filter by type / status / free-text search instead.)
  Writes go
  through **`DeviceRepository`** inside `DB::transaction`; re-registering a
  previously-removed identifier **restores** the soft-deleted row (the unique index
  spans trashed rows, so a plain insert would collide).
- **A Dowayai account must PROVE it works before it can be registered.** A badge
  PUSHES to our webhook, so a wrong serial shows up immediately; a Dowayai account
  instead **pulls on a schedule**, so a wrong password fails silently every minute
  and only the worker log ever knows. The login is therefore verified by actually
  signing in and listing the account's recordings — **`DowayaiClient::probe()`**,
  read-only (it ingests nothing and downloads no audio) and non-throwing (a bad
  password is an expected answer here, not an exception). Three surfaces share that
  one probe: the create modal's **Test connection** pre-flight
  (`POST devices/test-credentials` → JSON; submit stays disabled until it passes, and
  editing the account or password invalidates an earlier result), the **same check
  re-run server-side** in `StoreRequest::withValidator` — the real gate, because the
  client never is — and a per-row **Test** action for an already-saved device
  (`POST devices/{id}/test-connection` → flash). The result deliberately reports not
  the raw total but **how many recordings will ACTUALLY be imported**, counted with
  the poll's own `DowayaiIngestPolicy` (see
  [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md)) — so *"3 on the
  account, 1 will be imported"* is the truth, not a total that quietly hides the
  minimum-duration filter. `UpdateRequest` inherits the same gate, and the rule that
  makes it correct there is **"prove only what was submitted"**: an edit that leaves
  the password **blank keeps the stored one** and is not probed (there is nothing new
  to prove — the row's Test button covers verifying it), while an edit that types a
  **new** password is proved exactly like a create. A wrong password typed on an edit
  is the same silent-poll-failure, just arriving later.
- **Backfill — a badge write also fixes its own untagged history.** After `store` /
  `update` commits, `DevicesController` runs
  **[`BackfillDeviceAttributionAction`](/app/Actions/BackfillDeviceAttributionAction.php)**,
  which sets `admin_id` on every `f2f_recordings` row whose `device_sn` equals the
  device's `identifier` **and whose `admin_id` IS NULL**, via
  `F2fRecordingRepository::backfillAdminForDevice()`. Two invariants make it safe:
  - **Fill, never overwrite.** A row that already has an `admin_id` (auto-attributed
    at ingest, imported via `calls.sales_map`, or hand-corrected in the Showroom Edit
    modal) is never rewritten. That NULL-only guard is also what makes the call
    **idempotent** — re-saving a device is a no-op.
  - **Never guess.** Untagged rows went NULL precisely *while the device was
    unregistered / inactive / removed*, so they may be the **previous** owner's — and
    the registry keeps no ownership history to tell. So the Action **skips entirely**
    when this write changed the device's attribution identity: a different
    `admin_id`, a different `identifier`, or a different `type` (including the
    restore branch of `create()`, which is semantically an update). Reactivating a
    badge with the same owner and serial is unambiguous and *does* claim its dormant
    rows. Ambiguous cases route to the artisan command below, where a human draws the
    boundary with `--since`.

  It runs **after** the device's transaction, never nested inside it: a failed
  backfill must not roll back a valid registration, and its row locks must not be held
  for the device write's duration. The Action lives in `app/Actions/` rather than
  `DeviceRepository` so the row stays owned by the repository of its own table
  (GUIDELINES §2) and the module arrow keeps pointing **F2f → Device** — a Device → F2f
  call would make this registry import every consumer module. **Dowayai has no
  backfill:** `call_recordings` carries no `device_sn` (the poll attributes per account
  as it fetches), so there is no column to match an account against — it is not merely
  out of scope, it is inexpressible. See
  [Showroom F2F](/docs/modules_handbook/manage/f2f/readMe.md).
- **The existing backlog — `php artisan f2f:backfill-attribution`.** The hook above
  only fires when someone writes a device, so it never repairs rows that predate it.
  [`BackfillF2fDeviceAttribution`](/app/Console/Commands/BackfillF2fDeviceAttribution.php)
  sweeps every **active badge** device and attributes its untagged recordings through
  the same Action. `--dry-run` reports per-device counts without writing (this is a
  mass mutation over historical data — look before you leap); `--device=SN-1
  --since=2026-05-01` is the escape hatch for the reassignment cases the hook refuses
  to guess at. Console context has no actor, so `updated_by` is deliberately left
  untouched rather than wiped.

### Reference usage
To attribute an ingested recording to its agent, resolve the owner from the
identifier at row-creation time:

```php
'admin_id' => \Src\Device\Device::adminIdFor(\Src\Device\Device::TYPE_BADGE, $deviceNo),
```

`adminIdFor()` is the lookup for **badge (F2f)** ingest, where the recording arrives
by serial. The **dowayai (Phone Call)** poll is the account owner itself, so it reads
`admin_id` (and `secret`) straight off the device it is iterating — no lookup. The
agent everywhere is **derived from `admin_id`** (`Admin::displayName()`); there is no
denormalized name column (Phase C dropped `salesperson_name` — the code name stays),
so an unregistered / admin-less recording simply shows "—".

That lookup only covers rows created **after** the device exists. Closing the gap the
other way — a badge registered after it has already been recording — is the backfill.
Trigger it from a controller **after** the device write commits, never from inside
`DeviceRepository`:

```php
// DevicesController::store / update, after $devices->create()/update() returns
$attributed = $backfill->execute($device, $previousAdminId, $previousIdentifier, $previousType);
```

Pass the **pre-write** values (capture them before the repository call — `create()`'s
restore branch destroys the old mapping). Passing `null` for one means "no prior
mapping", which passes the guard. The `admin_id IS NULL` clause inside
`backfillAdminForDevice()` is the rest of the contract: it is what makes the call
idempotent and what stops a device edit from rewriting attribution a human already
fixed by hand.

## Related files

**Backend**
- [src/Device/Device.php](/src/Device/Device.php) — key model; `TYPES`; `admin()`; static `adminIdFor()`.
- [src/Device/Repositories/DeviceRepository.php](/src/Device/Repositories/DeviceRepository.php) — `create` (restore-on-conflict) / `update` / `delete`, all in `DB::transaction`. Deliberately knows **nothing** about the backfill — `Src\Device` stays a leaf module.
- [app/Actions/BackfillDeviceAttributionAction.php](/app/Actions/BackfillDeviceAttributionAction.php) — the badge backfill: `execute()` (the guarded post-write path — skips on an owner / serial / type change) and `attribute()` (the unguarded operator path the artisan command uses). Enforces badge-only, device-attributable (via `adminIdFor`), and admin-not-trashed.
- [app/Console/Commands/BackfillF2fDeviceAttribution.php](/app/Console/Commands/BackfillF2fDeviceAttribution.php) — `f2f:backfill-attribution` (`--device` / `--since` / `--dry-run`) for the pre-existing backlog.
- [app/Http/Controllers/Manage/Devices/DevicesController.php](/app/Http/Controllers/Manage/Devices/DevicesController.php) — `index` (§14 list) / `agents` (the agent-ComboBox typeahead → `Admin::search()`) / `store` / `update` / `destroy` / `testConnection` (saved row → flash) / `testCredentials` (typed login → JSON for the create modal); `store`/`update` capture the pre-write attribution identity, run the backfill after the repository write, and flash the attributed count.
- [app/Helpers/Calls/DowayaiClient.php](/app/Helpers/Calls/DowayaiClient.php) — static **`probe()`**, the one read-only credential check behind all three verify surfaces; reports the ingestable count via `DowayaiIngestPolicy` so the preview cannot over-promise.
- [app/Http/Requests/Manage/Devices/StoreRequest.php](/app/Http/Requests/Manage/Devices/StoreRequest.php) · [UpdateRequest.php](/app/Http/Requests/Manage/Devices/UpdateRequest.php) · [DevicesQueryRequest.php](/app/Http/Requests/Manage/Devices/DevicesQueryRequest.php).
- [src/F2f/Repositories/F2fRecordingRepository.php](/src/F2f/Repositories/F2fRecordingRepository.php) — **`backfillAdminForDevice()`**, the fill-only (`admin_id IS NULL`) write the Action calls; the F2f table stays F2f's to write.
- [src/F2f/F2fRecording.php](/src/F2f/F2fRecording.php) — **`untaggedForDevice()`**, the shared predicate behind both the write and the command's dry-run count.

**Tests**
- [tests/Feature/F2f/BackfillDeviceAttributionTest.php](/tests/Feature/F2f/BackfillDeviceAttributionTest.php) — every backfill guard (fill-not-overwrite, inactive, dowayai, reassign, serial/type change, restore, trashed admin, soft-deleted rows, the command + `--dry-run` / `--since` / idempotency).

**Frontend**
- [resources/js/Pages/Manage/Devices/Index.vue](/resources/js/Pages/Manage/Devices/Index.vue) — DataTable + filters + Add/Edit/Delete.
- [resources/js/Pages/Manage/Devices/Partials/DeviceFormModal.vue](/resources/js/Pages/Manage/Devices/Partials/DeviceFormModal.vue) — create/edit modal.

**Migration / Routes**
- [database/migrations/2026_07_01_000030_create_devices_table.php](/database/migrations/2026_07_01_000030_create_devices_table.php) — `devices` (unique `type`+`identifier`).
- [database/migrations/2026_07_02_000010_add_secret_to_devices_table.php](/database/migrations/2026_07_02_000010_add_secret_to_devices_table.php) — the encrypted `secret` (dowayai account password).
- [routes/web.php](/routes/web.php) — `manage.devices.*`.

**See also:** [Showroom F2F](/docs/modules_handbook/manage/f2f/readMe.md) (badge consumer) · [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md) (dowayai consumer).
