Skip to content

Integration Architecture & API Catalog

Overview

Pomp's Dispatch Center Application operates as a central hub that orchestrates data and events across multiple integrated systems. This document defines the logical integration architecture, API catalog, data governance model, event-driven patterns, and synchronization strategies that enable seamless interoperability between Pomp's internal platform and external systems.

Integration Partners

System Role Integration Type Data Ownership
MaddenCo ERP Enterprise Resource Planning - Billing, Invoicing, Work Orders Proxy API (Pomp's-provided) Source of Truth for Billing/Invoicing
REACH Portal Customer dispatch portal for work order submissions Proxy API (Pomp's-provided) Customer work order intake
Genesys Cloud Communication platform for SMS, phone calls, IVR Proxy API (Pomp's-provided) Alert delivery (SMS, Voice)
Dayforce HR/Payroll system for employee data Proxy API (Pomp's-provided) Employee/Technician master data
GeoTab Vehicle GPS tracking REST API (streaming) Real-time technician location
Azure Services Cloud infrastructure (Service Bus, Blob Storage, etc.) Native SDKs Event processing, file storage
Azure OpenAI AI/ML services for smart assignment, content generation Native SDK Intelligent automation
Azure AI Vision Computer vision for photo analysis Native SDK Photo analysis, damage assessment
Azure Bot Service Conversational AI for customer/tech interactions Native SDK Bot conversations via Genesys

Integration Philosophy

  • Event-Driven First: Use events and messages for loose coupling and real-time updates
  • Eventual Consistency: Accept temporary inconsistencies, reconcile regularly
  • Graceful Degradation: Continue operating when integration partners are unavailable
  • Idempotency: All operations safe to retry without side effects
  • Audit Everything: Log all integration activities for compliance and debugging
  • Proxy Layer: Abstract external system complexity behind Pomp's-controlled APIs

Table of Contents


Data Governance & Source of Truth

Data Domain Ownership

Each data domain has a designated Source of Truth (SoT) system. All other systems maintain synchronized copies or caches of this data.

Data Domain Source of Truth Why Secondary Systems Sync Direction
Billing & Invoicing MaddenCo ERP Financial system of record, regulatory compliance Pomp's DB (cache), REACH (read-only) MaddenCo → Pomp's → REACH
Work Orders (Financial) MaddenCo ERP Work order cost, labor, parts for billing Pomp's DB (operational copy) MaddenCo ↔ Pomp's
Service Requests (Dispatch) Pomp's Dispatch Platform Central dispatch creates, assigns, tracks REACH (mirror), MaddenCo (invoice source) Pomp's → REACH, Pomp's → MaddenCo
Technician Data Dayforce HR Employee master data, certifications, payroll Pomp's DB (operational), MaddenCo (reference) Dayforce → Pomp's → MaddenCo
Customer Master Data MaddenCo ERP Customer billing, contracts, terms Pomp's DB (cache), REACH (display) MaddenCo → Pomp's → REACH
Customer Contact Info REACH or Pomp's CRM Contact preferences, communication history MaddenCo (billing contact) REACH/Pomp's → MaddenCo
Dispatch Events & Status Pomp's Dispatch Platform Real-time technician status, assignments REACH (mirror), Mobile App (cache) Pomp's → REACH, Pomp's → Mobile
Photos & Documentation Pomp's Azure Blob Storage Technician photos, service documentation MaddenCo (invoice attachments), REACH (optional) Pomp's → MaddenCo, Pomp's → REACH
Inventory & Parts MaddenCo ERP Parts catalog, pricing, stock levels Pomp's DB (real-time cache) MaddenCo → Pomp's
Vehicle Location GeoTab Real-time GPS tracking Pomp's DB (current location only) GeoTab → Pomp's
Alerts & Notifications Pomp's Dispatch Platform Alert generation and tracking Genesys Cloud (delivery) Pomp's → Genesys Cloud

Data Conflict Resolution

When the same data exists in multiple systems, conflicts are resolved based on Source of Truth:

Conflict Scenario Resolution Example
Customer billing info differs between Pomp's and MaddenCo MaddenCo wins MaddenCo has updated terms; Pomp's cache is stale
Work order status differs between Pomp's and REACH Pomp's wins Pomp's is dispatch source of truth; REACH is mirror
Technician availability differs Pomp's wins Real-time schedule managed in Pomp's
Invoice total differs MaddenCo wins MaddenCo is financial SoT

Logical Integration Architecture

High-Level Architecture Diagram

flowchart TB
    subgraph pomps["POMP'S DISPATCH PLATFORM"]
        dispatchdb["Dispatch DB<br/>(Azure SQL)"]
        servicebus["Service Bus<br/>(Events)"]
        blobstorage["Blob Storage<br/>(Photos/Docs)"]
    end

    subgraph proxies["Proxy Layer"]
        maddenco_proxy["MADDENCO PROXY API<br/><br/>• Rate Limiting<br/>• Caching<br/>• Transform<br/>• Auth"]
        reach_proxy["REACH PROXY API<br/><br/>• Polling Svc<br/>• Push Sync<br/>• Webhook Recv<br/>• Transform"]
        genesys_proxy["GENESYS CLOUD PROXY API<br/><br/>• SMS Delivery<br/>• Voice Calls<br/>• IVR Scripts<br/>• Status Track"]
    end

    subgraph external["External Systems"]
        maddenco_erp["MADDENCO ERP<br/><br/>• Invoices<br/>• Work Orders<br/>• Customers<br/>• Parts/Inventory<br/>• Billing"]
        reach_portal["REACH PORTAL<br/><br/>• Work Orders<br/>• Comments<br/>• Customer Data<br/>• Status Updates<br/>• Attachments"]
        genesys_cloud["GENESYS CLOUD<br/><br/>• SMS Queue<br/>• Voice Queue<br/>• IVR Engine<br/>• Recording<br/>• Analytics"]
    end

    subgraph additional["Additional Integrations"]
        dayforce_proxy["DAYFORCE PROXY API<br/>(Pomp's-provided)<br/><br/>• Rate Limiting<br/>• Transform<br/>• Auth"]
        geotab["GEOTAB<br/><br/>• Vehicle GPS<br/>• Location Stream<br/>• Trip History"]
        dayforce_hr["DAYFORCE HR<br/><br/>• Employees<br/>• Certifications<br/>• Payroll"]
    end

    pomps --> maddenco_proxy
    pomps --> reach_proxy
    pomps --> genesys_proxy

    maddenco_proxy --> maddenco_erp
    reach_proxy --> reach_portal
    genesys_proxy --> genesys_cloud

    dayforce_proxy --> dayforce_hr

Integration Data Flow

INBOUND (External → Pomp's)

flowchart TB
    reach_portal["REACH Portal"]
    reach_proxy_api["Reach Proxy API"]
    pomps_service_bus["Pomp's Service Bus"]
    wo_event["WorkOrderCreated<br/>Event"]
    alert_service["Alert Service"]
    dispatch_db["Dispatch DB"]
    reach_sync["REACH Sync"]
    genesys_cloud_in["Genesys Cloud<br/>(SMS/Voice)"]
    reach_portal_update["REACH Portal<br/>(Status Update)"]

    reach_portal -->|Poll every 30s| reach_proxy_api
    reach_proxy_api --> pomps_service_bus
    pomps_service_bus --> wo_event
    wo_event --> alert_service
    wo_event --> dispatch_db
    wo_event --> reach_sync
    alert_service --> genesys_cloud_in
    reach_sync --> reach_portal_update

OUTBOUND (Pomp's → External)

flowchart TB
    pomps_platform["Pomp's Platform"]
    service_bus_out["Service Bus"]
    maddenco_sync["MaddenCo Sync Service"]
    maddenco_proxy_api["MaddenCo Proxy API"]
    maddenco_erp_out["MaddenCo ERP<br/>(Invoice Created)"]

    pomps_platform -->|Event| service_bus_out
    service_bus_out --> maddenco_sync
    maddenco_sync --> maddenco_proxy_api
    maddenco_proxy_api --> maddenco_erp_out

Integration Patterns

Pattern Catalog

Pattern Description Use Case Technology Frequency
Polling Periodically query external system for new data Pull new work orders from REACH Timer + REST API Every 30 seconds
Webhook (Inbound) Receive real-time events from external system REACH work order status callbacks (if supported) HTTP POST endpoint Event-driven
Webhook (Outbound) Push events to external system in real-time Push status updates to REACH HTTP POST Event-driven
Batch Sync Bulk data synchronization during off-peak hours Invoice reconciliation with MaddenCo Azure Data Factory Nightly (2 AM)
Request-Reply Synchronous API call for immediate response Lookup customer in MaddenCo REST API On-demand
Event Sourcing Publish events to message bus for subscribers Technician status change notification Azure Service Bus Event-driven
Saga/Orchestration Coordinate multi-step transactions across systems Work order → Invoice → Billing flow Service Bus + Durable Functions Event-driven
Cache-Aside Cache frequently accessed external data locally Customer and parts data from MaddenCo Redis Cache On-demand with TTL

Polling vs Event-Driven Decision Matrix

Scenario Polling Event-Driven Recommendation
New work orders from REACH ✅ (if no webhooks) ✅ (preferred) Event-driven if available, else poll every 30s
Technician status updates to REACH Push immediately via webhook
Invoice data from MaddenCo ✅ (batch nightly) ❌ (MaddenCo likely doesn't support) Batch sync + on-demand lookup
Customer data changes ✅ (batch nightly) ✅ (if MaddenCo supports) Batch sync with cache
Alert delivery to Genesys Event-driven (immediate)

MaddenCo ERP Integration

Overview

MaddenCo is the Source of Truth for Billing, Invoicing, Work Orders, and Customer Financial Data. Pomp's provides a Proxy API Layer that wraps MaddenCo's native API to normalize data formats, handle authentication, implement caching, and provide resilience.

MaddenCo Proxy API Architecture

flowchart TB
    proxy["MADDENCO PROXY API LAYER<br/>(Pomp's-Provided Abstraction)<br/><br/>• Auth Handler<br/>• Rate Limiter<br/>• Caching (Redis)<br/>• Data Transform<br/>• Error Handler<br/>• Audit Logger<br/>• Retry Policy<br/>• Circuit Breaker<br/>• Health Check"]

    maddenco["MADDENCO ERP API<br/>(Native REST/SOAP)"]

    proxy --> maddenco

MaddenCo Proxy API Endpoints

Invoice Management

Endpoint Method Purpose Request Response
/api/proxy/maddenco/invoices GET List invoices ?startDate=&endDate=&status= Invoice[]
/api/proxy/maddenco/invoices/{id} GET Get invoice details Path param: id Invoice
/api/proxy/maddenco/invoices POST Create invoice from work order CreateInvoiceRequest Invoice
/api/proxy/maddenco/invoices/{id} PATCH Update invoice UpdateInvoiceRequest Invoice
/api/proxy/maddenco/invoices/{id}/finalize POST Finalize for billing Path param: id Invoice
/api/proxy/maddenco/invoices/{id}/attachments POST Attach photos/docs multipart/form-data Attachment[]

Work Order Management

Endpoint Method Purpose Request Response
/api/proxy/maddenco/work-orders GET List work orders ?storeId=&status=&date= WorkOrder[]
/api/proxy/maddenco/work-orders/{id} GET Get work order details Path param: id WorkOrder
/api/proxy/maddenco/work-orders POST Create work order CreateWorkOrderRequest WorkOrder
/api/proxy/maddenco/work-orders/{id} PATCH Update work order UpdateWorkOrderRequest WorkOrder
/api/proxy/maddenco/work-orders/{id}/complete POST Mark complete CompleteWorkOrderRequest WorkOrder

Customer Management

Endpoint Method Purpose Request Response
/api/proxy/maddenco/customers GET Search customers ?query=&accountNumber= Customer[]
/api/proxy/maddenco/customers/{id} GET Get customer details Path param: id Customer
/api/proxy/maddenco/customers/{id}/billing GET Get billing info Path param: id BillingInfo
/api/proxy/maddenco/customers/{id}/contracts GET Get contract terms Path param: id Contract[]

Parts & Inventory

Endpoint Method Purpose Request Response
/api/proxy/maddenco/parts GET Search parts catalog ?query=&sku=&category= Part[]
/api/proxy/maddenco/parts/{sku} GET Get part details Path param: sku Part
/api/proxy/maddenco/inventory GET Get inventory levels ?storeId=&sku= InventoryLevel[]
/api/proxy/maddenco/inventory/{storeId}/{sku} GET Get specific stock Path params InventoryLevel

MaddenCo Data Models

// Invoice model (mapped from MaddenCo)
public class MaddenCoInvoice
{
    public string InvoiceId { get; set; }
    public string WorkOrderId { get; set; }
    public string CustomerId { get; set; }
    public string CustomerName { get; set; }
    public string StoreId { get; set; }
    public DateTime InvoiceDate { get; set; }
    public DateTime? DueDate { get; set; }
    public decimal Subtotal { get; set; }
    public decimal TaxAmount { get; set; }
    public decimal TotalAmount { get; set; }
    public string Status { get; set; } // Draft, Pending, Finalized, Paid, Voided
    public List<InvoiceLineItem> LineItems { get; set; }
    public List<string> AttachmentUrls { get; set; }
    public string MaddenCoReference { get; set; } // Original MaddenCo ID
}

// Work Order model (mapped from MaddenCo)
public class MaddenCoWorkOrder
{
    public string WorkOrderId { get; set; }
    public string CustomerId { get; set; }
    public string StoreId { get; set; }
    public string TechnicianId { get; set; }
    public string ServiceType { get; set; }
    public string Description { get; set; }
    public DateTime ScheduledDate { get; set; }
    public DateTime? CompletedDate { get; set; }
    public string Status { get; set; } // Open, InProgress, Completed, Cancelled
    public decimal LaborCost { get; set; }
    public decimal PartsCost { get; set; }
    public decimal TotalCost { get; set; }
    public List<WorkOrderPart> Parts { get; set; }
    public string MaddenCoReference { get; set; }
}

// Customer model (mapped from MaddenCo)
public class MaddenCoCustomer
{
    public string CustomerId { get; set; }
    public string AccountNumber { get; set; }
    public string CompanyName { get; set; }
    public string ContactName { get; set; }
    public string Email { get; set; }
    public string Phone { get; set; }
    public Address BillingAddress { get; set; }
    public Address ServiceAddress { get; set; }
    public string PaymentTerms { get; set; } // Net30, Net60, COD, etc.
    public decimal CreditLimit { get; set; }
    public string TaxExemptStatus { get; set; }
    public string MaddenCoReference { get; set; }
}

MaddenCo Sync Service

// Service for synchronizing data with MaddenCo
public class MaddenCoSyncService : IMaddenCoSyncService
{
    private readonly IMaddenCoProxyClient _proxyClient;
    private readonly IServiceRequestRepository _serviceRequestRepo;
    private readonly IInvoiceRepository _invoiceRepo;
    private readonly IEventPublisher _eventPublisher;
    private readonly IAuditLogger _auditLogger;

    // Push completed work order to MaddenCo for invoicing
    public async Task<MaddenCoInvoice> CreateInvoiceFromWorkOrderAsync(
        int workOrderId)
    {
        var workOrder = await _serviceRequestRepo.GetByIdAsync(workOrderId);

        // Transform Pomp's work order to MaddenCo format
        var createRequest = new CreateInvoiceRequest
        {
            CustomerId = workOrder.Customer.MaddenCoId,
            WorkOrderReference = workOrder.Id.ToString(),
            StoreId = workOrder.Store.MaddenCoId,
            ServiceDate = workOrder.CompletedDate.Value,
            LineItems = workOrder.LineItems.Select(li => new InvoiceLineItem
            {
                Description = li.Description,
                Quantity = li.Quantity,
                UnitPrice = li.UnitPrice,
                PartSku = li.PartSku,
                LaborHours = li.LaborHours
            }).ToList(),
            Attachments = workOrder.Photos.Select(p => p.BlobUrl).ToList()
        };

        // Call MaddenCo Proxy API
        var invoice = await _proxyClient.CreateInvoiceAsync(createRequest);

        // Update local invoice record with MaddenCo reference
        await _invoiceRepo.UpdateMaddenCoReferenceAsync(
            workOrderId, 
            invoice.MaddenCoReference);

        // Publish event for downstream systems
        await _eventPublisher.PublishAsync(new InvoiceCreatedInMaddenCoEvent
        {
            WorkOrderId = workOrderId,
            InvoiceId = invoice.InvoiceId,
            MaddenCoReference = invoice.MaddenCoReference,
            TotalAmount = invoice.TotalAmount
        });

        // Audit log
        await _auditLogger.LogAsync("MaddenCo.InvoiceCreated", 
            workOrderId, 
            invoice.InvoiceId);

        return invoice;
    }

    // Pull customer data from MaddenCo and cache locally
    public async Task<MaddenCoCustomer> GetCustomerAsync(string customerId)
    {
        // Check cache first
        var cached = await _cache.GetAsync<MaddenCoCustomer>(
            $"maddenco:customer:{customerId}");

        if (cached != null)
            return cached;

        // Fetch from MaddenCo via Proxy
        var customer = await _proxyClient.GetCustomerAsync(customerId);

        // Cache for 1 hour
        await _cache.SetAsync(
            $"maddenco:customer:{customerId}", 
            customer, 
            TimeSpan.FromHours(1));

        return customer;
    }

    // Nightly batch reconciliation
    public async Task ReconcileInvoicesAsync(DateTime date)
    {
        // Get all invoices from MaddenCo for date
        var maddenCoInvoices = await _proxyClient.GetInvoicesAsync(
            startDate: date,
            endDate: date.AddDays(1));

        // Get all invoices from Pomp's for date
        var pompsInvoices = await _invoiceRepo.GetByDateAsync(date);

        // Find discrepancies
        var discrepancies = new List<InvoiceDiscrepancy>();

        foreach (var pompsInvoice in pompsInvoices)
        {
            var maddenCoInvoice = maddenCoInvoices
                .FirstOrDefault(m => m.WorkOrderId == pompsInvoice.WorkOrderId);

            if (maddenCoInvoice == null)
            {
                discrepancies.Add(new InvoiceDiscrepancy
                {
                    Type = "MissingInMaddenCo",
                    PompsInvoiceId = pompsInvoice.Id,
                    WorkOrderId = pompsInvoice.WorkOrderId
                });
            }
            else if (maddenCoInvoice.TotalAmount != pompsInvoice.TotalAmount)
            {
                discrepancies.Add(new InvoiceDiscrepancy
                {
                    Type = "AmountMismatch",
                    PompsInvoiceId = pompsInvoice.Id,
                    MaddenCoInvoiceId = maddenCoInvoice.InvoiceId,
                    PompsAmount = pompsInvoice.TotalAmount,
                    MaddenCoAmount = maddenCoInvoice.TotalAmount
                });
            }
        }

        // Log and alert on discrepancies
        if (discrepancies.Any())
        {
            await _auditLogger.LogAsync("MaddenCo.ReconciliationDiscrepancies", 
                discrepancies.Count, 
                JsonSerializer.Serialize(discrepancies));

            await _alertService.SendAlertAsync(new DispatchAlert
            {
                AlertType = "RECONCILIATION_DISCREPANCY",
                Title = $"MaddenCo Invoice Discrepancies: {discrepancies.Count}",
                Message = $"Found {discrepancies.Count} invoice discrepancies for {date:yyyy-MM-dd}",
                Priority = AlertPriority.High,
                Recipients = new[] { "billing-admin@pomps.com" }
            });
        }
    }
}

MaddenCo Proxy Client Implementation

// HTTP client for MaddenCo Proxy API
public class MaddenCoProxyClient : IMaddenCoProxyClient
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<MaddenCoProxyClient> _logger;
    private readonly MaddenCoProxyOptions _options;

    public MaddenCoProxyClient(
        HttpClient httpClient,
        IOptions<MaddenCoProxyOptions> options,
        ILogger<MaddenCoProxyClient> logger)
    {
        _httpClient = httpClient;
        _options = options.Value;
        _logger = logger;

        _httpClient.BaseAddress = new Uri(_options.BaseUrl);
        _httpClient.DefaultRequestHeaders.Add("X-Api-Key", _options.ApiKey);
    }

    public async Task<MaddenCoInvoice> CreateInvoiceAsync(
        CreateInvoiceRequest request)
    {
        var response = await _httpClient.PostAsJsonAsync(
            "/api/proxy/maddenco/invoices", 
            request);

        if (!response.IsSuccessStatusCode)
        {
            var error = await response.Content.ReadAsStringAsync();
            _logger.LogError("MaddenCo CreateInvoice failed: {StatusCode} - {Error}",
                response.StatusCode, error);

            throw new MaddenCoIntegrationException(
                $"Failed to create invoice: {response.StatusCode}",
                response.StatusCode);
        }

        return await response.Content.ReadFromJsonAsync<MaddenCoInvoice>();
    }

    public async Task<MaddenCoCustomer> GetCustomerAsync(string customerId)
    {
        var response = await _httpClient.GetAsync(
            $"/api/proxy/maddenco/customers/{customerId}");

        if (response.StatusCode == HttpStatusCode.NotFound)
            return null;

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<MaddenCoCustomer>();
    }

    public async Task<List<MaddenCoInvoice>> GetInvoicesAsync(
        DateTime startDate, 
        DateTime endDate)
    {
        var response = await _httpClient.GetAsync(
            $"/api/proxy/maddenco/invoices?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}");

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<List<MaddenCoInvoice>>();
    }
}

// Startup configuration
services.AddHttpClient<IMaddenCoProxyClient, MaddenCoProxyClient>()
    .AddPolicyHandler(GetRetryPolicy())
    .AddPolicyHandler(GetCircuitBreakerPolicy());

static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(3, retryAttempt => 
            TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}

static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
}

REACH Portal Integration

Overview

REACH is a third-party dispatch portal where many customers submit work order requests. Pomp's must:

  1. Poll REACH for new work orders, comments, and updates
  2. Push to REACH dispatch status, technician assignments, completion, and photos
  3. Maintain Pomp's as Source of Truth for dispatch operations

REACH Integration Architecture

flowchart TB
    subgraph reach_layer["REACH Integration Layer"]
        polling["REACH Polling Service<br/><br/>• Every 30 sec<br/>• New orders<br/>• Comments<br/>• Cancellations"]
        push["REACH Push Service<br/><br/>• Event-driven<br/>• Status update<br/>• Photos<br/>• Completion"]

        proxy["REACH Proxy API Client<br/><br/>• Authentication (OAuth 2.0)<br/>• Rate Limiting (respect REACH limits)<br/>• Error Handling & Retry<br/>• Data Transformation (REACH ↔ Pomp's)<br/>• Audit Logging"]

        polling --> proxy
        push --> proxy
    end

    reach_api["REACH PORTAL API<br/><br/>Base URL: https://api.reach.com/v2<br/>Auth: OAuth 2.0 (Client Credentials)<br/>Rate Limit: 100 requests/minute"]

    reach_layer --> reach_api

REACH Proxy API Endpoints (Pomp's Wrapper)

Inbound (REACH → Pomp's)

Endpoint Method Purpose Polling Frequency
/api/proxy/reach/work-orders/pending GET Get new/pending work orders Every 30 seconds
/api/proxy/reach/work-orders/{id} GET Get work order details On-demand
/api/proxy/reach/work-orders/{id}/comments GET Get comments on work order Every 30 seconds
/api/proxy/reach/work-orders/{id}/history GET Get change history On-demand
/api/proxy/reach/customers/{id} GET Get customer info On-demand

Outbound (Pomp's → REACH)

Endpoint Method Purpose Trigger
/api/proxy/reach/work-orders/{id}/assign POST Notify assignment Event: WorkAssigned
/api/proxy/reach/work-orders/{id}/status PATCH Update status Event: StatusChanged
/api/proxy/reach/work-orders/{id}/eta PATCH Update technician ETA Event: TechnicianEnRoute
/api/proxy/reach/work-orders/{id}/complete POST Mark complete Event: WorkCompleted
/api/proxy/reach/work-orders/{id}/photos POST Upload completion photos Event: PhotosUploaded
/api/proxy/reach/work-orders/{id}/invoice POST Attach invoice info Event: InvoiceGenerated

REACH Data Models

// REACH work order (inbound)
public class ReachWorkOrder
{
    public string ReachOrderId { get; set; }
    public string CustomerAccountNumber { get; set; }
    public string CustomerName { get; set; }
    public string ContactPhone { get; set; }
    public string ContactEmail { get; set; }
    public Address ServiceLocation { get; set; }
    public string ServiceType { get; set; }
    public string Description { get; set; }
    public string Priority { get; set; } // Standard, Urgent, Emergency
    public DateTime RequestedDate { get; set; }
    public DateTime? RequestedTimeWindow { get; set; }
    public string Status { get; set; } // Pending, Assigned, InProgress, Completed, Cancelled
    public List<ReachComment> Comments { get; set; }
    public List<string> AttachmentUrls { get; set; }
    public DateTime CreatedDate { get; set; }
    public DateTime LastModifiedDate { get; set; }
}

// REACH comment (inbound)
public class ReachComment
{
    public string CommentId { get; set; }
    public string Author { get; set; }
    public string AuthorType { get; set; } // Customer, Dispatcher, Technician
    public string Text { get; set; }
    public DateTime CreatedDate { get; set; }
}

// Status update (outbound to REACH)
public class ReachStatusUpdate
{
    public string Status { get; set; }
    public string TechnicianName { get; set; }
    public string TechnicianPhone { get; set; }
    public DateTime? EstimatedArrival { get; set; }
    public string Notes { get; set; }
    public DateTime UpdatedAt { get; set; }
}

// Completion payload (outbound to REACH)
public class ReachCompletionPayload
{
    public DateTime CompletedAt { get; set; }
    public string TechnicianName { get; set; }
    public string CompletionNotes { get; set; }
    public List<string> PhotoUrls { get; set; }
    public string CustomerSignatureUrl { get; set; }
    public decimal? LaborHours { get; set; }
    public string InvoiceReference { get; set; }
}

REACH Polling Service

// Background service that polls REACH for new work orders
public class ReachPollingService : BackgroundService
{
    private readonly IReachProxyClient _reachClient;
    private readonly IServiceRequestService _serviceRequestService;
    private readonly IEventPublisher _eventPublisher;
    private readonly IAlertService _alertService;
    private readonly ILogger<ReachPollingService> _logger;
    private readonly ReachPollingOptions _options;

    private DateTime _lastPollTime = DateTime.UtcNow.AddMinutes(-5);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("REACH Polling Service started");

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await PollForNewWorkOrdersAsync();
                await PollForCommentsAsync();
                await PollForCancellationsAsync();

                _lastPollTime = DateTime.UtcNow;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "REACH Polling failed");

                // Alert on repeated failures
                await _alertService.SendAlertAsync(new DispatchAlert
                {
                    AlertType = "REACH_POLLING_ERROR",
                    Title = "REACH Polling Failed",
                    Message = $"Error polling REACH: {ex.Message}",
                    Priority = AlertPriority.High
                });
            }

            await Task.Delay(
                TimeSpan.FromSeconds(_options.PollingIntervalSeconds), 
                stoppingToken);
        }
    }

    private async Task PollForNewWorkOrdersAsync()
    {
        // Get new work orders since last poll
        var newOrders = await _reachClient.GetPendingWorkOrdersAsync(
            since: _lastPollTime);

        _logger.LogInformation("Polled REACH: found {Count} new work orders", 
            newOrders.Count);

        foreach (var reachOrder in newOrders)
        {
            // Check if we already have this order (idempotency)
            var existing = await _serviceRequestService
                .GetByReachIdAsync(reachOrder.ReachOrderId);

            if (existing != null)
            {
                _logger.LogDebug("Work order {ReachId} already exists, skipping",
                    reachOrder.ReachOrderId);
                continue;
            }

            // Transform REACH order to Pomp's service request
            var serviceRequest = MapReachOrderToServiceRequest(reachOrder);

            // Create in Pomp's database
            var created = await _serviceRequestService.CreateAsync(serviceRequest);

            // Publish event for alert service and other subscribers
            await _eventPublisher.PublishAsync(new WorkOrderCreatedEvent
            {
                ServiceRequestId = created.Id,
                ReachOrderId = reachOrder.ReachOrderId,
                CustomerName = reachOrder.CustomerName,
                ServiceLocation = reachOrder.ServiceLocation,
                Priority = MapPriority(reachOrder.Priority),
                Source = "REACH"
            });

            _logger.LogInformation(
                "Created service request {Id} from REACH order {ReachId}",
                created.Id, reachOrder.ReachOrderId);
        }
    }

    private async Task PollForCommentsAsync()
    {
        // Get service requests that originated from REACH
        var reachOrders = await _serviceRequestService
            .GetActiveReachOrdersAsync();

        foreach (var order in reachOrders)
        {
            var comments = await _reachClient.GetCommentsAsync(
                order.ReachOrderId,
                since: order.LastCommentPollTime);

            foreach (var comment in comments)
            {
                // Check if comment already exists
                if (await _serviceRequestService.CommentExistsAsync(
                    order.Id, comment.CommentId))
                    continue;

                // Add comment to service request
                await _serviceRequestService.AddCommentAsync(order.Id, new Comment
                {
                    ExternalId = comment.CommentId,
                    Author = comment.Author,
                    AuthorType = comment.AuthorType,
                    Text = comment.Text,
                    CreatedDate = comment.CreatedDate,
                    Source = "REACH"
                });

                // Publish event for alerts
                await _eventPublisher.PublishAsync(new CommentAddedEvent
                {
                    ServiceRequestId = order.Id,
                    CommentId = comment.CommentId,
                    Author = comment.Author,
                    Text = comment.Text,
                    Source = "REACH"
                });
            }

            // Update last poll time for this order
            await _serviceRequestService.UpdateLastCommentPollTimeAsync(
                order.Id, DateTime.UtcNow);
        }
    }

    private async Task PollForCancellationsAsync()
    {
        // Get active REACH orders and check for cancellations
        var reachOrders = await _serviceRequestService
            .GetActiveReachOrdersAsync();

        foreach (var order in reachOrders)
        {
            var reachOrder = await _reachClient.GetWorkOrderAsync(
                order.ReachOrderId);

            if (reachOrder.Status == "Cancelled" && 
                order.Status != ServiceRequestStatus.Cancelled)
            {
                // Cancel in Pomp's
                await _serviceRequestService.CancelAsync(
                    order.Id, 
                    "Cancelled in REACH portal",
                    "REACH");

                // Publish event
                await _eventPublisher.PublishAsync(new WorkOrderCancelledEvent
                {
                    ServiceRequestId = order.Id,
                    ReachOrderId = order.ReachOrderId,
                    Reason = "Cancelled by customer in REACH portal"
                });
            }
        }
    }

    private ServiceRequest MapReachOrderToServiceRequest(ReachWorkOrder reachOrder)
    {
        return new ServiceRequest
        {
            ReachOrderId = reachOrder.ReachOrderId,
            CustomerAccountNumber = reachOrder.CustomerAccountNumber,
            CustomerName = reachOrder.CustomerName,
            ContactPhone = reachOrder.ContactPhone,
            ContactEmail = reachOrder.ContactEmail,
            ServiceAddress = reachOrder.ServiceLocation,
            ServiceType = reachOrder.ServiceType,
            Description = reachOrder.Description,
            Priority = MapPriority(reachOrder.Priority),
            RequestedDate = reachOrder.RequestedDate,
            RequestedTimeWindow = reachOrder.RequestedTimeWindow,
            Status = ServiceRequestStatus.Pending,
            Source = "REACH",
            CreatedDate = DateTime.UtcNow
        };
    }

    private ServiceRequestPriority MapPriority(string reachPriority)
    {
        return reachPriority switch
        {
            "Emergency" => ServiceRequestPriority.Critical,
            "Urgent" => ServiceRequestPriority.High,
            "Standard" => ServiceRequestPriority.Medium,
            _ => ServiceRequestPriority.Low
        };
    }
}

REACH Push Service

// Service to push updates to REACH
public class ReachPushService : IReachPushService
{
    private readonly IReachProxyClient _reachClient;
    private readonly ILogger<ReachPushService> _logger;

    // Push technician assignment to REACH
    public async Task PushAssignmentAsync(
        string reachOrderId,
        TechnicianAssignment assignment)
    {
        var payload = new ReachStatusUpdate
        {
            Status = "Assigned",
            TechnicianName = assignment.TechnicianName,
            TechnicianPhone = assignment.TechnicianPhone,
            EstimatedArrival = assignment.EstimatedArrival,
            Notes = $"Technician {assignment.TechnicianName} assigned",
            UpdatedAt = DateTime.UtcNow
        };

        await _reachClient.UpdateStatusAsync(reachOrderId, payload);

        _logger.LogInformation(
            "Pushed assignment to REACH: {ReachId}, Tech: {Tech}",
            reachOrderId, assignment.TechnicianName);
    }

    // Push status update to REACH
    public async Task PushStatusUpdateAsync(
        string reachOrderId,
        string status,
        string technicianName,
        DateTime? eta = null)
    {
        var reachStatus = MapStatusToReach(status);

        var payload = new ReachStatusUpdate
        {
            Status = reachStatus,
            TechnicianName = technicianName,
            EstimatedArrival = eta,
            UpdatedAt = DateTime.UtcNow
        };

        await _reachClient.UpdateStatusAsync(reachOrderId, payload);

        _logger.LogInformation(
            "Pushed status to REACH: {ReachId}, Status: {Status}",
            reachOrderId, reachStatus);
    }

    // Push completion to REACH
    public async Task PushCompletionAsync(
        string reachOrderId,
        WorkOrderCompletion completion)
    {
        var payload = new ReachCompletionPayload
        {
            CompletedAt = completion.CompletedAt,
            TechnicianName = completion.TechnicianName,
            CompletionNotes = completion.Notes,
            PhotoUrls = completion.PhotoUrls,
            CustomerSignatureUrl = completion.SignatureUrl,
            LaborHours = completion.LaborHours,
            InvoiceReference = completion.InvoiceReference
        };

        await _reachClient.CompleteWorkOrderAsync(reachOrderId, payload);

        _logger.LogInformation(
            "Pushed completion to REACH: {ReachId}",
            reachOrderId);
    }

    // Push photos to REACH
    public async Task PushPhotosAsync(
        string reachOrderId,
        List<string> photoUrls)
    {
        await _reachClient.UploadPhotosAsync(reachOrderId, photoUrls);

        _logger.LogInformation(
            "Pushed {Count} photos to REACH: {ReachId}",
            photoUrls.Count, reachOrderId);
    }

    private string MapStatusToReach(string pompsStatus)
    {
        return pompsStatus switch
        {
            "Assigned" => "Assigned",
            "EnRoute" => "InProgress",
            "Arrived" => "InProgress",
            "WorkInProgress" => "InProgress",
            "Completed" => "Completed",
            "Cancelled" => "Cancelled",
            _ => "Pending"
        };
    }
}

// Event handler to trigger REACH push on status changes
public class ReachSyncEventHandler : 
    IEventHandler<WorkAssignedEvent>,
    IEventHandler<TechnicianStatusChangedEvent>,
    IEventHandler<WorkCompletedEvent>,
    IEventHandler<PhotosUploadedEvent>
{
    private readonly IReachPushService _reachPushService;
    private readonly IServiceRequestRepository _serviceRequestRepo;

    public async Task HandleAsync(WorkAssignedEvent @event)
    {
        var serviceRequest = await _serviceRequestRepo
            .GetByIdAsync(@event.ServiceRequestId);

        // Only push if originated from REACH
        if (string.IsNullOrEmpty(serviceRequest.ReachOrderId))
            return;

        await _reachPushService.PushAssignmentAsync(
            serviceRequest.ReachOrderId,
            new TechnicianAssignment
            {
                TechnicianName = @event.TechnicianName,
                TechnicianPhone = @event.TechnicianPhone,
                EstimatedArrival = @event.EstimatedArrival
            });
    }

    public async Task HandleAsync(TechnicianStatusChangedEvent @event)
    {
        var serviceRequest = await _serviceRequestRepo
            .GetByIdAsync(@event.ServiceRequestId);

        if (string.IsNullOrEmpty(serviceRequest.ReachOrderId))
            return;

        await _reachPushService.PushStatusUpdateAsync(
            serviceRequest.ReachOrderId,
            @event.NewStatus,
            @event.TechnicianName,
            @event.EstimatedArrival);
    }

    public async Task HandleAsync(WorkCompletedEvent @event)
    {
        var serviceRequest = await _serviceRequestRepo
            .GetByIdAsync(@event.ServiceRequestId);

        if (string.IsNullOrEmpty(serviceRequest.ReachOrderId))
            return;

        await _reachPushService.PushCompletionAsync(
            serviceRequest.ReachOrderId,
            new WorkOrderCompletion
            {
                CompletedAt = @event.CompletedAt,
                TechnicianName = @event.TechnicianName,
                Notes = @event.CompletionNotes,
                PhotoUrls = @event.PhotoUrls,
                SignatureUrl = @event.SignatureUrl,
                LaborHours = @event.LaborHours,
                InvoiceReference = @event.InvoiceReference
            });
    }

    public async Task HandleAsync(PhotosUploadedEvent @event)
    {
        var serviceRequest = await _serviceRequestRepo
            .GetByIdAsync(@event.ServiceRequestId);

        if (string.IsNullOrEmpty(serviceRequest.ReachOrderId))
            return;

        await _reachPushService.PushPhotosAsync(
            serviceRequest.ReachOrderId,
            @event.PhotoUrls);
    }
}

Genesys Cloud Integration

Overview

Genesys Cloud is the communication platform for delivering SMS and Voice (phone call) alerts to dispatchers, technicians, store managers, and customers. All dispatch alerts that require SMS or phone delivery are routed through Genesys Cloud.

Genesys Cloud Integration Architecture

flowchart TB
    subgraph genesys_integration["GENESYS CLOUD INTEGRATION"]
        alert_service["Pomp's Alert Service<br/><br/>• Generate alert from dispatch event<br/>• Determine recipient channels (Web, SMS, Voice)<br/>• Format message per channel<br/>• Route to appropriate delivery service"]

        web_push["Web Push<br/>Service"]
        sms_service["Genesys SMS<br/>Service"]
        voice_service["Genesys Voice<br/>Service"]

        proxy["Genesys Cloud Proxy API<br/><br/>• OAuth 2.0 Authentication<br/>• Rate Limiting (100 SMS/sec)<br/>• Retry & Circuit Breaker<br/>• Delivery Status Tracking<br/>• Webhook Callback Processing"]

        alert_service --> web_push
        alert_service --> sms_service
        alert_service --> voice_service

        sms_service --> proxy
        voice_service --> proxy
    end

    subgraph genesys_platform["GENESYS CLOUD PLATFORM"]
        sms_gateway["SMS Gateway<br/><br/>• Outbound SMS<br/>• Delivery Tracking<br/>• Opt-Out Mgmt"]
        voice_ivr["Voice/IVR<br/><br/>• Outbound Call<br/>• IVR Script<br/>• DTMF Input<br/>• Recording"]
    end

    genesys_integration --> genesys_platform

Genesys Cloud Proxy API Endpoints

Endpoint Method Purpose Request Response
/api/proxy/genesys/sms/send POST Send SMS message SendSMSRequest SMSDeliveryResult
/api/proxy/genesys/sms/status/{messageId} GET Get SMS delivery status Path param SMSStatus
/api/proxy/genesys/sms/opt-out/{phoneNumber} GET Check opt-out status Path param OptOutStatus
/api/proxy/genesys/voice/call POST Initiate outbound call OutboundCallRequest CallResult
/api/proxy/genesys/voice/status/{callId} GET Get call status Path param CallStatus
/api/proxy/genesys/webhooks/sms POST Receive SMS delivery callbacks Genesys payload 200 OK
/api/proxy/genesys/webhooks/voice POST Receive call completion callbacks Genesys payload 200 OK

Genesys Cloud Data Models

// SMS Request
public class SendSMSRequest
{
    public string ToPhoneNumber { get; set; }
    public string MessageBody { get; set; }
    public string AlertType { get; set; }
    public string AlertId { get; set; }
    public Dictionary<string, string> Metadata { get; set; }
}

// SMS Delivery Result
public class SMSDeliveryResult
{
    public string MessageId { get; set; }
    public string Status { get; set; } // Queued, Sent, Delivered, Failed
    public DateTime QueuedAt { get; set; }
    public string GenesysReference { get; set; }
}

// Outbound Call Request
public class OutboundCallRequest
{
    public string ToPhoneNumber { get; set; }
    public string IVRScriptId { get; set; }
    public string AnnouncementText { get; set; }
    public bool RequireConfirmation { get; set; } // Press 1 to confirm
    public int MaxRetries { get; set; }
    public int RetryIntervalSeconds { get; set; }
    public string AlertType { get; set; }
    public string AlertId { get; set; }
    public Dictionary<string, string> Metadata { get; set; }
}

// Call Result
public class CallResult
{
    public string CallId { get; set; }
    public string Status { get; set; } // Initiated, Ringing, Answered, Completed, Failed
    public DateTime InitiatedAt { get; set; }
    public string GenesysReference { get; set; }
}

// Webhook Callback (SMS)
public class GenesysSMSWebhook
{
    public string MessageId { get; set; }
    public string Status { get; set; }
    public DateTime Timestamp { get; set; }
    public string ErrorCode { get; set; }
    public string ErrorMessage { get; set; }
}

// Webhook Callback (Voice)
public class GenesysVoiceWebhook
{
    public string CallId { get; set; }
    public string Status { get; set; } // Completed, NoAnswer, Busy, Failed
    public string DTMFInput { get; set; } // e.g., "1" for confirmation
    public int DurationSeconds { get; set; }
    public string RecordingUrl { get; set; }
    public DateTime Timestamp { get; set; }
}

Genesys Cloud Service Implementation

// Genesys Cloud integration service
public class GenesysCloudService : IGenesysCloudService
{
    private readonly HttpClient _httpClient;
    private readonly GenesysCloudOptions _options;
    private readonly ILogger<GenesysCloudService> _logger;
    private readonly IAlertRepository _alertRepository;

    public GenesysCloudService(
        HttpClient httpClient,
        IOptions<GenesysCloudOptions> options,
        ILogger<GenesysCloudService> logger,
        IAlertRepository alertRepository)
    {
        _httpClient = httpClient;
        _options = options.Value;
        _logger = logger;
        _alertRepository = alertRepository;
    }

    // Send SMS via Genesys Cloud
    public async Task<SMSDeliveryResult> SendSMSAsync(SendSMSRequest request)
    {
        // Check opt-out status first
        var optOutStatus = await CheckOptOutStatusAsync(request.ToPhoneNumber);
        if (optOutStatus.IsOptedOut)
        {
            _logger.LogWarning(
                "SMS blocked: {Phone} has opted out",
                MaskPhone(request.ToPhoneNumber));

            return new SMSDeliveryResult
            {
                Status = "OptedOut",
                MessageId = null
            };
        }

        // Prepare Genesys API payload
        var genesysPayload = new
        {
            toAddress = request.ToPhoneNumber,
            toAddressMessengerType = "sms",
            textBody = request.MessageBody,
            fromAddress = _options.SMSFromNumber,
            customAttributes = new Dictionary<string, string>
            {
                { "alertType", request.AlertType },
                { "alertId", request.AlertId }
            }
        };

        // Send to Genesys
        var response = await _httpClient.PostAsJsonAsync(
            $"{_options.BaseUrl}/api/v2/messaging/integrations/open",
            genesysPayload);

        if (!response.IsSuccessStatusCode)
        {
            var error = await response.Content.ReadAsStringAsync();
            _logger.LogError("Genesys SMS failed: {Error}", error);

            throw new GenesysIntegrationException(
                $"SMS send failed: {response.StatusCode}");
        }

        var result = await response.Content
            .ReadFromJsonAsync<GenesysSMSResponse>();

        // Track delivery
        await _alertRepository.UpdateDeliveryStatusAsync(
            request.AlertId,
            AlertChannel.SMS,
            "Queued",
            result.Id);

        _logger.LogInformation(
            "SMS queued: {MessageId} to {Phone}",
            result.Id, MaskPhone(request.ToPhoneNumber));

        return new SMSDeliveryResult
        {
            MessageId = result.Id,
            Status = "Queued",
            QueuedAt = DateTime.UtcNow,
            GenesysReference = result.Id
        };
    }

    // Initiate outbound call via Genesys Cloud
    public async Task<CallResult> InitiateCallAsync(OutboundCallRequest request)
    {
        // Prepare IVR call payload
        var genesysPayload = new
        {
            phoneNumber = request.ToPhoneNumber,
            callerId = _options.VoiceCallerId,
            flowId = request.IVRScriptId ?? _options.DefaultIVRFlowId,
            userData = new Dictionary<string, string>
            {
                { "alertType", request.AlertType },
                { "alertId", request.AlertId },
                { "announcementText", request.AnnouncementText },
                { "requireConfirmation", request.RequireConfirmation.ToString() }
            }
        };

        // Initiate call via Genesys
        var response = await _httpClient.PostAsJsonAsync(
            $"{_options.BaseUrl}/api/v2/conversations/calls",
            genesysPayload);

        if (!response.IsSuccessStatusCode)
        {
            var error = await response.Content.ReadAsStringAsync();
            _logger.LogError("Genesys call failed: {Error}", error);

            throw new GenesysIntegrationException(
                $"Call initiation failed: {response.StatusCode}");
        }

        var result = await response.Content
            .ReadFromJsonAsync<GenesysCallResponse>();

        // Track call
        await _alertRepository.UpdateDeliveryStatusAsync(
            request.AlertId,
            AlertChannel.PhoneCall,
            "Initiated",
            result.Id);

        _logger.LogInformation(
            "Call initiated: {CallId} to {Phone}",
            result.Id, MaskPhone(request.ToPhoneNumber));

        return new CallResult
        {
            CallId = result.Id,
            Status = "Initiated",
            InitiatedAt = DateTime.UtcNow,
            GenesysReference = result.Id
        };
    }

    // Check if phone number has opted out
    public async Task<OptOutStatus> CheckOptOutStatusAsync(string phoneNumber)
    {
        var response = await _httpClient.GetAsync(
            $"{_options.BaseUrl}/api/v2/messaging/settings/optout?address={phoneNumber}");

        if (!response.IsSuccessStatusCode)
        {
            // If we can't determine, assume not opted out
            return new OptOutStatus { IsOptedOut = false };
        }

        var result = await response.Content
            .ReadFromJsonAsync<GenesysOptOutResponse>();

        return new OptOutStatus
        {
            IsOptedOut = result.OptedOut,
            OptOutDate = result.OptOutDate
        };
    }

    // Process SMS delivery webhook
    public async Task ProcessSMSWebhookAsync(GenesysSMSWebhook webhook)
    {
        _logger.LogInformation(
            "SMS webhook received: {MessageId} - {Status}",
            webhook.MessageId, webhook.Status);

        // Update alert delivery status
        await _alertRepository.UpdateDeliveryStatusByExternalIdAsync(
            webhook.MessageId,
            AlertChannel.SMS,
            webhook.Status,
            webhook.ErrorMessage);

        // If failed, may need to retry or escalate
        if (webhook.Status == "Failed")
        {
            await HandleSMSFailureAsync(webhook);
        }
    }

    // Process voice call webhook
    public async Task ProcessVoiceWebhookAsync(GenesysVoiceWebhook webhook)
    {
        _logger.LogInformation(
            "Voice webhook received: {CallId} - {Status}, DTMF: {DTMF}",
            webhook.CallId, webhook.Status, webhook.DTMFInput);

        // Update alert delivery status
        var ackReceived = webhook.DTMFInput == "1"; // Press 1 to confirm

        await _alertRepository.UpdateDeliveryStatusByExternalIdAsync(
            webhook.CallId,
            AlertChannel.PhoneCall,
            ackReceived ? "Confirmed" : webhook.Status,
            null);

        // If confirmation received, stop escalation
        if (ackReceived)
        {
            var alert = await _alertRepository
                .GetByExternalDeliveryIdAsync(webhook.CallId);

            if (alert != null)
            {
                await _alertRepository.MarkAcknowledgedAsync(
                    alert.Id,
                    "Voice confirmation",
                    AlertChannel.PhoneCall);
            }
        }

        // If no answer/failed and retries remaining, retry
        if (webhook.Status == "NoAnswer" || webhook.Status == "Failed")
        {
            await HandleCallFailureAsync(webhook);
        }
    }

    private async Task HandleSMSFailureAsync(GenesysSMSWebhook webhook)
    {
        var alert = await _alertRepository
            .GetByExternalDeliveryIdAsync(webhook.MessageId);

        if (alert == null) return;

        // Check retry count
        var retryCount = await _alertRepository
            .GetRetryCountAsync(alert.Id, AlertChannel.SMS);

        if (retryCount < 3)
        {
            // Retry after delay
            await Task.Delay(TimeSpan.FromSeconds(30));

            await SendSMSAsync(new SendSMSRequest
            {
                ToPhoneNumber = alert.RecipientPhone,
                MessageBody = alert.SMSBody,
                AlertType = alert.AlertType,
                AlertId = alert.Id.ToString()
            });

            await _alertRepository.IncrementRetryCountAsync(
                alert.Id, AlertChannel.SMS);
        }
        else
        {
            // Escalate - maybe try phone call instead
            _logger.LogWarning(
                "SMS failed after 3 retries for alert {AlertId}",
                alert.Id);
        }
    }

    private async Task HandleCallFailureAsync(GenesysVoiceWebhook webhook)
    {
        var alert = await _alertRepository
            .GetByExternalDeliveryIdAsync(webhook.CallId);

        if (alert == null) return;

        var retryCount = await _alertRepository
            .GetRetryCountAsync(alert.Id, AlertChannel.PhoneCall);

        if (retryCount < 3)
        {
            // Retry after 2 minutes
            await Task.Delay(TimeSpan.FromMinutes(2));

            await InitiateCallAsync(new OutboundCallRequest
            {
                ToPhoneNumber = alert.RecipientPhone,
                AnnouncementText = alert.PhoneCallScript,
                RequireConfirmation = true,
                AlertType = alert.AlertType,
                AlertId = alert.Id.ToString()
            });

            await _alertRepository.IncrementRetryCountAsync(
                alert.Id, AlertChannel.PhoneCall);
        }
        else
        {
            // Max retries reached - escalate to next recipient
            _logger.LogWarning(
                "Call failed after 3 retries for alert {AlertId}",
                alert.Id);

            await _alertRepository.MarkDeliveryFailedAsync(
                alert.Id, AlertChannel.PhoneCall);
        }
    }

    private string MaskPhone(string phone)
    {
        if (string.IsNullOrEmpty(phone) || phone.Length < 4)
            return "****";

        return $"***-***-{phone[^4..]}";
    }
}

Genesys Cloud Configuration

// appsettings.json
{
  "GenesysCloud": {
    "BaseUrl": "https://api.usw2.pure.cloud",
    "ClientId": "[OAuth Client ID from Azure Key Vault]",
    "ClientSecret": "[OAuth Client Secret from Azure Key Vault]",
    "TokenUrl": "https://login.usw2.pure.cloud/oauth/token",
    "SMS": {
      "FromNumber": "+1-555-POMPS-9",
      "SenderId": "Pomps Dispatch",
      "MaxRetries": 3,
      "RetryDelaySeconds": 30
    },
    "Voice": {
      "CallerId": "+1-555-POMPS-0",
      "DefaultIVRFlowId": "flow-dispatch-alerts",
      "MaxRetries": 3,
      "RetryDelayMinutes": 2,
      "RecordingEnabled": true
    },
    "Webhooks": {
      "SMSCallbackUrl": "https://api.pomps.com/api/proxy/genesys/webhooks/sms",
      "VoiceCallbackUrl": "https://api.pomps.com/api/proxy/genesys/webhooks/voice",
      "HMACSecret": "[HMAC Secret from Azure Key Vault]"
    }
  }
}

Dayforce Integration

Overview

Dayforce is the Source of Truth for Employee/Technician Master Data, including employee records, certifications, skills, payroll information, and employment status. Pomp's provides a Proxy API Layer that wraps Dayforce's native API to normalize data formats, handle authentication, and provide caching for technician data.

Dayforce Proxy API Architecture

flowchart TB
    proxy["DAYFORCE PROXY API LAYER<br/>(Pomp's-Provided Abstraction)<br/><br/>• Auth Handler<br/>• Rate Limiter<br/>• Caching (Redis)<br/>• Data Transform<br/>• Error Handler<br/>• Audit Logger"]

    dayforce["DAYFORCE HR API<br/>(Native REST)"]

    proxy --> dayforce

Dayforce Proxy API Endpoints

Employee/Technician Management

Endpoint Method Purpose Request Response
/api/proxy/dayforce/employees GET List employees ?role=Technician&storeId=&status= Employee[]
/api/proxy/dayforce/employees/{id} GET Get employee details Path param: id Employee
/api/proxy/dayforce/employees/{id}/certifications GET Get certifications Path param: id Certification[]
/api/proxy/dayforce/employees/{id}/skills GET Get skills Path param: id Skill[]
/api/proxy/dayforce/employees/by-store/{storeId} GET Get employees by store Path param: storeId Employee[]
/api/proxy/dayforce/technicians GET List active technicians ?storeId=&region= Technician[]
/api/proxy/dayforce/technicians/{id} GET Get technician details Path param: id Technician

Organization Structure

Endpoint Method Purpose Request Response
/api/proxy/dayforce/stores GET List stores ?region=&status= Store[]
/api/proxy/dayforce/stores/{id} GET Get store details Path param: id Store
/api/proxy/dayforce/stores/{id}/employees GET Get store employees Path param: id Employee[]
/api/proxy/dayforce/regions GET List regions N/A Region[]

Dayforce Data Models

// Employee model (mapped from Dayforce)
public class DayforceEmployee
{
    public string EmployeeId { get; set; }
    public string DayforceXRefCode { get; set; } // Dayforce unique ID
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public string Phone { get; set; }
    public string Role { get; set; } // Technician, Dispatcher, StoreManager, etc.
    public string StoreId { get; set; }
    public string StoreName { get; set; }
    public string RegionId { get; set; }
    public string ManagerId { get; set; }
    public DateTime HireDate { get; set; }
    public string EmploymentStatus { get; set; } // Active, OnLeave, Terminated
    public List<DayforceCertification> Certifications { get; set; }
    public List<DayforceSkill> Skills { get; set; }
}

// Technician model (extended from Employee)
public class DayforceTechnician : DayforceEmployee
{
    public string VehicleId { get; set; }
    public string LicenseNumber { get; set; }
    public List<string> ServiceTypes { get; set; } // Tire Service, Alignment, etc.
    public List<string> EquipmentCertifications { get; set; }
    public bool IsAvailable { get; set; }
    public string CurrentAssignmentId { get; set; }
}

// Certification model
public class DayforceCertification
{
    public string CertificationId { get; set; }
    public string Name { get; set; }
    public string IssuingAuthority { get; set; }
    public DateTime IssueDate { get; set; }
    public DateTime? ExpirationDate { get; set; }
    public string Status { get; set; } // Active, Expired, Pending
}

// Skill model
public class DayforceSkill
{
    public string SkillId { get; set; }
    public string Name { get; set; }
    public string Category { get; set; } // Technical, Safety, Equipment
    public int ProficiencyLevel { get; set; } // 1-5
    public DateTime AcquiredDate { get; set; }
}

// Store model
public class DayforceStore
{
    public string StoreId { get; set; }
    public string DayforceXRefCode { get; set; }
    public string Name { get; set; }
    public string StoreNumber { get; set; }
    public Address Address { get; set; }
    public string Phone { get; set; }
    public string RegionId { get; set; }
    public string ManagerId { get; set; }
    public string Status { get; set; } // Active, Inactive
    public List<string> ServiceCapabilities { get; set; }
}

Dayforce Sync Service

// Service for synchronizing employee data from Dayforce
public class DayforceSyncService : IDayforceSyncService
{
    private readonly IDayforceProxyClient _proxyClient;
    private readonly ITechnicianRepository _technicianRepo;
    private readonly IStoreRepository _storeRepo;
    private readonly IEventPublisher _eventPublisher;
    private readonly ICache _cache;
    private readonly ILogger<DayforceSyncService> _logger;

    // Get technician with caching
    public async Task<DayforceTechnician> GetTechnicianAsync(string technicianId)
    {
        // Check cache first
        var cached = await _cache.GetAsync<DayforceTechnician>(
            $"dayforce:technician:{technicianId}");

        if (cached != null)
            return cached;

        // Fetch from Dayforce via Proxy
        var technician = await _proxyClient.GetTechnicianAsync(technicianId);

        // Cache for 1 hour
        await _cache.SetAsync(
            $"dayforce:technician:{technicianId}", 
            technician, 
            TimeSpan.FromHours(1));

        return technician;
    }

    // Get technicians by store with caching
    public async Task<List<DayforceTechnician>> GetTechniciansByStoreAsync(
        string storeId)
    {
        var cached = await _cache.GetAsync<List<DayforceTechnician>>(
            $"dayforce:store:{storeId}:technicians");

        if (cached != null)
            return cached;

        var technicians = await _proxyClient
            .GetTechniciansByStoreAsync(storeId);

        // Cache for 30 minutes
        await _cache.SetAsync(
            $"dayforce:store:{storeId}:technicians", 
            technicians, 
            TimeSpan.FromMinutes(30));

        return technicians;
    }

    // Nightly batch sync of all employee data
    public async Task SyncAllEmployeesAsync()
    {
        _logger.LogInformation("Starting Dayforce employee sync");

        var employees = await _proxyClient.GetAllEmployeesAsync();
        var syncCount = 0;
        var errorCount = 0;

        foreach (var employee in employees)
        {
            try
            {
                if (employee.Role == "Technician")
                {
                    await SyncTechnicianAsync(employee);
                }

                syncCount++;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, 
                    "Failed to sync employee {EmployeeId}", 
                    employee.EmployeeId);
                errorCount++;
            }
        }

        _logger.LogInformation(
            "Dayforce sync completed: {Synced} synced, {Errors} errors",
            syncCount, errorCount);

        // Invalidate cache
        await _cache.RemoveByPrefixAsync("dayforce:");
    }

    private async Task SyncTechnicianAsync(DayforceEmployee employee)
    {
        var existing = await _technicianRepo
            .GetByDayforceIdAsync(employee.DayforceXRefCode);

        if (existing == null)
        {
            // New technician
            var technician = MapToTechnician(employee);
            await _technicianRepo.CreateAsync(technician);

            await _eventPublisher.PublishAsync(new TechnicianCreatedEvent
            {
                TechnicianId = technician.Id,
                Name = $"{employee.FirstName} {employee.LastName}",
                StoreId = employee.StoreId
            });
        }
        else
        {
            // Update existing
            UpdateTechnician(existing, employee);
            await _technicianRepo.UpdateAsync(existing);

            // Check for status changes
            if (existing.EmploymentStatus != employee.EmploymentStatus)
            {
                await _eventPublisher.PublishAsync(new TechnicianStatusChangedEvent
                {
                    TechnicianId = existing.Id,
                    PreviousStatus = existing.EmploymentStatus,
                    NewStatus = employee.EmploymentStatus
                });
            }
        }
    }

    private Technician MapToTechnician(DayforceEmployee employee)
    {
        return new Technician
        {
            DayforceId = employee.DayforceXRefCode,
            FirstName = employee.FirstName,
            LastName = employee.LastName,
            Email = employee.Email,
            Phone = employee.Phone,
            StoreId = int.Parse(employee.StoreId),
            HireDate = employee.HireDate,
            EmploymentStatus = employee.EmploymentStatus,
            Certifications = employee.Certifications?
                .Select(c => new TechnicianCertification
                {
                    Name = c.Name,
                    ExpirationDate = c.ExpirationDate,
                    Status = c.Status
                }).ToList()
        };
    }

    private void UpdateTechnician(Technician existing, DayforceEmployee employee)
    {
        existing.FirstName = employee.FirstName;
        existing.LastName = employee.LastName;
        existing.Email = employee.Email;
        existing.Phone = employee.Phone;
        existing.StoreId = int.Parse(employee.StoreId);
        existing.EmploymentStatus = employee.EmploymentStatus;
    }
}

Dayforce Proxy Client Implementation

// HTTP client for Dayforce Proxy API
public class DayforceProxyClient : IDayforceProxyClient
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<DayforceProxyClient> _logger;
    private readonly DayforceProxyOptions _options;

    public DayforceProxyClient(
        HttpClient httpClient,
        IOptions<DayforceProxyOptions> options,
        ILogger<DayforceProxyClient> logger)
    {
        _httpClient = httpClient;
        _options = options.Value;
        _logger = logger;

        _httpClient.BaseAddress = new Uri(_options.BaseUrl);
        _httpClient.DefaultRequestHeaders.Add("X-Api-Key", _options.ApiKey);
    }

    public async Task<DayforceTechnician> GetTechnicianAsync(string technicianId)
    {
        var response = await _httpClient.GetAsync(
            $"/api/proxy/dayforce/technicians/{technicianId}");

        if (response.StatusCode == HttpStatusCode.NotFound)
            return null;

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<DayforceTechnician>();
    }

    public async Task<List<DayforceTechnician>> GetTechniciansByStoreAsync(
        string storeId)
    {
        var response = await _httpClient.GetAsync(
            $"/api/proxy/dayforce/employees/by-store/{storeId}?role=Technician");

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<List<DayforceTechnician>>();
    }

    public async Task<List<DayforceEmployee>> GetAllEmployeesAsync()
    {
        var response = await _httpClient.GetAsync(
            "/api/proxy/dayforce/employees?status=Active");

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<List<DayforceEmployee>>();
    }

    public async Task<DayforceStore> GetStoreAsync(string storeId)
    {
        var response = await _httpClient.GetAsync(
            $"/api/proxy/dayforce/stores/{storeId}");

        if (response.StatusCode == HttpStatusCode.NotFound)
            return null;

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<DayforceStore>();
    }
}

// Startup configuration
services.AddHttpClient<IDayforceProxyClient, DayforceProxyClient>()
    .AddPolicyHandler(GetRetryPolicy())
    .AddPolicyHandler(GetCircuitBreakerPolicy());

Dayforce Configuration

// appsettings.json
{
  "DayforceProxy": {
    "BaseUrl": "https://api.pomps.com",
    "ApiKey": "[API Key from Azure Key Vault]",
    "SyncSchedule": "0 1 * * *",
    "CacheDuration": {
      "TechnicianMinutes": 60,
      "StoreMinutes": 120
    }
  }
}

Pomp's Internal API Catalog

Overview

Pomp's Dispatch Platform exposes REST APIs for internal consumption by the web portal, mobile app, and background services.

API Base URL

  • Production: https://api.pomps.com/api/v1
  • Staging: https://api-staging.pomps.com/api/v1
  • Development: https://localhost:5001/api/v1

Service Request APIs

Endpoint Method Purpose Auth Roles
/service-requests GET List service requests JWT Dispatcher, Admin
/service-requests POST Create service request JWT Dispatcher, CustomerService, Admin
/service-requests/{id} GET Get service request details JWT Dispatcher, StoreManager, Admin
/service-requests/{id} PATCH Update service request JWT Dispatcher, Admin
/service-requests/{id}/assign POST Assign technician JWT Dispatcher, Admin
/service-requests/{id}/cancel POST Cancel service request JWT Dispatcher, Admin
/service-requests/{id}/comments GET Get comments JWT All roles
/service-requests/{id}/comments POST Add comment JWT All roles

Dispatch APIs

Endpoint Method Purpose Auth Roles
/dispatches GET List dispatches JWT Dispatcher, Admin
/dispatches/{id} GET Get dispatch details JWT Dispatcher, StoreManager, Admin
/dispatches/{id}/status PATCH Update dispatch status JWT Technician, Dispatcher, Admin
/dispatches/{id}/eta PATCH Update ETA JWT Technician, Dispatcher
/dispatches/by-store/{storeId} GET List dispatches by store JWT StoreManager, Dispatcher, Admin
/dispatches/by-technician/{techId} GET List dispatches by technician JWT Technician, Dispatcher, Admin

Work Order APIs

Endpoint Method Purpose Auth Roles
/work-orders GET List work orders JWT Dispatcher, StoreManager, Admin
/work-orders/{id} GET Get work order details JWT All roles
/work-orders/{id}/approve POST Approve work order JWT StoreManager, Admin
/work-orders/{id}/reject POST Reject work order JWT StoreManager, Admin
/work-orders/{id}/complete POST Mark complete JWT Technician
/work-orders/{id}/photos GET Get photos JWT All roles
/work-orders/{id}/photos POST Upload photos JWT Technician

Invoice APIs

Endpoint Method Purpose Auth Roles
/invoices GET List invoices JWT BillingClerk, Admin
/invoices/{id} GET Get invoice details JWT BillingClerk, Admin
/invoices/generate POST Generate from work order JWT BillingClerk, Admin
/invoices/{id}/finalize POST Finalize for billing JWT BillingClerk, Admin
/invoices/{id}/sync-maddenco POST Sync to MaddenCo JWT Admin

Alert APIs

Endpoint Method Purpose Auth Roles
/alerts GET List alerts for user JWT All roles
/alerts/{id} GET Get alert details JWT All roles
/alerts/{id}/acknowledge POST Acknowledge alert JWT All roles
/alerts/{id}/dismiss POST Dismiss alert JWT All roles
/alerts/unread-count GET Get unread count JWT All roles

Technician APIs (Mobile)

Endpoint Method Purpose Auth Roles
/technicians/me/assignments GET Get my assignments JWT Technician
/technicians/me/status PATCH Update my status JWT Technician
/technicians/me/location PATCH Update my location JWT Technician
/technicians/{id} GET Get technician details JWT Dispatcher, Admin
/technicians/by-store/{storeId} GET List technicians by store JWT StoreManager, Dispatcher, Admin

Event-Driven Architecture

Azure Service Bus Topics

Topic Publishers Subscribers Purpose
work-order-events Dispatch Portal, REACH Sync Alert Service, REACH Sync, MaddenCo Sync, Analytics Work order lifecycle events
technician-events Mobile App, Dispatch Portal Alert Service, REACH Sync, Dashboard Technician status and location
invoice-events Invoice Service, MaddenCo Sync Alert Service, REACH Sync, Reporting Invoice lifecycle events
alert-events Alert Service Genesys Service, Web Push, Mobile Push Alert delivery tracking

Event Definitions

// Work order created (from REACH, Portal, or Phone)
public class WorkOrderCreatedEvent
{
    public int ServiceRequestId { get; set; }
    public string ReachOrderId { get; set; }
    public string Source { get; set; } // REACH, Portal, Phone
    public string CustomerName { get; set; }
    public Address ServiceLocation { get; set; }
    public ServiceRequestPriority Priority { get; set; }
    public DateTime CreatedAt { get; set; }
}

// Technician assigned to work order
public class WorkAssignedEvent
{
    public int ServiceRequestId { get; set; }
    public int TechnicianId { get; set; }
    public string TechnicianName { get; set; }
    public string TechnicianPhone { get; set; }
    public int StoreId { get; set; }
    public DateTime? EstimatedArrival { get; set; }
    public int AssignedByUserId { get; set; }
    public DateTime AssignedAt { get; set; }
}

// Technician status changed
public class TechnicianStatusChangedEvent
{
    public int ServiceRequestId { get; set; }
    public int TechnicianId { get; set; }
    public string TechnicianName { get; set; }
    public string PreviousStatus { get; set; }
    public string NewStatus { get; set; } // EnRoute, Arrived, WorkInProgress, Complete
    public GeoLocation Location { get; set; }
    public DateTime? EstimatedArrival { get; set; }
    public DateTime ChangedAt { get; set; }
}

// Work order completed
public class WorkCompletedEvent
{
    public int ServiceRequestId { get; set; }
    public int TechnicianId { get; set; }
    public string TechnicianName { get; set; }
    public DateTime CompletedAt { get; set; }
    public string CompletionNotes { get; set; }
    public List<string> PhotoUrls { get; set; }
    public string SignatureUrl { get; set; }
    public decimal? LaborHours { get; set; }
    public string InvoiceReference { get; set; }
}

// Photos uploaded
public class PhotosUploadedEvent
{
    public int ServiceRequestId { get; set; }
    public int TechnicianId { get; set; }
    public List<string> PhotoUrls { get; set; }
    public int PhotoCount { get; set; }
    public DateTime UploadedAt { get; set; }
}

// Invoice generated
public class InvoiceGeneratedEvent
{
    public int InvoiceId { get; set; }
    public int ServiceRequestId { get; set; }
    public string CustomerId { get; set; }
    public decimal TotalAmount { get; set; }
    public string InvoicePdfUrl { get; set; }
    public DateTime GeneratedAt { get; set; }
}

// Invoice synced to MaddenCo
public class InvoiceSyncedToMaddenCoEvent
{
    public int InvoiceId { get; set; }
    public string MaddenCoReference { get; set; }
    public DateTime SyncedAt { get; set; }
}

// Comment added (from REACH or internal)
public class CommentAddedEvent
{
    public int ServiceRequestId { get; set; }
    public string CommentId { get; set; }
    public string Author { get; set; }
    public string AuthorType { get; set; }
    public string Text { get; set; }
    public string Source { get; set; } // REACH, Portal, Mobile
    public DateTime CreatedAt { get; set; }
}

Event Processing Pipeline

flowchart TB
    event_published["Event Published"]
    service_bus["Azure Service Bus Topic"]
    filters["Subscription Filters<br/>(by event type, priority, etc.)"]

    subgraph handlers["Event Handlers"]
        alert_handler["Alert Handler<br/><br/>• Generate alerts<br/>• Route to Genesys"]
        reach_handler["REACH Sync Handler<br/><br/>• Push status<br/>• Push photos<br/>• Push completion"]
        maddenco_handler["MaddenCo Sync Handler<br/><br/>• Create invoice<br/>• Attach photos"]

        analytics_handler["Analytics Handler<br/><br/>• Track KPIs<br/>• Log metrics"]
        dashboard_handler["Dashboard Handler<br/><br/>• Real-time updates"]
    end

    event_published --> service_bus
    service_bus --> filters
    filters --> handlers

Data Synchronization Strategies

Real-Time Sync

Data Trigger Direction Latency Target
Work order status Status change event Pomp's → REACH < 5 seconds
Technician assignment Assignment event Pomp's → REACH < 5 seconds
Photos uploaded Upload complete event Pomp's → REACH, Pomp's → MaddenCo < 30 seconds
Work completion Completion event Pomp's → REACH, Pomp's → MaddenCo < 30 seconds
Alerts Alert generated Pomp's → Genesys < 2 seconds

Polling-Based Sync

Data Source Direction Frequency Latency
New work orders REACH REACH → Pomp's Every 30 seconds < 60 seconds
Work order comments REACH REACH → Pomp's Every 30 seconds < 60 seconds
Cancellations REACH REACH → Pomp's Every 30 seconds < 60 seconds

Batch Sync (Nightly)

Data Source Direction Schedule Purpose
Invoice reconciliation MaddenCo Bi-directional 2:00 AM UTC Ensure financial accuracy
Customer data MaddenCo MaddenCo → Pomp's 3:00 AM UTC Update customer cache
Parts catalog MaddenCo MaddenCo → Pomp's 4:00 AM UTC Update parts/pricing cache
Technician data Dayforce Dayforce → Pomp's 1:00 AM UTC Update employee data

Conflict Resolution

Scenario Winner Rationale
Customer billing info differs MaddenCo Financial SoT
Work order status differs (Pomp's vs REACH) Pomp's Dispatch SoT
Technician assignment differs Pomp's Real-time dispatcher decisions
Invoice amount differs MaddenCo Financial SoT
Customer contact info differs Pomp's (if updated recently) or REACH Most recent update wins

API Authentication & Security

Authentication Methods

Integration Auth Method Credentials Storage Token Expiration
MaddenCo Proxy API Key + Service Principal Azure Key Vault N/A (API Key)
REACH Proxy API Key + Service Principal Azure Key Vault N/A (API Key)
Dayforce Proxy API Key + Service Principal Azure Key Vault N/A (API Key)
Genesys Cloud Proxy OAuth 2.0 Client Credentials Azure Key Vault 24 hours (auto-refresh)
GeoTab Certificate-based Azure Key Vault 1 year (certificate rotation)
Internal APIs JWT Bearer (Azure AD) N/A (user session) 8 hours

API Security Controls

// Startup.cs - API security configuration
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = Configuration["AzureAd:Authority"];
        options.Audience = Configuration["AzureAd:Audience"];
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(5)
        };
    });

// Rate limiting
services.AddRateLimiter(options =>
{
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
        context => RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: context.User?.Identity?.Name ?? context.Request.Headers.Host.ToString(),
            factory: partition => new FixedWindowRateLimiterOptions
            {
                AutoReplenishment = true,
                PermitLimit = 100,
                Window = TimeSpan.FromMinutes(1)
            }));
});

// CORS
services.AddCors(options =>
{
    options.AddPolicy("AllowedOrigins", builder =>
    {
        builder
            .WithOrigins(
                "https://dispatch.pomps.com",
                "https://portal.pomps.com")
            .AllowAnyMethod()
            .AllowAnyHeader()
            .AllowCredentials();
    });
});

Webhook Security

// Validate Genesys webhook signature
public bool ValidateGenesysWebhook(HttpRequest request, string payload)
{
    var signature = request.Headers["X-Genesys-Signature"].FirstOrDefault();
    if (string.IsNullOrEmpty(signature))
        return false;

    var expectedSignature = ComputeHMAC(payload, _options.WebhookHMACSecret);
    return signature == expectedSignature;
}

private string ComputeHMAC(string payload, string secret)
{
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
    return Convert.ToBase64String(hash);
}

Error Handling & Retry Logic

Retry Policies

// Transient error retry policy (Polly)
public static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(
            retryCount: 3,
            sleepDurationProvider: (retryAttempt, response, context) =>
            {
                // Check for Retry-After header
                if (response?.Result?.Headers?.RetryAfter?.Delta != null)
                    return response.Result.Headers.RetryAfter.Delta.Value;

                // Exponential backoff with jitter
                var baseDelay = TimeSpan.FromSeconds(Math.Pow(2, retryAttempt));
                var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000));
                return baseDelay + jitter;
            },
            onRetryAsync: async (outcome, timespan, retryAttempt, context) =>
            {
                // Log retry attempt
                Log.Warning(
                    "Retry {Attempt} after {Delay}ms for {Url}",
                    retryAttempt,
                    timespan.TotalMilliseconds,
                    outcome.Result?.RequestMessage?.RequestUri);
            });
}

// Circuit breaker policy
public static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .CircuitBreakerAsync(
            handledEventsAllowedBeforeBreaking: 5,
            durationOfBreak: TimeSpan.FromSeconds(30),
            onBreak: (outcome, breakDelay) =>
            {
                Log.Warning(
                    "Circuit breaker opened for {Delay}s due to: {Error}",
                    breakDelay.TotalSeconds,
                    outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString());
            },
            onReset: () =>
            {
                Log.Information("Circuit breaker reset");
            });
}

Error Classification

Error Type HTTP Status Action Retry
Rate Limited 429 Wait for Retry-After, then retry Yes (with backoff)
Service Unavailable 503 Retry immediately, then backoff Yes (3 attempts)
Gateway Timeout 504 Retry with shorter timeout Yes (2 attempts)
Bad Request 400 Log error, alert engineering No
Unauthorized 401 Refresh token, retry once Yes (1 attempt)
Forbidden 403 Log, alert security team No
Not Found 404 Log, skip record No
Internal Server Error 500 Log, alert, retry Yes (2 attempts)

Dead Letter Queue

// Handle failed messages
public class DeadLetterProcessor : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var receiver = _serviceBusClient.CreateReceiver(
            "work-order-events",
            "alert-service",
            new ServiceBusReceiverOptions
            {
                SubQueue = SubQueue.DeadLetter
            });

        while (!stoppingToken.IsCancellationRequested)
        {
            var message = await receiver.ReceiveMessageAsync(
                TimeSpan.FromSeconds(30),
                stoppingToken);

            if (message == null)
                continue;

            // Log dead letter for investigation
            _logger.LogError(
                "Dead letter message: {MessageId}, Reason: {Reason}, Error: {Error}",
                message.MessageId,
                message.DeadLetterReason,
                message.DeadLetterErrorDescription);

            // Store for manual review
            await _deadLetterRepository.SaveAsync(new DeadLetterRecord
            {
                MessageId = message.MessageId,
                Topic = "work-order-events",
                Subscription = "alert-service",
                Body = message.Body.ToString(),
                Reason = message.DeadLetterReason,
                Error = message.DeadLetterErrorDescription,
                EnqueuedTime = message.EnqueuedTime,
                DeadLetteredTime = DateTime.UtcNow
            });

            // Alert on-call
            await _alertService.SendAlertAsync(new DispatchAlert
            {
                AlertType = "DEAD_LETTER_MESSAGE",
                Title = "Dead Letter Message Detected",
                Message = $"Message {message.MessageId} dead-lettered: {message.DeadLetterReason}",
                Priority = AlertPriority.High
            });

            await receiver.CompleteMessageAsync(message, stoppingToken);
        }
    }
}

Monitoring & Observability

Integration Health Dashboard

flowchart TB
    title["INTEGRATION HEALTH DASHBOARD"]

    subgraph api_health["API Health Status"]
        maddenco["MaddenCo API<br/><br/>● HEALTHY<br/>Latency: 45ms<br/>Errors: 0.1%<br/>Rate: 42/min"]
        reach_api["REACH API<br/><br/>● HEALTHY<br/>Latency: 120ms<br/>Errors: 0.2%<br/>Rate: 28/min"]
        genesys["Genesys Cloud<br/><br/>● HEALTHY<br/>Latency: 85ms<br/>Errors: 0.0%<br/>Rate: 156/min"]
    end

    reach_polling["REACH Polling Status<br/>Last Poll: 15 seconds ago<br/><br/>Pending Orders: 3<br/>New Comments: 7<br/>Sync Lag: 28 seconds<br/>Errors (24h): 2"]

    maddenco_sync["MaddenCo Sync Status<br/>Last Batch: 2:00 AM today<br/><br/>Invoices Synced: 847<br/>Discrepancies: 0<br/>Customers Updated: 23<br/>Parts Updated: 156"]

    genesys_stats["Genesys Delivery Stats (24h)<br/><br/>SMS Sent: 1,247<br/>Delivered: 1,241 (99.5%)<br/>Calls Made: 34<br/>Confirmed: 31 (91.2%)<br/>Opt-Outs: 6<br/>Failures: 3"]

Key Metrics

Metric Target Alert Threshold
API Response Time (P95) < 500ms > 2000ms
API Error Rate < 1% > 5%
REACH Polling Lag < 60s > 300s
MaddenCo Sync Success > 99% < 95%
SMS Delivery Rate > 98% < 90%
Call Confirmation Rate > 85% < 70%
Dead Letter Messages 0 > 10/hour
Circuit Breaker Opens 0 > 3/hour

Application Insights Configuration

// Track integration metrics
public class IntegrationMetricsService
{
    private readonly TelemetryClient _telemetry;

    public void TrackAPICall(
        string integration, 
        string endpoint, 
        int statusCode, 
        TimeSpan duration,
        bool success)
    {
        _telemetry.TrackDependency(
            dependencyTypeName: "HTTP",
            dependencyName: integration,
            data: endpoint,
            startTime: DateTimeOffset.UtcNow - duration,
            duration: duration,
            success: success);

        _telemetry.GetMetric(
            $"Integration.{integration}.Latency")
            .TrackValue(duration.TotalMilliseconds);

        _telemetry.GetMetric(
            $"Integration.{integration}.StatusCode",
            "StatusCode")
            .TrackValue(1, statusCode.ToString());
    }

    public void TrackPollingCycle(
        string source,
        int newOrders,
        int newComments,
        TimeSpan duration)
    {
        _telemetry.TrackEvent("PollingCycleCompleted", new Dictionary<string, string>
        {
            { "Source", source },
            { "NewOrders", newOrders.ToString() },
            { "NewComments", newComments.ToString() },
            { "DurationMs", duration.TotalMilliseconds.ToString() }
        });

        _telemetry.GetMetric($"Polling.{source}.NewOrders")
            .TrackValue(newOrders);

        _telemetry.GetMetric($"Polling.{source}.Duration")
            .TrackValue(duration.TotalMilliseconds);
    }

    public void TrackSyncJob(
        string integration,
        string jobType,
        int recordsProcessed,
        int errors,
        TimeSpan duration)
    {
        _telemetry.TrackEvent("SyncJobCompleted", new Dictionary<string, string>
        {
            { "Integration", integration },
            { "JobType", jobType },
            { "RecordsProcessed", recordsProcessed.ToString() },
            { "Errors", errors.ToString() },
            { "DurationMs", duration.TotalMilliseconds.ToString() }
        });
    }
}

Disaster Recovery & Failover

Integration Failover Strategies

Integration Failure Mode Failover Strategy RTO
MaddenCo API unavailable Queue invoices locally, retry when available 4 hours
REACH API unavailable Continue with local dispatch, sync when restored 2 hours
Genesys SMS SMS delivery failure Retry 3x, then escalate to phone call 15 minutes
Genesys Voice Call failure Retry 3x, then send SMS 15 minutes
Azure Service Bus Message processing failure Dead letter queue, manual retry 1 hour

Graceful Degradation

// Example: MaddenCo unavailable
public async Task<Invoice> CreateInvoiceWithFallbackAsync(
    WorkOrder workOrder)
{
    try
    {
        // Try to create in MaddenCo
        var maddenCoInvoice = await _maddenCoClient.CreateInvoiceAsync(
            MapToMaddenCoRequest(workOrder));

        return await _invoiceRepo.CreateAsync(new Invoice
        {
            WorkOrderId = workOrder.Id,
            MaddenCoReference = maddenCoInvoice.InvoiceId,
            Status = InvoiceStatus.Synced
        });
    }
    catch (MaddenCoUnavailableException ex)
    {
        _logger.LogWarning(ex, 
            "MaddenCo unavailable, queueing invoice for later sync");

        // Create local invoice with pending sync status
        var invoice = await _invoiceRepo.CreateAsync(new Invoice
        {
            WorkOrderId = workOrder.Id,
            MaddenCoReference = null,
            Status = InvoiceStatus.PendingSync
        });

        // Queue for retry
        await _syncQueue.EnqueueAsync(new PendingSyncItem
        {
            Type = "Invoice",
            EntityId = invoice.Id,
            RetryCount = 0,
            CreatedAt = DateTime.UtcNow
        });

        // Alert billing team
        await _alertService.SendAlertAsync(new DispatchAlert
        {
            AlertType = "MADDENCO_SYNC_PENDING",
            Title = "Invoice Pending MaddenCo Sync",
            Message = $"Invoice {invoice.Id} created locally, pending sync to MaddenCo",
            Priority = AlertPriority.Medium
        });

        return invoice;
    }
}

Data Consistency Checks

// Daily reconciliation job
public class DataConsistencyJob : IJob
{
    public async Task ExecuteAsync()
    {
        // Check REACH sync consistency
        await CheckReachConsistencyAsync();

        // Check MaddenCo sync consistency
        await CheckMaddenCoConsistencyAsync();

        // Check alert delivery consistency
        await CheckAlertDeliveryConsistencyAsync();
    }

    private async Task CheckReachConsistencyAsync()
    {
        // Get all active REACH orders from Pomp's
        var pompsOrders = await _serviceRequestRepo
            .GetActiveReachOrdersAsync();

        foreach (var order in pompsOrders)
        {
            var reachOrder = await _reachClient
                .GetWorkOrderAsync(order.ReachOrderId);

            // Compare status
            if (MapReachStatus(reachOrder.Status) != order.Status)
            {
                _logger.LogWarning(
                    "Status mismatch for {OrderId}: Pomp's={PompsStatus}, REACH={ReachStatus}",
                    order.Id, order.Status, reachOrder.Status);

                // Pomp's is SoT - push correct status to REACH
                await _reachPushService.PushStatusUpdateAsync(
                    order.ReachOrderId,
                    order.Status.ToString(),
                    order.AssignedTechnician?.Name);
            }
        }
    }
}

Azure AI Services

Pomp's Dispatch Center integrates with Azure AI services to provide intelligent automation, photo analysis, and conversational interfaces.

AI Integration Architecture

flowchart TB
    platform["POMP'S DISPATCH PLATFORM"]

    subgraph azure_openai["AZURE OPENAI"]
        gpt4o["GPT-4o<br/><br/>• Smart Assignment<br/>• Customer Comms<br/>• Issue Triage<br/>• Knowledge"]
    end

    subgraph azure_vision["AZURE AI VISION"]
        image_analysis["Image Analysis<br/><br/>• Tire Condition<br/>• Damage Assessment<br/>• Quality Check"]
    end

    subgraph azure_bot["AZURE BOT SERVICE"]
        dispatch_bot["Dispatch Bot<br/><br/>• Status Inquiry<br/>• Schedule<br/>• FAQ<br/>• Escalation"]
    end

    genesys["GENESYS CLOUD<br/><br/>• SMS Bot Channel<br/>• Voice IVR<br/>• Agent Handoff"]

    platform --> azure_openai
    platform --> azure_vision
    platform --> azure_bot
    azure_bot --> genesys

Azure OpenAI Integration

Model: GPT-4o (Azure OpenAI Service)

Use Case Description Input Output
Smart Assignment Suggest optimal technician based on skills, proximity, workload Work order details, tech list Ranked tech recommendations with reasoning
Customer Communication Generate professional customer notifications Context, template, language Personalized message in English or Spanish
Issue Triage Analyze customer description to determine service type Customer description Service type, priority suggestion, parts estimate
Knowledge Base Answer technician questions about procedures Question + context Procedure guidance, safety notes
Dispatch Summary Generate shift summary reports Day's work orders, metrics Executive summary text

Smart Assignment Service

public class SmartAssignmentService
{
    private readonly OpenAIClient _openAIClient;
    private readonly ITechnicianRepository _techRepo;
    private readonly IGeoTabService _geoTabService;

    public async Task<TechnicianRecommendation[]> GetRecommendationsAsync(
        WorkOrder workOrder,
        int maxRecommendations = 3)
    {
        // 1. Get available technicians with skills and current location
        var availableTechs = await _techRepo.GetAvailableAsync(workOrder.StoreId);
        var techLocations = await _geoTabService.GetCurrentLocationsAsync(
            availableTechs.Select(t => t.VehicleId));

        // 2. Build context for AI
        var prompt = BuildAssignmentPrompt(workOrder, availableTechs, techLocations);

        // 3. Get AI recommendations
        var response = await _openAIClient.GetChatCompletionsAsync(
            deploymentOrModelName: "gpt-4o",
            new ChatCompletionsOptions
            {
                Messages = {
                    new ChatRequestSystemMessage(AssignmentSystemPrompt),
                    new ChatRequestUserMessage(prompt)
                },
                Temperature = 0.3f,  // Lower for more consistent results
                MaxTokens = 1000,
                ResponseFormat = ChatCompletionsResponseFormat.JsonObject
            });

        return ParseRecommendations(response.Value.Choices[0].Message.Content);
    }

    private string BuildAssignmentPrompt(
        WorkOrder workOrder,
        IEnumerable<Technician> technicians,
        Dictionary<string, GeoLocation> locations)
    {
        return $@"
            Work Order Details:
            - Type: {workOrder.ServiceType}
            - Priority: {workOrder.Priority}
            - Customer: {workOrder.CustomerName} (Tier: {workOrder.CustomerTier})
            - Location: {workOrder.Address}
            - Required Skills: {string.Join(", ", workOrder.RequiredSkills)}
            - Scheduled Time: {workOrder.ScheduledTime}

            Available Technicians:
            {FormatTechnicians(technicians, locations)}

            Recommend the top 3 technicians with reasoning for each.
            Consider: proximity, skills match, current workload, customer tier.
        ";
    }
}

Azure AI Vision Integration

Service: Azure AI Vision 4.0

Use Case Trigger Analysis Output
Tire Condition Analysis Photo upload (tire type) Tread depth estimate, damage detected, replacement recommendation
Damage Assessment Before/after photos Damage type, severity, affected areas
Parts Verification Completion photos Parts installed verification, match to work order
Quality Control Random sample of completed work Quality score, issues detected
Vehicle Identification Vehicle photo Make, model, year (where visible)

Photo Analysis Service

public class PhotoAnalysisService
{
    private readonly ImageAnalysisClient _visionClient;
    private readonly OpenAIClient _openAIClient;
    private readonly IPhotoRepository _photoRepo;

    public async Task<TireConditionReport> AnalyzeTirePhotoAsync(
        string photoUri,
        string workOrderId)
    {
        // 1. Get basic image analysis from Azure AI Vision
        var analysisResult = await _visionClient.AnalyzeAsync(
            new Uri(photoUri),
            VisualFeatures.Tags | VisualFeatures.Objects | VisualFeatures.Caption,
            new ImageAnalysisOptions
            {
                Language = "en",
                GenderNeutralCaption = true
            });

        // 2. Use GPT-4o Vision for detailed tire analysis
        var detailedAnalysis = await _openAIClient.GetChatCompletionsAsync(
            deploymentOrModelName: "gpt-4o",
            new ChatCompletionsOptions
            {
                Messages = {
                    new ChatRequestSystemMessage(TireAnalysisSystemPrompt),
                    new ChatRequestUserMessage(
                        new ChatMessageImageContentItem(new Uri(photoUri)),
                        new ChatMessageTextContentItem(
                            "Analyze this tire photo. Assess: tread depth, damage, wear patterns, sidewall condition. Recommend if replacement needed."))
                },
                Temperature = 0.2f,
                MaxTokens = 500,
                ResponseFormat = ChatCompletionsResponseFormat.JsonObject
            });

        var report = ParseTireReport(detailedAnalysis);

        // 3. Store analysis results
        await _photoRepo.UpdateAnalysisAsync(photoUri, new PhotoAnalysis
        {
            AnalyzedAt = DateTimeOffset.UtcNow,
            VisionTags = analysisResult.Value.Tags.Select(t => t.Name).ToList(),
            TireCondition = report.Condition,
            ReplacementRecommended = report.ReplacementRecommended,
            Confidence = report.Confidence
        });

        return report;
    }

    public async Task<DamageAssessment> AnalyzeDamagePhotosAsync(
        string[] photoUris,
        string workOrderId)
    {
        var analyses = new List<PhotoDamageResult>();

        foreach (var photoUri in photoUris)
        {
            var result = await _openAIClient.GetChatCompletionsAsync(
                deploymentOrModelName: "gpt-4o",
                new ChatCompletionsOptions
                {
                    Messages = {
                        new ChatRequestSystemMessage(DamageAssessmentSystemPrompt),
                        new ChatRequestUserMessage(
                            new ChatMessageImageContentItem(new Uri(photoUri)),
                            new ChatMessageTextContentItem(
                                "Analyze this vehicle/equipment photo for damage. Identify: damage type, location, severity (minor/moderate/severe), and recommended action."))
                    },
                    Temperature = 0.2f,
                    ResponseFormat = ChatCompletionsResponseFormat.JsonObject
                });

            analyses.Add(ParseDamageResult(result));
        }

        return new DamageAssessment
        {
            WorkOrderId = workOrderId,
            OverallSeverity = DetermineSeverity(analyses),
            DamageAreas = analyses.SelectMany(a => a.Areas).Distinct().ToList(),
            RecommendedActions = analyses.SelectMany(a => a.Actions).Distinct().ToList()
        };
    }
}

Photo Analysis Trigger (Azure Service Bus)

[FunctionName("ProcessPhotoUpload")]
public async Task ProcessPhotoUpload(
    [ServiceBusTrigger("photo-analysis", Connection = "ServiceBusConnection")]
    PhotoUploadEvent photoEvent,
    ILogger log)
{
    log.LogInformation("Processing photo: {PhotoId}", photoEvent.PhotoId);

    switch (photoEvent.PhotoType)
    {
        case PhotoType.TireCondition:
            var tireReport = await _photoAnalysisService
                .AnalyzeTirePhotoAsync(photoEvent.BlobUri, photoEvent.WorkOrderId);

            if (tireReport.ReplacementRecommended)
            {
                await _alertService.CreateAlertAsync(new DispatchAlert
                {
                    AlertType = "TIRE_REPLACEMENT_RECOMMENDED",
                    WorkOrderId = photoEvent.WorkOrderId,
                    Message = $"AI analysis recommends tire replacement: {tireReport.Reason}"
                });
            }
            break;

        case PhotoType.BeforeService:
        case PhotoType.AfterService:
            await _photoAnalysisService.AnalyzeDamagePhotosAsync(
                new[] { photoEvent.BlobUri },
                photoEvent.WorkOrderId);
            break;
    }
}

Azure Bot Service Integration

Purpose: Conversational interface for customers and internal users, integrated with Genesys Cloud CCaaS.

Bot Capabilities

Capability Channel Description
Status Inquiry SMS, Voice IVR, Web Chat "Where is my technician?" - Returns current ETA and status
Appointment Scheduling SMS, Web Chat Schedule or reschedule service appointments
FAQ/Knowledge Base All channels Answer common questions about services, pricing, hours
Issue Reporting SMS, Web Chat Report issues with recent service
Escalation to Agent All channels Transfer to human agent when needed

Bot Conversation Flow

sequenceDiagram
    participant Customer
    participant Bot Service
    participant Backend

    Customer->>Bot Service: "Where is my tech?"
    Bot Service->>Backend: Identify intent (GPT-4o)
    Backend-->>Bot Service: 
    Bot Service->>Backend: Look up customer (phone #)
    Backend-->>Bot Service: 
    Bot Service->>Backend: Get active work order
    Backend-->>Bot Service: 
    Bot Service->>Backend: Get technician location (GeoTab)
    Backend-->>Bot Service: 
    Bot Service->>Backend: Calculate ETA (Azure Maps)
    Backend-->>Bot Service: 
    Bot Service->>Customer: "John is 12 min away"

Bot Implementation

public class DispatchBot : ActivityHandler
{
    private readonly OpenAIClient _openAIClient;
    private readonly IWorkOrderService _workOrderService;
    private readonly IGeoTabService _geoTabService;
    private readonly ICustomerService _customerService;

    protected override async Task OnMessageActivityAsync(
        ITurnContext<IMessageActivity> turnContext,
        CancellationToken cancellationToken)
    {
        var userMessage = turnContext.Activity.Text;
        var userPhone = GetPhoneFromChannel(turnContext);

        // Determine intent using Azure OpenAI
        var intent = await DetermineIntentAsync(userMessage);

        switch (intent.Type)
        {
            case "StatusInquiry":
                await HandleStatusInquiryAsync(turnContext, userPhone);
                break;

            case "Schedule":
                await HandleSchedulingAsync(turnContext, userPhone, intent.Entities);
                break;

            case "FAQ":
                await HandleFAQAsync(turnContext, intent.Question);
                break;

            case "Escalate":
            case "Unknown":
                await TransferToAgentAsync(turnContext);
                break;
        }
    }

    private async Task HandleStatusInquiryAsync(
        ITurnContext context,
        string customerPhone)
    {
        // Look up customer by phone
        var customer = await _customerService.FindByPhoneAsync(customerPhone);
        if (customer == null)
        {
            await context.SendActivityAsync(
                "I couldn't find an account with this phone number. " +
                "Would you like to speak with an agent?");
            return;
        }

        // Get active work order
        var workOrder = await _workOrderService
            .GetActiveWorkOrderForCustomerAsync(customer.Id);

        if (workOrder == null)
        {
            await context.SendActivityAsync(
                "I don't see any active service requests for your account. " +
                "Would you like to schedule a service?");
            return;
        }

        // Get technician location and ETA
        if (workOrder.AssignedTechnicianId != null)
        {
            var location = await _geoTabService
                .GetCurrentLocationAsync(workOrder.AssignedTechnician.VehicleId);
            var eta = await CalculateETAAsync(location, workOrder.Address);

            var response = workOrder.Status switch
            {
                WorkOrderStatus.Assigned => 
                    $"Your technician {workOrder.AssignedTechnician.Name} is scheduled " +
                    $"to arrive at {workOrder.ScheduledTime:h:mm tt}.",

                WorkOrderStatus.EnRoute =>
                    $"{workOrder.AssignedTechnician.Name} is on the way! " +
                    $"Estimated arrival in {eta.Minutes} minutes.",

                WorkOrderStatus.OnSite =>
                    $"{workOrder.AssignedTechnician.Name} is currently on-site " +
                    $"and working on your service.",

                _ => $"Your service request is {workOrder.Status}."
            };

            await context.SendActivityAsync(response);
        }
        else
        {
            await context.SendActivityAsync(
                "Your service request has been received and a technician " +
                "will be assigned shortly. We'll notify you when they're on the way.");
        }
    }

    private async Task TransferToAgentAsync(ITurnContext context)
    {
        // Initiate Genesys Cloud agent handoff
        await context.SendActivityAsync(
            "Let me connect you with a customer service representative.");

        var handoffContext = new GenesysHandoffContext
        {
            CustomerPhone = GetPhoneFromChannel(context),
            ConversationHistory = await GetConversationHistoryAsync(context),
            Intent = "agent_requested"
        };

        // Trigger Genesys handoff via event
        await _serviceBus.PublishAsync(new BotHandoffEvent(handoffContext));
    }
}

Genesys Cloud Bot Channel Configuration

{
  "botConfiguration": {
    "name": "PompsDispatchBot",
    "channels": [
      {
        "type": "sms",
        "enabled": true,
        "phoneNumbers": ["+1-800-POMPS-SVC"],
        "welcomeMessage": "Thanks for contacting Pomp's! How can I help you today?"
      },
      {
        "type": "voiceIVR",
        "enabled": true,
        "dtmfMapping": {
          "1": "StatusInquiry",
          "2": "Schedule",
          "0": "Escalate"
        }
      },
      {
        "type": "webChat",
        "enabled": true,
        "widgetConfig": {
          "position": "bottom-right",
          "primaryColor": "#1E40AF"
        }
      }
    ],
    "handoffConfig": {
      "provider": "GenesysCloud",
      "queueName": "CustomerService",
      "skillsRouting": true
    }
  }
}

AI Service Monitoring

Metric Target Alert Threshold
OpenAI Response Time < 2 seconds > 5 seconds
Vision Analysis Time < 3 seconds > 8 seconds
Bot Intent Accuracy > 90% < 80%
Bot Escalation Rate < 25% > 40%
Photo Analysis Queue Depth < 50 > 200

Summary

The Pomp's Dispatch Center integration architecture provides a robust, event-driven framework for synchronizing data across MaddenCo ERP, REACH Portal, Genesys Cloud, and Azure AI services. Key design principles include:

Principle Implementation
Source of Truth Clarity MaddenCo for billing, Pomp's for dispatch, documented ownership
Proxy API Layer Pomp's-provided abstraction over external APIs
Event-Driven Architecture Azure Service Bus for loose coupling and real-time updates
Polling + Push Hybrid Poll REACH for new data, push updates in real-time
Graceful Degradation Queue operations when integrations unavailable
Comprehensive Monitoring Health dashboards, metrics, alerting
Security First OAuth 2.0, API keys, HMAC webhooks, Key Vault secrets