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

17 KiB
Raw Blame History

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:

@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

  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

# 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