SyncMadeEasy/force-app/main/default/classes/ScheduledShopifyReport.cls

480 lines
19 KiB
OpenEdge ABL

/**
* ScheduledShopifyReport
* -------------------------------------------------------------------------
* Schedulable that runs a Salesforce Report and emails the rendered output
* with a direct download link for the file in the selected format.
*
* FIXED: Uses only valid Salesforce Reports API types. No invalid types
* like Reports.ReportColumn or getClass() on ReportFact.
* -------------------------------------------------------------------------
*/
public with sharing class ScheduledShopifyReport implements Schedulable {
public Id reportId;
public String reportDevName;
public String format; // PDF | EXCEL | CSV
public List<String> recipients;
public void execute(SchedulableContext sc) {
if (reportId == null || recipients == null || recipients.isEmpty()) {
return;
}
try {
// Run the report WITH detail rows
Reports.ReportResults rr = Reports.ReportManager.runReport(reportId, true);
// DEBUG: Log report structure to verify data
System.debug('=== ScheduledShopifyReport DEBUG ===');
System.debug('Report ID: ' + reportId);
System.debug('Report DevName: ' + reportDevName);
System.debug('Format: ' + format);
System.debug('Fact Map Keys: ' + rr.getFactMap().keySet());
for (String key : rr.getFactMap().keySet()) {
Reports.ReportFact fact = rr.getFactMap().get(key);
System.debug('Fact [' + key + '] aggregates: ' + fact.getAggregates());
// Use instanceof instead of getClass()
if (fact instanceof Reports.ReportFactWithDetails) {
Reports.ReportFactWithDetails d = (Reports.ReportFactWithDetails) fact;
System.debug('Fact [' + key + '] rows: ' + d.getRows().size());
}
}
System.debug('=====================================');
// Generate the file and get download link
String downloadUrl = generateAndUploadFile(rr);
// Build email body with download link
String body = 'Hi,\n\n' +
'Your scheduled Shopify Sync report "' + reportDevName + '" ran successfully.\n' +
'Format: ' + format + '\n\n' +
'Download your report: ' + downloadUrl + '\n\n' +
'— Shopify Sync Dashboard\n';
Messaging.SingleEmailMessage msg = new Messaging.SingleEmailMessage();
msg.setToAddresses(recipients);
msg.setSubject('[Shopify Sync] Scheduled report: ' + reportDevName);
msg.setPlainTextBody(body);
// Also attach the file directly
Messaging.EmailFileAttachment att = buildReportAttachment(rr);
if (att != null) {
msg.setFileAttachments(new List<Messaging.EmailFileAttachment>{ att });
}
// FIX: Messaging.sendEmail() does NOT throw on delivery failure (bad
// deliverability settings, org email limits, bounced addresses, etc.) —
// it just returns a result array with success=false. Previously that
// return value was discarded, so a failed send looked identical to a
// successful one: the job "ran" but nobody ever got the report.
List<Messaging.SendEmailResult> results =
Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{ msg });
if (results == null || results.isEmpty() || !results[0].isSuccess()) {
String sendErr = (results != null && !results.isEmpty() && results[0].getErrors() != null && !results[0].getErrors().isEmpty())
? results[0].getErrors()[0].getMessage()
: 'Unknown email delivery failure';
throw new ScheduledReportException(
'Report ran but email delivery failed for reportId=' + reportId + ': ' + sendErr);
}
} catch (Exception e) {
System.debug(LoggingLevel.ERROR,
'ScheduledShopifyReport failed for reportId=' + reportId + ': ' + e.getMessage());
System.debug(LoggingLevel.ERROR, 'Stack trace: ' + e.getStackTraceString());
// FIX: previously the failure died here — nothing left this method,
// so the schedule owner had no way to know a run had failed short of
// reading debug logs at the exact moment the job fired. Now we make a
// best-effort attempt to tell them via email instead of failing silent.
notifyFailure(e);
}
}
/**
* Best-effort failure notification — sent as a SEPARATE email so a failure
* in report generation/attachment building can't also break this. Any
* exception here (e.g. deliverability blocked entirely) is swallowed: we've
* already done everything reasonably possible to surface the problem.
*/
@TestVisible
private void notifyFailure(Exception e) {
try {
if (recipients == null || recipients.isEmpty()) return;
Messaging.SingleEmailMessage errMsg = new Messaging.SingleEmailMessage();
errMsg.setToAddresses(recipients);
errMsg.setSubject('[Shopify Sync] Scheduled report FAILED: ' + reportDevName);
errMsg.setPlainTextBody(
'Hi,\n\n' +
'Your scheduled Shopify Sync report "' + reportDevName + '" did not complete successfully.\n' +
'Reason: ' + e.getMessage() + '\n\n' +
'Check that the report still exists, is accessible to the user who scheduled it, ' +
'and that Email Deliverability (Setup) allows sending to external addresses.\n\n' +
'— Shopify Sync Dashboard\n');
Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{ errMsg });
} catch (Exception notifyEx) {
System.debug(LoggingLevel.ERROR,
'ScheduledShopifyReport failure-notification also failed: ' + notifyEx.getMessage());
}
}
public class ScheduledReportException extends Exception {}
// ==================== FILE GENERATION ====================
@TestVisible
private String generateAndUploadFile(Reports.ReportResults rr) {
try {
String base64Content = getFileAsBase64(rr);
if (String.isBlank(base64Content)) {
return URL.getOrgDomainUrl().toExternalForm() + '/' + reportId;
}
String timestamp = DateTime.now().format('yyyyMMdd_HHmmss');
String fileName = reportDevName + '_' + timestamp + getFileExtension();
return ShopifySyncDashboardController.createPublicFileLink(fileName, base64Content);
} catch (Exception e) {
System.debug('Error generating download link: ' + e.getMessage());
return URL.getOrgDomainUrl().toExternalForm() + '/' + reportId;
}
}
@TestVisible
private String getFileAsBase64(Reports.ReportResults rr) {
if (format == 'PDF') {
return getPdfAsBase64(rr);
} else if (format == 'EXCEL') {
return getExcelAsBase64(rr);
} else if (format == 'CSV') {
return getCsvAsBase64(rr);
}
return null;
}
// ==================== PDF GENERATION ====================
@TestVisible
private String getPdfAsBase64(Reports.ReportResults rr) {
try {
List<String> headers = extractHeaders(rr);
List<List<String>> rows = extractRows(rr);
String title = reportDevName + ' Report';
return ShopifySyncDashboardController.generatePdfBase64(headers, rows, title);
} catch (Exception e) {
System.debug('PDF generation failed: ' + e.getMessage());
return getSummaryPdf();
}
}
@TestVisible
private String getSummaryPdf() {
try {
List<String> headers = new List<String>{'Report Name', 'Report ID', 'Format', 'Generated On'};
List<List<String>> rows = new List<List<String>>();
rows.add(new List<String>{reportDevName, reportId, format, DateTime.now().format()});
return ShopifySyncDashboardController.generatePdfBase64(headers, rows, reportDevName + ' Report Summary');
} catch (Exception e) {
return null;
}
}
// ==================== EXCEL GENERATION ====================
@TestVisible
private String getExcelAsBase64(Reports.ReportResults rr) {
try {
String excelXml = convertToExcel(rr);
if (String.isBlank(excelXml)) return null;
Blob excelBlob = Blob.valueOf(excelXml);
return EncodingUtil.base64Encode(excelBlob);
} catch (Exception e) {
System.debug('Excel generation failed: ' + e.getMessage());
return null;
}
}
// ==================== CSV GENERATION ====================
@TestVisible
private String getCsvAsBase64(Reports.ReportResults rr) {
try {
String csv = convertToCsv(rr);
if (String.isBlank(csv)) return null;
Blob csvBlob = Blob.valueOf(csv);
return EncodingUtil.base64Encode(csvBlob);
} catch (Exception e) {
System.debug('CSV generation failed: ' + e.getMessage());
return null;
}
}
// ==================== EMAIL ATTACHMENT ====================
@TestVisible
private Messaging.EmailFileAttachment buildReportAttachment(Reports.ReportResults rr) {
try {
String base64Content = getFileAsBase64(rr);
if (String.isBlank(base64Content)) return null;
Blob fileBlob = EncodingUtil.base64Decode(base64Content);
Messaging.EmailFileAttachment att = new Messaging.EmailFileAttachment();
String timestamp = DateTime.now().format('yyyyMMdd_HHmmss');
att.setFileName(reportDevName + '_' + timestamp + getFileExtension());
att.setBody(fileBlob);
if (format == 'PDF') {
att.setContentType('application/pdf');
} else if (format == 'EXCEL') {
att.setContentType('application/vnd.ms-excel');
} else if (format == 'CSV') {
att.setContentType('text/csv');
}
return att;
} catch (Exception e) {
System.debug('Attachment generation failed: ' + e.getMessage());
return null;
}
}
@TestVisible
private String getFileExtension() {
if (format == 'PDF') return '.pdf';
if (format == 'EXCEL') return '.xls';
if (format == 'CSV') return '.csv';
return '.txt';
}
// ==================== REPORT DATA EXTRACTION ====================
// FIXED: Uses only valid Salesforce API types
/**
* Extract column headers from ReportResults.
* Uses Reports.ReportMetadata.getDetailColumns() which returns List<String>
* (API names of columns), then looks up labels via ReportExtendedMetadata.
*/
@TestVisible
private List<String> extractHeaders(Reports.ReportResults rr) {
List<String> headers = new List<String>();
if (rr == null) return headers;
try {
// getDetailColumns() returns List<String> of API names
List<String> columnNames = rr.getReportMetadata().getDetailColumns();
// Get extended metadata for labels
Reports.ReportExtendedMetadata extMeta = rr.getReportExtendedMetadata();
Map<String, Reports.DetailColumn> detailCols = extMeta.getDetailColumnInfo();
for (String colName : columnNames) {
if (detailCols.containsKey(colName)) {
headers.add(detailCols.get(colName).getLabel());
} else {
headers.add(colName); // fallback to API name
}
}
} catch (Exception e) {
System.debug('Error extracting headers: ' + e.getMessage());
}
if (headers.isEmpty()) {
headers.add('Report Data');
}
return headers;
}
/**
* Extract row data from ReportResults using JSON serialization.
* This is the most reliable way to access nested ReportDataCell structures.
*/
@TestVisible
private List<List<String>> extractRows(Reports.ReportResults rr) {
List<List<String>> result = new List<List<String>>();
if (rr == null || rr.getFactMap() == null) return result;
try {
// Serialize the entire ReportResults to JSON and parse it
String jsonStr = JSON.serialize(rr);
Map<String, Object> reportData = (Map<String, Object>) JSON.deserializeUntyped(jsonStr);
return extractRowsFromReportData(reportData);
} catch (Exception e) {
System.debug('Error extracting rows: ' + e.getMessage());
System.debug('Stack trace: ' + e.getStackTraceString());
}
return result;
}
/**
* Walks an already-deserialized report JSON payload (factMap -> rows ->
* dataCells/cells -> label/value) and returns the extracted row values.
* Split out from extractRows() so this parsing logic can be unit-tested
* with hand-built maps — Reports.ReportResults has no public constructor
* and can't be mocked directly from Apex test code.
*/
@TestVisible
private List<List<String>> extractRowsFromReportData(Map<String, Object> reportData) {
List<List<String>> result = new List<List<String>>();
// Navigate to factMap
Object factMapObj = reportData != null ? reportData.get('factMap') : null;
if (factMapObj == null) {
System.debug('No factMap found in report data');
return result;
}
Map<String, Object> factMap = (Map<String, Object>) factMapObj;
System.debug('Processing ' + factMap.size() + ' fact map entries');
for (String key : factMap.keySet()) {
Object fact = factMap.get(key);
if (fact == null) continue;
String factJson = JSON.serialize(fact);
Map<String, Object> factData = (Map<String, Object>) JSON.deserializeUntyped(factJson);
// Try to find rows in the fact
Object rowsObj = factData.get('rows');
if (rowsObj == null) {
System.debug('Fact [' + key + '] has no rows');
continue;
}
List<Object> rows = (List<Object>) rowsObj;
System.debug('Fact [' + key + '] has ' + rows.size() + ' rows');
for (Object row : rows) {
if (row == null) continue;
String rowJson = JSON.serialize(row);
Map<String, Object> rowData = (Map<String, Object>) JSON.deserializeUntyped(rowJson);
// Each row has dataCells array
Object dataCellsObj = rowData.get('dataCells');
if (dataCellsObj == null) {
dataCellsObj = rowData.get('cells');
if (dataCellsObj == null) continue;
}
List<Object> dataCells = (List<Object>) dataCellsObj;
List<String> rowValues = new List<String>();
for (Object cell : dataCells) {
String cellJson = JSON.serialize(cell);
Map<String, Object> cellData = (Map<String, Object>) JSON.deserializeUntyped(cellJson);
// Prefer label (display value) over value (raw API value)
Object label = cellData.get('label');
if (label != null) {
rowValues.add(String.valueOf(label));
} else {
Object value = cellData.get('value');
rowValues.add(value != null ? String.valueOf(value) : '');
}
}
if (!rowValues.isEmpty()) {
result.add(rowValues);
}
}
}
System.debug('Total rows extracted: ' + result.size());
return result;
}
// ==================== FORMAT CONVERTERS ====================
@TestVisible
private String convertToExcel(Reports.ReportResults rr) {
List<String> columns = extractHeaders(rr);
List<List<String>> rows = extractRows(rr);
// Ensure we have at least some data
if (columns.isEmpty()) {
columns = new List<String>{'Report Name', 'Report ID', 'Format', 'Generated On'};
rows = new List<List<String>>();
rows.add(new List<String>{reportDevName, reportId, format, DateTime.now().format()});
}
String xml = '';
xml += '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<?mso-application progid="Excel.Sheet"?>\n';
xml += '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"\n';
xml += ' xmlns:o="urn:schemas-microsoft-com:office:office"\n';
xml += ' xmlns:x="urn:schemas-microsoft-com:office:excel"\n';
xml += ' xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">\n';
xml += '<Worksheet ss:Name="Report">\n';
xml += '<Table>\n';
// Header row
xml += '<Row>\n';
for (String col : columns) {
xml += '<Cell><Data ss:Type="String">' + escapeXml(col) + '</Data></Cell>\n';
}
xml += '</Row>\n';
// Data rows
for (List<String> row : rows) {
xml += '<Row>\n';
for (String cell : row) {
xml += '<Cell><Data ss:Type="String">' + escapeXml(cell) + '</Data></Cell>\n';
}
xml += '</Row>\n';
}
xml += '</Table>\n';
xml += '</Worksheet>\n';
xml += '</Workbook>';
return xml;
}
@TestVisible
private String convertToCsv(Reports.ReportResults rr) {
List<String> columns = extractHeaders(rr);
List<List<String>> rows = extractRows(rr);
if (columns.isEmpty()) {
columns = new List<String>{'Report Name', 'Report ID', 'Format', 'Generated On'};
rows = new List<List<String>>();
rows.add(new List<String>{reportDevName, reportId, format, DateTime.now().format()});
}
String csv = '\uFEFF'; // BOM for UTF-8
csv += String.join(columns, ',') + '\n';
for (List<String> row : rows) {
List<String> rowStrings = new List<String>();
for (String cell : row) {
String value = cell != null ? cell : '';
if (value.contains(',') || value.contains('"') || value.contains('\n')) {
value = '"' + value.replace('"', '""') + '"';
}
rowStrings.add(value);
}
csv += String.join(rowStrings, ',') + '\n';
}
return csv;
}
@TestVisible
private String escapeXml(String value) {
if (value == null) return '';
String escaped = value;
escaped = escaped.replace('&', '&amp;');
escaped = escaped.replace('<', '&lt;');
escaped = escaped.replace('>', '&gt;');
escaped = escaped.replace('"', '&quot;');
escaped = escaped.replace('\'', '&apos;');
return escaped;
}
}