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:
- Poll REACH for new work orders, comments, and updates
- Push to REACH dispatch status, technician assignments, completion, and photos
- 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=®ion= |
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 |