/** * ScheduleReportController * ------------------------------------------------------------------------- * Backend for the "Schedule Report" button on shopifySyncDashboard LWC. * * Architecture notes (Sumit, May 2026): * Salesforce's native AnalyticsSubscription / ReportSubscription objects * are NOT directly DML-insertable from Apex. The standards-compliant way * to schedule a Salesforce Report from Apex is: * 1) Build a cron expression from user inputs (frequency + time). * 2) System.schedule() a Schedulable class. * 3) The Schedulable runs Reports.ReportManager.runReport() and emails * the result to the recipients. * * This controller does (1) and (2). The Schedulable (ScheduledShopifyReport) * does (3). Together they reproduce the behavior of a standard * "Subscribe to Report" without requiring the user to switch UIs. * * Security: * - @AuraEnabled is exposed only to users with the right permission set. * - Recipients are validated as emails. * - Report DeveloperName is validated against ReportDefinition before * scheduling so we fail fast on typos. * ------------------------------------------------------------------------- */ public with sharing class ScheduleReportController { @TestVisible private static final Set ALLOWED_FREQUENCIES = new Set{ 'Daily', 'Weekly', 'Monthly', 'Yearly' }; @TestVisible private static final Set ALLOWED_FORMATS = new Set{ 'PDF', 'EXCEL', 'CSV' }; /** Lightweight option for the LWC report picklist. */ public class ReportOption { @AuraEnabled public String label; // Report.Name — what the user sees @AuraEnabled public String developerName; // Report.DeveloperName — what gets scheduled } /** Existing scheduled-report job, for the LWC "Existing schedules" list. */ public class ScheduledJobInfo { @AuraEnabled public String id; // CronTrigger Id (used for delete) @AuraEnabled public String jobName; // full CronJobDetail.Name @AuraEnabled public String reportDeveloperName; // parsed from the job name @AuraEnabled public String reportName; // Report.Name (falls back to DeveloperName) @AuraEnabled public String cronExpression; @AuraEnabled public String state; // WAITING / ACQUIRED / ... @AuraEnabled public Datetime nextFireTime; @AuraEnabled public Datetime previousFireTime; } // All jobs created by scheduleReport() share this prefix — it scopes both // the listing query and the delete guard to OUR jobs only. @TestVisible private static final String JOB_NAME_PREFIX = 'ShopifySyncReport_'; /** * Lists every report schedule created from this dashboard (job name prefix * scoped). Non-cacheable on purpose: the list must be fresh right after a * create or delete. */ @AuraEnabled public static List getScheduledReports() { List jobs = [ SELECT Id, CronExpression, NextFireTime, PreviousFireTime, State, CronJobDetail.Name FROM CronTrigger WHERE CronJobDetail.Name LIKE :JOB_NAME_PREFIX + '%' ORDER BY NextFireTime ASC LIMIT 100 ]; // Job name format: ShopifySyncReport__ — // strip prefix + trailing timestamp to recover the DeveloperName. Map devNameByJob = new Map(); Set devNames = new Set(); for (CronTrigger ct : jobs) { String n = ct.CronJobDetail.Name.removeStart(JOB_NAME_PREFIX); Integer us = n.lastIndexOf('_'); String dev = (us > 0) ? n.substring(0, us) : n; devNameByJob.put(ct.Id, dev); devNames.add(dev); } Map labelByDev = new Map(); for (Report r : [SELECT Name, DeveloperName FROM Report WHERE DeveloperName IN :devNames]) { labelByDev.put(r.DeveloperName, r.Name); } List out = new List(); for (CronTrigger ct : jobs) { ScheduledJobInfo j = new ScheduledJobInfo(); j.id = ct.Id; j.jobName = ct.CronJobDetail.Name; j.reportDeveloperName = devNameByJob.get(ct.Id); j.reportName = labelByDev.containsKey(j.reportDeveloperName) ? labelByDev.get(j.reportDeveloperName) : j.reportDeveloperName; j.cronExpression = ct.CronExpression; j.state = ct.State; j.nextFireTime = ct.NextFireTime; j.previousFireTime = ct.PreviousFireTime; out.add(j); } return out; } /** * Aborts a scheduled-report job. Guarded to OUR job-name prefix so this * endpoint can never abort arbitrary org jobs (e.g. other apps' crons). */ @AuraEnabled public static void deleteScheduledReport(String jobId) { if (String.isBlank(jobId)) { throw mkError('Job Id is required.'); } List cts = [ SELECT Id, CronJobDetail.Name FROM CronTrigger WHERE Id = :jobId LIMIT 1 ]; if (cts.isEmpty()) { throw mkError('Scheduled job not found — it may have already been deleted.'); } if (cts[0].CronJobDetail.Name == null || !cts[0].CronJobDetail.Name.startsWith(JOB_NAME_PREFIX)) { throw mkError('Only report schedules created from this dashboard can be deleted here.'); } try { System.abortJob(cts[0].Id); } catch (Exception e) { throw mkError('Could not delete the schedule: ' + e.getMessage()); } } /** * Reports the end user can schedule, for the LWC picklist. * Scoped to the HIC_ShopifySync package namespace; sample Flow/Orchestration * reports (DeveloperName 'flow_%') are excluded as they're not Shopify data. * Falls back to all non-flow reports when the namespaced query is empty * (e.g. running un-namespaced in the packaging/dev org). */ @AuraEnabled(cacheable=true) public static List getSchedulableReports() { List rows = [ SELECT Name, DeveloperName FROM Report WHERE NamespacePrefix = 'HIC_ShopifySync' AND (NOT DeveloperName LIKE 'flow_%') ORDER BY Name LIMIT 200 ]; if (rows.isEmpty()) { rows = [ SELECT Name, DeveloperName FROM Report WHERE (NOT DeveloperName LIKE 'flow_%') ORDER BY Name LIMIT 200 ]; } List out = new List(); for (Report r : rows) { ReportOption o = new ReportOption(); o.label = r.Name; o.developerName = r.DeveloperName; out.add(o); } return out; } /** * Schedules a Salesforce Report to run on a recurring cron schedule and * email the result to the supplied recipients. * * @param reportDeveloperName DeveloperName of the Report to run. * @param frequency "Daily" | "Weekly" | "Monthly". * @param timeOfDay "HH:MM" 24-hour. * @param dayOfWeek Cron DOW "1"=Sun..."7"=Sat (used when Weekly). * @param dayOfMonth "1".."28" (used when Monthly). * @param format "PDF" | "EXCEL" | "CSV". * @param recipientsCsv Comma-separated list of email addresses. * @return CronTrigger Id of the scheduled job. */ @AuraEnabled public static String scheduleReport( String reportDeveloperName, String frequency, String timeOfDay, String dayOfWeek, String dayOfMonth, String monthOfYear, String format, String recipientsCsv ) { // ---- Validation ------------------------------------------------- if (String.isBlank(reportDeveloperName)) { throw mkError('Report Developer Name is required.'); } if (!ALLOWED_FREQUENCIES.contains(frequency)) { throw mkError('Frequency must be Daily, Weekly, or Monthly.'); } if (!ALLOWED_FORMATS.contains(format)) { throw mkError('Format must be PDF, EXCEL, or CSV.'); } Integer hour; Integer minute; try { List hm = timeOfDay.split(':'); hour = Integer.valueOf(hm[0]); minute = Integer.valueOf(hm[1]); if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { throw new IllegalArgumentException('out of range'); } } catch (Exception parseEx) { throw mkError('Time must be HH:MM in 24-hour format.'); } List recipients = parseRecipients(recipientsCsv); if (recipients.isEmpty()) { throw mkError('At least one valid recipient email is required.'); } // Validate Report exists (fail-fast — prevents scheduling a job // that will only error at runtime). List reports = [ SELECT Id, DeveloperName, Name FROM Report WHERE DeveloperName = :reportDeveloperName LIMIT 1 ]; if (reports.isEmpty()) { throw mkError('Report with DeveloperName "' + reportDeveloperName + '" not found.'); } Id reportId = reports[0].Id; // ---- Build cron expression -------------------------------------- String cron = buildCron(frequency, hour, minute, dayOfWeek, dayOfMonth, monthOfYear); // ---- Build a unique job name (CronTrigger names must be unique) - String jobName = 'ShopifySyncReport_' + reportDeveloperName + '_' + String.valueOf(System.now().getTime()); // ---- Schedule --------------------------------------------------- ScheduledShopifyReport job = new ScheduledShopifyReport(); job.reportId = reportId; job.reportDevName = reportDeveloperName; job.format = format; job.recipients = recipients; Id cronId = System.schedule(jobName, cron, job); return cronId; } // ------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------- @TestVisible private static String buildCron(String frequency, Integer hour, Integer minute, String dayOfWeek, String dayOfMonth, String monthOfYear) { // CronTrigger format: Seconds Minutes Hours DayOfMonth Month DayOfWeek [Year] String secs = '0'; String mins = String.valueOf(minute); String hrs = String.valueOf(hour); if (frequency == 'Daily') { // Every day at HH:MM return secs + ' ' + mins + ' ' + hrs + ' * * ?'; } if (frequency == 'Weekly') { // Specific day of week at HH:MM String dow = String.isBlank(dayOfWeek) ? '2' : dayOfWeek; // default Monday return secs + ' ' + mins + ' ' + hrs + ' ? * ' + dow; } if (frequency == 'Yearly') { // Specific month + day of month at HH:MM String mon = String.isBlank(monthOfYear) ? '1' : monthOfYear; String yDom = String.isBlank(dayOfMonth) ? '1' : dayOfMonth; return secs + ' ' + mins + ' ' + hrs + ' ' + yDom + ' ' + mon + ' ?'; } // Monthly — specific day of month at HH:MM String dom = String.isBlank(dayOfMonth) ? '1' : dayOfMonth; return secs + ' ' + mins + ' ' + hrs + ' ' + dom + ' * ?'; } // Hard cap on subscribers per scheduled report — keeps email-send governor // usage predictable and matches the LWC UI cap. @TestVisible private static final Integer MAX_RECIPIENTS = 5; @TestVisible private static List parseRecipients(String csv) { List out = new List(); if (String.isBlank(csv)) return out; // Accept BOTH ',' and ';' as separators — common UX expectation. // Also dedupe (case-insensitive) so the same email isn't subscribed twice. Pattern p = Pattern.compile('^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$'); Set seen = new Set(); for (String raw : csv.split('[,;]')) { String e = raw == null ? '' : raw.trim(); if (String.isBlank(e)) continue; if (!p.matcher(e).matches()) continue; String key = e.toLowerCase(); if (seen.contains(key)) continue; seen.add(key); out.add(e); // Stop after the first MAX_RECIPIENTS valid, deduped emails if (out.size() >= MAX_RECIPIENTS) break; } return out; } private static AuraHandledException mkError(String msg) { AuraHandledException ex = new AuraHandledException(msg); ex.setMessage(msg); // ensures the message reaches the LWC catch block return ex; } public class IllegalArgumentException extends Exception {} }