17 KiB
Implementation Plan: ATM Auto-Registration and Management UI
Overview
Implement three key features:
- Auto-register ATMs when hiveops-agent communicates for the first time
- Add "ATM Management" menu to the frontend navigation
- Create ATM list view showing all ATMs with agent connection status
- Enable manual ATM creation from the frontend
Architecture Decisions
Auto-Registration Strategy
- Modify existing
POST /api/journal-eventsendpoint to accept BOTH database ID (Long) AND agent identifier (String) - When agent identifier is provided, look up or auto-create ATM
- Auto-create ATM with defaults if not found: location="Unknown", address="Auto-registered", model="Unknown"
- Maintains backward compatibility - existing UI calls using database ID continue to work
/api/agentbase path reserved for future agent-related functionality (not used in this implementation)
Connection Status Tracking
- Use last-seen timestamp approach via
AtmProperties.lastHeartbeatfield - Update timestamp on every agent communication (journal events, config sync)
- Calculate connection status dynamically:
- Connected: lastHeartbeat within 5 minutes
- Disconnected: lastHeartbeat older than 5 minutes
- Never Connected: lastHeartbeat is null
- Add computed
agentConnectionStatusfield toAtmDTO
Backend Changes
1. Modified and New DTOs
File: backend/src/main/java/com/hiveops/incident/dto/CreateJournalEventRequest.java (MODIFY)
Modify existing DTO to support both database ID and agent identifier:
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CreateJournalEventRequest {
private Long atmId; // Database ID (for UI/existing integrations)
private String agentAtmId; // Agent identifier e.g., "ATM-001" (for hiveops-agent)
private Long incidentId;
private String eventType;
private String eventDetails;
private Integer cardReaderSlot;
private String cardReaderStatus;
private String cassetteType;
private Integer cassetteFillLevel;
private Integer cassetteBillCount;
private String cassetteCurrency;
private String eventSource;
}
Validation: Either atmId OR agentAtmId must be provided (not both, not neither)
File: backend/src/main/java/com/hiveops/incident/dto/CreateAtmRequest.java (NEW)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CreateAtmRequest {
@NotBlank
private String atmId; // Unique identifier
@NotBlank
private String location;
@NotBlank
private String address;
@NotBlank
private String model;
private Double latitude;
private Double longitude;
}
File: backend/src/main/java/com/hiveops/incident/dto/AtmDTO.java (MODIFY)
Add two new fields:
private String agentConnectionStatus; // "CONNECTED", "DISCONNECTED", "NEVER_CONNECTED"
private LocalDateTime lastHeartbeat;
2. Service Layer Changes
File: backend/src/main/java/com/hiveops/incident/service/AtmService.java (MODIFY)
Add new methods:
@Transactional
public Atm findOrCreateAtm(String agentAtmId) {
return atmRepository.findByAtmId(agentAtmId)
.orElseGet(() -> autoRegisterAtm(agentAtmId));
}
@Transactional
public void updateLastHeartbeat(Atm atm) {
AtmProperties props = atmPropertiesRepository.findByAtmId(atm.getId())
.orElseGet(() -> {
AtmProperties newProps = new AtmProperties();
newProps.setAtm(atm);
return atmPropertiesRepository.save(newProps);
});
props.setLastHeartbeat(LocalDateTime.now());
atmPropertiesRepository.save(props);
}
private Atm autoRegisterAtm(String agentAtmId) {
Atm atm = Atm.builder()
.atmId(agentAtmId)
.location("Unknown")
.address("Auto-registered - pending configuration")
.model("Unknown")
.build();
logger.info("Auto-registering new ATM: {}", agentAtmId);
return atmRepository.save(atm);
}
@Transactional
public AtmDTO createAtm(CreateAtmRequest request) {
if (atmRepository.findByAtmId(request.getAtmId()).isPresent()) {
throw new RuntimeException("ATM with ID " + request.getAtmId() + " already exists");
}
Atm atm = Atm.builder()
.atmId(request.getAtmId())
.location(request.getLocation())
.address(request.getAddress())
.model(request.getModel())
.latitude(request.getLatitude())
.longitude(request.getLongitude())
.build();
Atm saved = atmRepository.save(atm);
return mapToDto(saved);
}
@Transactional
public AtmDTO updateAtm(Long id, CreateAtmRequest request) {
Atm atm = atmRepository.findById(id)
.orElseThrow(() -> new RuntimeException("ATM not found"));
// Cannot change atmId (unique identifier)
atm.setLocation(request.getLocation());
atm.setAddress(request.getAddress());
atm.setModel(request.getModel());
atm.setLatitude(request.getLatitude());
atm.setLongitude(request.getLongitude());
Atm saved = atmRepository.save(atm);
return mapToDto(saved);
}
@Transactional
public void deleteAtm(Long id) {
Atm atm = atmRepository.findById(id)
.orElseThrow(() -> new RuntimeException("ATM not found"));
atm.setStatus(AtmStatus.INACTIVE); // Soft delete
atmRepository.save(atm);
}
Modify existing method mapToDto(Atm atm) at line 89:
private AtmDTO mapToDto(Atm atm) {
// Get lastHeartbeat from properties
LocalDateTime lastHeartbeat = atmPropertiesRepository.findByAtmId(atm.getId())
.map(AtmProperties::getLastHeartbeat)
.orElse(null);
// Calculate connection status
String connectionStatus = calculateConnectionStatus(lastHeartbeat);
return AtmDTO.builder()
.id(atm.getId())
.atmId(atm.getAtmId())
.location(atm.getLocation())
.address(atm.getAddress())
.status(atm.getStatus().name())
.latitude(atm.getLatitude())
.longitude(atm.getLongitude())
.model(atm.getModel())
.lastServiceDate(atm.getLastServiceDate())
.createdAt(atm.getCreatedAt())
.updatedAt(atm.getUpdatedAt())
.agentConnectionStatus(connectionStatus)
.lastHeartbeat(lastHeartbeat)
.build();
}
private String calculateConnectionStatus(LocalDateTime lastHeartbeat) {
if (lastHeartbeat == null) {
return "NEVER_CONNECTED";
}
long minutesAgo = java.time.Duration.between(lastHeartbeat, LocalDateTime.now()).toMinutes();
return minutesAgo <= 5 ? "CONNECTED" : "DISCONNECTED";
}
File: backend/src/main/java/com/hiveops/incident/service/JournalEventService.java (MODIFY)
Add dependency injection in constructor:
private final AtmService atmService;
Modify existing createEvent method (replace lines 25-46):
public JournalEventDTO createEvent(CreateJournalEventRequest request) {
Atm atm;
// Support both database ID and agent identifier
if (request.getAgentAtmId() != null && !request.getAgentAtmId().isEmpty()) {
// Agent identifier provided - find or auto-register
atm = atmService.findOrCreateAtm(request.getAgentAtmId());
atmService.updateLastHeartbeat(atm);
} else if (request.getAtmId() != null) {
// Database ID provided (existing behavior)
atm = atmRepository.findById(request.getAtmId())
.orElseThrow(() -> new RuntimeException("ATM not found"));
} else {
throw new RuntimeException("Either atmId or agentAtmId must be provided");
}
JournalEvent event = JournalEvent.builder()
.atm(atm)
.incident(request.getIncidentId() != null ?
incidentRepository.findById(request.getIncidentId()).orElse(null) : null)
.eventType(EventType.valueOf(request.getEventType()))
.eventDetails(request.getEventDetails())
.cardReaderSlot(request.getCardReaderSlot())
.cardReaderStatus(request.getCardReaderStatus())
.cassetteType(request.getCassetteType())
.cassetteFillLevel(request.getCassetteFillLevel())
.cassetteBillCount(request.getCassetteBillCount())
.cassetteCurrency(request.getCassetteCurrency())
.eventSource(request.getEventSource() != null ? request.getEventSource() : "MANUAL")
.build();
JournalEvent saved = journalEventRepository.save(event);
return mapToDto(saved);
}
3. Controller Changes
File: backend/src/main/java/com/hiveops/incident/controller/JournalEventController.java (NO CHANGES)
The existing POST /api/journal-events endpoint will automatically support both formats through the modified CreateJournalEventRequest DTO and updated service logic. No controller changes needed.
Endpoint behavior:
- UI sends:
{ "atmId": 123, ... }- works as before - Agent sends:
{ "agentAtmId": "ATM-001", ... }- auto-registers if needed
File: backend/src/main/java/com/hiveops/incident/controller/AtmController.java (MODIFY)
Add CRUD endpoints:
@PostMapping
public ResponseEntity<AtmDTO> createAtm(@Valid @RequestBody CreateAtmRequest request) {
AtmDTO atm = atmService.createAtm(request);
return ResponseEntity.ok(atm);
}
@PutMapping("/{id}")
public ResponseEntity<AtmDTO> updateAtm(@PathVariable Long id, @Valid @RequestBody CreateAtmRequest request) {
AtmDTO atm = atmService.updateAtm(id, request);
return ResponseEntity.ok(atm);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteAtm(@PathVariable Long id) {
atmService.deleteAtm(id);
return ResponseEntity.noContent().build();
}
Frontend Changes
1. API Client Updates
File: frontend/src/lib/api.ts (MODIFY)
Add to atmAPI object:
create: (data: Partial<Atm>) => api.post<Atm>('/atms', data),
update: (id: number, data: Partial<Atm>) => api.put<Atm>(`/atms/${id}`, data),
delete: (id: number) => api.delete(`/atms/${id}`),
2. New Components
File: frontend/src/components/AtmManagement/index.ts (NEW)
export { default } from './AtmManagement.svelte';
File: frontend/src/components/AtmManagement/AtmManagement.svelte (NEW)
Container component with tabs for List and Create views:
<script lang="ts">
import AtmList from './AtmList.svelte';
import CreateAtm from './CreateAtm.svelte';
export let activeTab: 'list' | 'create' = 'list';
</script>
<div class="atm-management">
{#if activeTab === 'list'}
<AtmList />
{:else if activeTab === 'create'}
<CreateAtm />
{/if}
</div>
File: frontend/src/components/AtmManagement/AtmList.svelte (NEW)
ATM list with connection status indicators:
- Table showing: ID, ATM ID, Location, Model, Status, Agent Status, Last Heartbeat, Actions
- Connection status dot (green=connected, red=disconnected, gray=never)
- Search/filter functionality
- Edit/Delete actions
- Follow pattern from
IncidentList.svelte
Key helper function:
function getConnectionStatus(status: string, lastHeartbeat: string | null) {
if (status === 'NEVER_CONNECTED') {
return { label: 'Never Connected', color: '#9ca3af', dot: '⚫' };
}
if (status === 'CONNECTED') {
return { label: 'Connected', color: '#10b981', dot: '🟢' };
}
return { label: 'Disconnected', color: '#ef4444', dot: '🔴' };
}
File: frontend/src/components/AtmManagement/CreateAtm.svelte (NEW)
Manual ATM creation form with fields:
- ATM ID (text, required, unique)
- Location (text, required)
- Address (text, required)
- Model (text, required)
- Latitude (number, optional)
- Longitude (number, optional)
Follow pattern from CreateIncident.svelte with form validation, loading states, and success messages.
3. Navigation Updates
File: frontend/src/App.svelte (MODIFY)
Add state variables (around line 20-30):
let atmManagementExpanded = false;
let atmManagementTab: 'list' | 'create' = 'list';
const atmManagementTabs = [
{ id: 'list', label: 'ATM List', icon: '📋' },
{ id: 'create', label: 'Add ATM', icon: '➕' },
];
function selectAtmManagementTab(tabId: string) {
atmManagementTab = tabId as 'list' | 'create';
currentView = 'atm-management';
}
Add menu section (insert between ATM Properties and Fleet Management, around line 173):
<div class="nav-group">
<button
class="nav-btn"
class:active={currentView === 'atm-management'}
on:click={() => {
atmManagementExpanded = !atmManagementExpanded;
if (!atmManagementExpanded) {
atmManagementTab = 'list';
currentView = 'atm-management';
}
}}
>
<span class="nav-icon">📱</span>
ATM Management
<span class="expand-icon" class:expanded={atmManagementExpanded}>
{atmManagementExpanded ? '▼' : '▶'}
</span>
</button>
{#if atmManagementExpanded}
<div class="submenu">
{#each atmManagementTabs as tab}
<button
class="submenu-btn"
class:active={currentView === 'atm-management' && atmManagementTab === tab.id}
on:click={() => selectAtmManagementTab(tab.id)}
>
<span class="submenu-icon">{tab.icon}</span>
{tab.label}
</button>
{/each}
</div>
{/if}
</div>
Add import (around line 10):
import AtmManagement from './components/AtmManagement';
Add route (in main content section, around line 245):
{:else if currentView === 'atm-management'}
<div class="atm-management-view">
<AtmManagement activeTab={atmManagementTab} />
</div>
Implementation Sequence
Phase 1: Backend Auto-Registration
- Modify
CreateJournalEventRequest.javato addagentAtmIdfield - Add
findOrCreateAtm(),autoRegisterAtm(),updateLastHeartbeat()toAtmService - Inject
AtmServiceintoJournalEventServiceconstructor - Modify
createEvent()method inJournalEventServiceto support both atmId formats - Test existing
/api/journal-eventsendpoint with both formats
Phase 2: Backend ATM Management
- Create
CreateAtmRequest.java - Modify
AtmDTOto addagentConnectionStatusandlastHeartbeatfields - Add
createAtm(),updateAtm(),deleteAtm()toAtmService - Modify
mapToDto()and addcalculateConnectionStatus()toAtmService - Add POST, PUT, DELETE endpoints to
AtmController
Phase 3: Frontend Components
- Add
create,update,deletemethods toatmAPIinapi.ts - Create
AtmList.sveltecomponent with connection status display - Create
CreateAtm.svelteform component - Create
AtmManagement.sveltecontainer component - Create
index.tsexport file
Phase 4: Frontend Navigation
- Add state variables and tab configuration to
App.svelte - Add import for
AtmManagementcomponent - Add menu section between ATM Properties and Fleet Management
- Add route handler in main content section
Critical Files
Backend:
backend/src/main/java/com/hiveops/incident/service/AtmService.javabackend/src/main/java/com/hiveops/incident/service/JournalEventService.javabackend/src/main/java/com/hiveops/incident/controller/AtmController.javabackend/src/main/java/com/hiveops/incident/dto/CreateJournalEventRequest.javabackend/src/main/java/com/hiveops/incident/dto/AtmDTO.java
Frontend:
frontend/src/App.sveltefrontend/src/lib/api.tsfrontend/src/components/AtmManagement/(new directory)
Verification
Test Auto-Registration
# Send agent journal event with new ATM (using agent identifier)
curl -X POST http://localhost:8080/api/journal-events \
-H "Content-Type: application/json" \
-d '{
"agentAtmId": "ATM-001",
"eventType": "CARD_READER_DETECTED",
"eventDetails": "Card reader initialized",
"eventSource": "HIVEOPS_AGENT"
}'
# Verify ATM was auto-created
curl http://localhost:8080/api/atms/search?query=ATM-001
# Test backward compatibility (using database ID)
curl -X POST http://localhost:8080/api/journal-events \
-H "Content-Type: application/json" \
-d '{
"atmId": 1,
"eventType": "CASSETTE_LOW",
"eventDetails": "Cassette running low",
"eventSource": "MANUAL"
}'
Test Manual ATM Creation
- Navigate to ATM Management > Add ATM
- Fill form with: atmId="ATM-002", location="New York", address="123 Main St", model="Hyosung 2700"
- Submit and verify ATM appears in list
- Check connection status shows "Never Connected" (gray)
Test Connection Status
- Send journal event from existing ATM
- Refresh ATM list
- Verify connection status changes to "Connected" (green)
- Wait 6 minutes
- Verify status changes to "Disconnected" (red)
Test Edit/Delete
- Click edit on an ATM
- Modify location and address
- Save and verify changes
- Click delete
- Verify ATM status changes to INACTIVE