interface/modules/custom_modules/oe-module-faxsms/FAX_QUEUE_STORAGE_REFACTORING.md
This document describes the comprehensive refactoring of the OpenEMR FaxSMS module to standardize inbound fax storage across all fax providers (EtherFax and SignalWire). The refactoring ensures consistent queue storage, automatic patient matching, and proper document integration.
Before this refactoring, the module had inconsistent approaches to storing inbound faxes:
The centralized service for managing fax documents:
FaxDocumentService
├── storeFaxDocument() # Store fax with automatic patient matching
├── assignFaxToPatient() # Assign unassigned fax to patient
├── findPatientByPhone() # Auto-match patient by phone number
├── getUnassignedFaxes() # List unassigned received faxes
├── getFaxDocument() # Retrieve fax details
└── deleteFaxDocument() # Mark fax as deleted
Key Features:
Both EtherFax and SignalWire now follow the same storage pattern:
Incoming Fax
↓
[Download/Retrieve Media]
↓
[Attempt Patient Matching by Phone]
↓
[Store Document via FaxDocumentService]
↓
[Insert/Update oe_faxsms_queue]
├── job_id (provider SID)
├── status (received, delivered, failed, etc.)
├── direction (inbound)
├── patient_id (if matched)
├── document_id (OpenEMR document)
├── media_path (stored file location)
└── details_json (complete metadata)
The oe_faxsms_queue table now includes:
| Column | Type | Purpose |
|---|---|---|
| id | int | Primary key |
| job_id | text | Provider's fax ID/SID |
| status | varchar(50) | Fax status (received, queued, delivered, failed) |
| direction | varchar(20) | inbound or outbound |
| site_id | varchar(63) | Multi-site support |
| patient_id | int | Assigned patient (if matched) |
| document_id | int | OpenEMR document reference |
| media_path | longtext | File system path to fax media |
| details_json | longtext | Complete fax metadata |
| calling_number | tinytext | Sender's phone number |
| called_number | tinytext | Recipient's phone number |
| mime | tinytext | MIME type of document |
| date | datetime | Queue record creation time |
| receive_date | datetime | Fax received time |
| deleted | int | Soft delete flag |
| uid | int | User ID who processed fax |
| account | tinytext | Provider account identifier |
Key Indexes:
job_id - Fast lookup by provider SIDsite_id - Multi-site filteringpatient_id - Patient-based queriesuid, receive_date - User-based timeline queriesHandles standardized inbound fax storage:
private function storeInboundFax(array $faxData): void
{
// 1. Download media using oeHttp with Bearer token
$mediaContent = $this->downloadFaxMediaContent($mediaUrl);
// 2. Initialize FaxDocumentService
$faxService = new FaxDocumentService($siteId);
// 3. Attempt patient matching by phone
$patientId = $faxService->findPatientByPhone($fromNumber);
// 4. Store document
$result = $faxService->storeFaxDocument(
$faxSid,
$mediaContent,
$fromNumber,
$patientId,
$mimeType
);
// 5. Insert/update queue record
QueryUtils::sqlStatementThrowException($sql, [
// ... parameters with document_id and media_path
]);
}
Secure media download with proper authentication:
private function downloadFaxMediaContent(string $mediaUrl): ?string
{
// 1. Validate URL (SSRF protection)
if (!$this->isValidSignalWireUrl($mediaUrl)) {
return null;
}
// 2. Get and decrypt credentials
$apiToken = $this->getDecryptedApiToken();
// 3. Download using oeHttp with Bearer token
$httpRequest = oeHttpRequest::newArgs(oeHttp::client());
$httpRequest->usingHeaders([
'Authorization' => 'Bearer ' . $apiToken
]);
$response = $httpRequest->get($mediaUrl);
return $response->body();
}
Prevents SSRF attacks by whitelisting SignalWire domains:
private function isValidSignalWireUrl(string $url): bool
{
$parsedUrl = parse_url($url);
// Only HTTPS
if ($parsedUrl['scheme'] !== 'https') {
return false;
}
// Whitelist SignalWire domains
$allowedDomains = [
'files.signalwire.com',
'api.signalwire.com'
];
// Check host
$host = strtolower($parsedUrl['host']);
foreach ($allowedDomains as $domain) {
if ($host === $domain || str_ends_with($host, '.' . $domain)) {
return true;
}
}
return false;
}
Outbound faxes now stored in queue after successful send:
$fax = $this->client->fax->v1->faxes->create([
'to' => $phone,
'from' => $this->faxNumber,
'mediaUrl' => $mediaUrl
]);
// Build metadata
$faxData = [
'sid' => $fax->sid,
'from' => $this->faxNumber,
'to' => $phone,
'direction' => 'outbound',
'status' => $fax->status ?? 'queued',
'recipient_name' => $recipientName,
'sent_by' => $user['username'],
'dateCreated' => date('Y-m-d H:i:s')
];
// Store in queue
QueryUtils::sqlStatementThrowException($sql, [
$uid, $fax->sid, $this->faxNumber, $phone,
json_encode($faxData), 'outbound', $fax->status ?? 'queued', $siteId
]);
Simplified to only handle inbound faxes:
private function upsertFaxFromSignalWire($fax): void
{
// Only process inbound faxes
if ($fax->direction !== 'inbound') {
return;
}
// Fetch fresh status from API
$freshFax = $this->client->fax->v1->faxes->getContext($jobId)->fetch();
// Build standardized fax data
$faxData = [
'sid' => $jobId,
'from' => $from,
'to' => $to,
'status' => $status,
'direction' => 'inbound',
'numPages' => $numPages,
'mediaUrl' => $mediaUrl,
'mimeType' => 'application/pdf'
];
// Use standardized storage method
$this->storeInboundFax($faxData);
}
Now uses FaxDocumentService for consistent handling:
public function insertFaxQueue($faxDetails): int
{
try {
// 1. Decode fax content
$mediaContent = base64_decode((string)$faxDetails->FaxImage);
// 2. Initialize FaxDocumentService
$faxService = new FaxDocumentService($siteId);
// 3. Auto-match patient
$patientId = $faxService->findPatientByPhone($fromNumber);
// 4. Store document
$result = $faxService->storeFaxDocument(
$jobId,
$mediaContent,
$fromNumber,
$patientId,
$docType
);
// 5. Insert queue record with document references
QueryUtils::sqlStatementThrowException($sql, [
$uid, $account, $jobId, $received,
$fromNumber, $toNumber, $docType,
json_encode($faxData),
'received', 'inbound',
$siteId, $patientId, $documentId, $mediaPath
]);
return (int)$recordId;
} catch (Exception $e) {
error_log("EtherFaxActions.insertFaxQueue(): ERROR - " . $e->getMessage());
throw $e;
}
}
All deprecated database functions replaced with QueryUtils:
| Old Function | New Method | Location |
|---|---|---|
| sqlStatement | QueryUtils::fetchRecords() | getNotificationLog, fetchFaxQueue |
| sqlFetchArray | (replaced with foreach) | getNotificationLog, fetchFaxQueue |
| sqlQuery | QueryUtils::querySingleRow() | getUser, getAssumedPatientId, fetchFaxFromQueue, fetchQueueCount, setFaxDeleted |
| sqlInsert | QueryUtils::sqlStatementThrowException() | insertFaxQueue, insertSentFaxQueue |
All queries now filter by site_id:
$siteId = $_SESSION['site_id'] ?? 'default';
// Query includes site_id filter
$result = QueryUtils::querySingleRow(
"SELECT * FROM oe_faxsms_queue WHERE job_id = ? AND site_id = ?",
[$jobId, $siteId]
);
FaxDocumentService::findPatientByPhone() attempts to match patients using multiple phone number patterns:
Input Phone: +1 (555) 123-4567
Patterns Tried (in order):
1. 5551234567 (digits only)
2. +15551234567 (E.164 format)
3. 15551234567 (with country code)
4. 555-123-4567 (formatted)
5. (555) 123-4567 (formatted with parens)
Database Search:
- phone_cell LIKE '%pattern%'
- phone_home LIKE '%pattern%'
- phone_biz LIKE '%pattern%'
Returns: Patient ID if found, 0 otherwise
try {
// Business logic
} catch (FaxDocumentException $e) {
error_log("Service: Error - " . $e->getMessage());
throw $e; // Propagate or handle gracefully
} catch (Exception $e) {
error_log("Service: Unexpected error - " . $e->getMessage());
// Continue with queue insert even if document storage fails
}
All operations log key details:
INFO: "Successfully stored fax {jobId} (patient_id={pid}, document_id={docId})"
WARN: "Failed to download media for fax {jobId}"
ERROR: "insertFaxQueue(): ERROR - {message}"
DEBUG: "Processing fax sid={sid}, from={from}, direction={direction}"
For existing installations, the schema changes are non-breaking:
Run migration:
# The module installer automatically applies SQL migrations
# Or manually:
mysql -u user -p database < table.sql
KEY `job_id` (`job_id`(255)) -- Fast SID lookup
KEY `site_id` (`site_id`) -- Multi-site filtering
KEY `patient_id` (`patient_id`) -- Patient-based queries
KEY `uid` (`uid`,`receive_date`) -- User timeline
All code follows:
Issue: Faxes not matching to patients
Issue: Documents not created
Issue: Multi-site faxes mixed up
Enable detailed logging:
error_log("DEBUG: storeInboundFax() - Processing fax {$faxSid}");
error_log("DEBUG: downloadFaxMediaContent() - Downloaded " . strlen($content) . " bytes");
error_log("DEBUG: Patient match result: {$patientId}");
error_log("DEBUG: Document created: {$documentId}");
GNU General Public License v3 (GPL-3.0)
See LICENSE for details.