316 lines
13 KiB
OpenEdge ABL
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 {}
|
|
} |