---
module: devices.device-groups
title: Device Groups
tab: Architect
order: 30
audience: dev
---

> **Internal · architecture.** Grounded in `hiveops-devices` backend + `AtmGroups.svelte` + `api.ts` (2026-07-01).

## Ownership
Device Groups are owned end-to-end by **hiveops-devices** (Spring Boot, port **8096**, DB **`hiveiq_devices`**). No other service owns group state. See [platform.service-ownership]. The one cross-service interaction is the hardware-profile push, which reaches into **fleet** (via NGINX `/fleet/*`) and emits a device event on **Kafka**.

## Components (this feature only)
- **Frontend:** `AtmManagement/AtmGroups.svelte` — table + slide-in panels for create/edit and Assign Devices, plus an Assign-Profile modal. Two-panel "shuttle" pickers manage membership client-side before saving. Calls `atmGroupAPI` (and `fleetAPI`, `agentProfileAPI`) in `lib/api.ts`.
- **Controller:** `AtmGroupController` — `@RequestMapping({"/api/atm-groups","/api/device-groups"})` (Phase-2 rename alias).
- **Service:** `AtmGroupService` (`@Transactional(readOnly=true)`, write methods `@Transactional`).
- **Repository:** `AtmGroupRepository` — uses `LEFT JOIN FETCH g.atms` queries (`findByIdWithAtms`, `findAllWithAtms`, `findAllWithAtmsByInstitutionKeyIn`) to avoid lazy-load blowups on the `@ManyToMany`.
- **Entity:** `AtmGroup` → table `atm_groups`; membership is a `@ManyToMany` to `Atm` via join table `atm_group_members` (`group_id`, `atm_id`).
- **DTO:** `AtmGroupDTO` (adds computed `atmIds` sorted + `atmCount`); request DTO `CreateAtmGroupRequest`.

## Data stores (read/written)
| Table | Access | Notes |
|-------|--------|-------|
| `atm_groups` | R/W | group name (`UNIQUE`, global), description, `color`, `institution_key` (len 10), timestamps |
| `atm_group_members` | R/W | join table; membership replace/add/remove all mutate here |
| `atms` | R (membership resolve) / W (hardware-profile only) | `setGroupHardwareProfile` writes `atm.hardware_profile` per member |

See [platform.data-architecture]. Schema is Flyway-managed; `ddl-auto=validate`.

## Data flow — plain group CRUD
1. Frontend `atmGroupAPI.*` → devices backend controller → service → repository → `atm_groups` / `atm_group_members`.
2. Reads are institution-filtered by `InstitutionContext.resolveScope()`; single-institution callers have their `institution_key` forced on create.
3. **No Kafka is emitted for group create/update/delete/membership changes.** Groups are a devices-local concept and are *not* mirrored into the `device_summary` tables of incident/fleet/reports/recon.

## Data flow — Assign Profile (the cross-service path)
This is the executor → Kafka → downstream pattern for this feature:

```
User picks profile in Assign-Profile modal
  → POST /api/atm-groups/{id}/hardware-profile { profileKey }   (devices backend)
      AtmGroupService.setGroupHardwareProfile():
        for each member Atm:
          atm.setHardwareProfile(profileKey.trim().toLowerCase())  // normalised
          atmRepository.save(atm)
          DeviceEventPublisher.publishUpdated(atm)  ──► Kafka hiveops.device.events (DEVICE_UPDATED, keyed by atmId)
        returns { atmIds: [...] }   // sorted member ids
  → Frontend, if atmIds non-empty:
      POST /fleet/tasks { atmIds, taskKind: "CONFIG_UPDATE",
                          configPatch: { "atm.hardware.profile": profileKey } }   (fleet backend, via /fleet/*)
```

So a single profile assign produces **two** effects: (a) N `DEVICE_UPDATED` Kafka events consumed by the device-summary mirrors ([platform.kafka]), and (b) one batch of `CONFIG_UPDATE` fleet tasks that the agents pull to actually apply `atm.hardware.profile`. The Kafka publish and the fleet task are independent — the fleet task is orchestrated by the **frontend**, not the backend, so a backend-only caller of `/hardware-profile` updates DB + emits events but does **not** queue the config push.

## Key API endpoints
| Method + Path | Auth | Purpose |
|---------------|------|---------|
| `GET /api/atm-groups` | authed | List (institution-scoped) |
| `GET /api/atm-groups/{id}` | authed | One group |
| `POST /api/atm-groups` | admin | Create |
| `PUT /api/atm-groups/{id}` | admin | Update (partial; non-null fields only) |
| `DELETE /api/atm-groups/{id}` | admin | Delete (clears members first) |
| `PUT /api/atm-groups/{id}/atms` | admin | **Replace** all members (`List<Long>` body) |
| `POST /api/atm-groups/{id}/atms/{atmId}` | admin | Add one member |
| `DELETE /api/atm-groups/{id}/atms/{atmId}` | admin | Remove one member |
| `POST /api/atm-groups/{id}/hardware-profile` | admin | Bulk-set `atm.hardware_profile`, returns `{atmIds}` |
| `POST /fleet/tasks` | authed | (fleet) `CONFIG_UPDATE` push queued by frontend after profile assign |

admin = `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`.

## Kafka
- **Produced (only via profile assign):** `hiveops.device.events`, type `DEVICE_UPDATED`, keyed by `atmId`, from `DeviceEventPublisher.publishUpdated`. Consumed by the device-summary mirrors in incident/fleet/reports/recon. See [platform.kafka].
- **No group-specific topic exists.** Group identity/membership is never published.

## Design notes / trade-offs
- **Default group is not persisted server-side** — it lives in browser `localStorage` (`hiveops_defaultGroupId`, `stores.ts`) and only drives the `groupId` filter on `/api/atms/paginated`.
- **Group name uniqueness is global**, not per-institution — a multi-tenant naming collision surfaces as a DB constraint error.
- **Membership save is a full-set replace** (`assignAtms` / `updateGroup` with `atmIds`), so the client must send the complete intended set.
- Cross-link: [platform.data-architecture], [platform.kafka], [platform.service-ownership], and the service-level [technical.devices].
