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

316 lines
13 KiB
OpenEdge ABL

/**
* 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<String> ALLOWED_FREQUENCIES =
new Set<String>{ 'Daily', 'Weekly', 'Monthly', 'Yearly' };
@TestVisible private static final Set<String> ALLOWED_FORMATS =
new Set<String>{ '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<ScheduledJobInfo> getScheduledReports() {
List<CronTrigger> 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_<DeveloperName>_<millis> —
// strip prefix + trailing timestamp to recover the DeveloperName.
Map<Id, String> devNameByJob = new Map<Id, String>();
Set<String> devNames = new Set<String>();
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<String, String> labelByDev = new Map<String, String>();
for (Report r : [SELECT Name, DeveloperName FROM Report WHERE DeveloperName IN :devNames]) {
labelByDev.put(r.DeveloperName, r.Name);
}
List<ScheduledJobInfo> out = new List<ScheduledJobInfo>();
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<CronTrigger> 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<ReportOption> getSchedulableReports() {
List<Report> 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<ReportOption> out = new List<ReportOption>();
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<String> 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<String> 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<Report> 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<String> parseRecipients(String csv) {
List<String> out = new List<String>();
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<String> seen = new Set<String>();
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 {}
}