/** * 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 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{ 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 results = Messaging.sendEmail(new List{ 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{ 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 headers = extractHeaders(rr); List> 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 headers = new List{'Report Name', 'Report ID', 'Format', 'Generated On'}; List> rows = new List>(); rows.add(new List{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 * (API names of columns), then looks up labels via ReportExtendedMetadata. */ @TestVisible private List extractHeaders(Reports.ReportResults rr) { List headers = new List(); if (rr == null) return headers; try { // getDetailColumns() returns List of API names List columnNames = rr.getReportMetadata().getDetailColumns(); // Get extended metadata for labels Reports.ReportExtendedMetadata extMeta = rr.getReportExtendedMetadata(); Map 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> extractRows(Reports.ReportResults rr) { List> result = new List>(); if (rr == null || rr.getFactMap() == null) return result; try { // Serialize the entire ReportResults to JSON and parse it String jsonStr = JSON.serialize(rr); Map reportData = (Map) 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> extractRowsFromReportData(Map reportData) { List> result = new List>(); // 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 factMap = (Map) 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 factData = (Map) 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 rows = (List) rowsObj; System.debug('Fact [' + key + '] has ' + rows.size() + ' rows'); for (Object row : rows) { if (row == null) continue; String rowJson = JSON.serialize(row); Map rowData = (Map) 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 dataCells = (List) dataCellsObj; List rowValues = new List(); for (Object cell : dataCells) { String cellJson = JSON.serialize(cell); Map cellData = (Map) 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 columns = extractHeaders(rr); List> rows = extractRows(rr); // Ensure we have at least some data if (columns.isEmpty()) { columns = new List{'Report Name', 'Report ID', 'Format', 'Generated On'}; rows = new List>(); rows.add(new List{reportDevName, reportId, format, DateTime.now().format()}); } String xml = ''; xml += '\n'; xml += '\n'; xml += '\n'; } xml += '\n'; // Data rows for (List row : rows) { xml += '\n'; for (String cell : row) { xml += '' + escapeXml(cell) + '\n'; } xml += '\n'; } xml += '\n'; xml += '\n'; xml += ''; return xml; } @TestVisible private String convertToCsv(Reports.ReportResults rr) { List columns = extractHeaders(rr); List> rows = extractRows(rr); if (columns.isEmpty()) { columns = new List{'Report Name', 'Report ID', 'Format', 'Generated On'}; rows = new List>(); rows.add(new List{reportDevName, reportId, format, DateTime.now().format()}); } String csv = '\uFEFF'; // BOM for UTF-8 csv += String.join(columns, ',') + '\n'; for (List row : rows) { List rowStrings = new List(); 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('&', '&'); escaped = escaped.replace('<', '<'); escaped = escaped.replace('>', '>'); escaped = escaped.replace('"', '"'); escaped = escaped.replace('\'', '''); return escaped; } }