516 lines
17 KiB
Markdown
516 lines
17 KiB
Markdown
# Implementation Plan: ATM Auto-Registration and Management UI
|
||
|
||
## Overview
|
||
|
||
Implement three key features:
|
||
1. **Auto-register ATMs** when hiveops-agent communicates for the first time
|
||
2. **Add "ATM Management" menu** to the frontend navigation
|
||
3. **Create ATM list view** showing all ATMs with agent connection status
|
||
4. **Enable manual ATM creation** from the frontend
|
||
|
||
## Architecture Decisions
|
||
|
||
### Auto-Registration Strategy
|
||
- Modify existing `POST /api/journal-events` endpoint 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/agent` base path reserved for future agent-related functionality (not used in this implementation)
|
||
|
||
### Connection Status Tracking
|
||
- Use last-seen timestamp approach via `AtmProperties.lastHeartbeat` field
|
||
- 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 `agentConnectionStatus` field to `AtmDTO`
|
||
|
||
## 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:
|
||
```java
|
||
@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)
|
||
```java
|
||
@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:
|
||
```java
|
||
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:**
|
||
```java
|
||
@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:**
|
||
```java
|
||
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:**
|
||
```java
|
||
private final AtmService atmService;
|
||
```
|
||
|
||
**Modify existing `createEvent` method (replace lines 25-46):**
|
||
```java
|
||
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:
|
||
```java
|
||
@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:
|
||
```typescript
|
||
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)
|
||
```typescript
|
||
export { default } from './AtmManagement.svelte';
|
||
```
|
||
|
||
#### File: `frontend/src/components/AtmManagement/AtmManagement.svelte` (NEW)
|
||
|
||
Container component with tabs for List and Create views:
|
||
```svelte
|
||
<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:
|
||
```typescript
|
||
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):**
|
||
```typescript
|
||
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):**
|
||
```svelte
|
||
<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):**
|
||
```typescript
|
||
import AtmManagement from './components/AtmManagement';
|
||
```
|
||
|
||
**Add route (in main content section, around line 245):**
|
||
```svelte
|
||
{:else if currentView === 'atm-management'}
|
||
<div class="atm-management-view">
|
||
<AtmManagement activeTab={atmManagementTab} />
|
||
</div>
|
||
```
|
||
|
||
## Implementation Sequence
|
||
|
||
### Phase 1: Backend Auto-Registration
|
||
1. Modify `CreateJournalEventRequest.java` to add `agentAtmId` field
|
||
2. Add `findOrCreateAtm()`, `autoRegisterAtm()`, `updateLastHeartbeat()` to `AtmService`
|
||
3. Inject `AtmService` into `JournalEventService` constructor
|
||
4. Modify `createEvent()` method in `JournalEventService` to support both atmId formats
|
||
5. Test existing `/api/journal-events` endpoint with both formats
|
||
|
||
### Phase 2: Backend ATM Management
|
||
1. Create `CreateAtmRequest.java`
|
||
2. Modify `AtmDTO` to add `agentConnectionStatus` and `lastHeartbeat` fields
|
||
3. Add `createAtm()`, `updateAtm()`, `deleteAtm()` to `AtmService`
|
||
4. Modify `mapToDto()` and add `calculateConnectionStatus()` to `AtmService`
|
||
5. Add POST, PUT, DELETE endpoints to `AtmController`
|
||
|
||
### Phase 3: Frontend Components
|
||
1. Add `create`, `update`, `delete` methods to `atmAPI` in `api.ts`
|
||
2. Create `AtmList.svelte` component with connection status display
|
||
3. Create `CreateAtm.svelte` form component
|
||
4. Create `AtmManagement.svelte` container component
|
||
5. Create `index.ts` export file
|
||
|
||
### Phase 4: Frontend Navigation
|
||
1. Add state variables and tab configuration to `App.svelte`
|
||
2. Add import for `AtmManagement` component
|
||
3. Add menu section between ATM Properties and Fleet Management
|
||
4. Add route handler in main content section
|
||
|
||
## Critical Files
|
||
|
||
**Backend:**
|
||
- `backend/src/main/java/com/hiveops/incident/service/AtmService.java`
|
||
- `backend/src/main/java/com/hiveops/incident/service/JournalEventService.java`
|
||
- `backend/src/main/java/com/hiveops/incident/controller/AtmController.java`
|
||
- `backend/src/main/java/com/hiveops/incident/dto/CreateJournalEventRequest.java`
|
||
- `backend/src/main/java/com/hiveops/incident/dto/AtmDTO.java`
|
||
|
||
**Frontend:**
|
||
- `frontend/src/App.svelte`
|
||
- `frontend/src/lib/api.ts`
|
||
- `frontend/src/components/AtmManagement/` (new directory)
|
||
|
||
## Verification
|
||
|
||
### Test Auto-Registration
|
||
```bash
|
||
# 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
|
||
1. Navigate to ATM Management > Add ATM
|
||
2. Fill form with: atmId="ATM-002", location="New York", address="123 Main St", model="Hyosung 2700"
|
||
3. Submit and verify ATM appears in list
|
||
4. Check connection status shows "Never Connected" (gray)
|
||
|
||
### Test Connection Status
|
||
1. Send journal event from existing ATM
|
||
2. Refresh ATM list
|
||
3. Verify connection status changes to "Connected" (green)
|
||
4. Wait 6 minutes
|
||
5. Verify status changes to "Disconnected" (red)
|
||
|
||
### Test Edit/Delete
|
||
1. Click edit on an ATM
|
||
2. Modify location and address
|
||
3. Save and verify changes
|
||
4. Click delete
|
||
5. Verify ATM status changes to INACTIVE
|