directlx-claude-config/plans/shimmying-prancing-puffin.md

516 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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