// Create or update a scheduled task definition // Input: BusinessID (required), ScheduledTaskID (optional - for update), // Name, Title, Details, CategoryID, TypeID, CronExpression, IsActive, // ScheduleType ('cron' or 'interval'), IntervalMinutes (for interval type) // Output: { OK: true, SCHEDULED_TASK_ID: int } function apiAbort(required struct payload) { writeOutput(serializeJSON(payload)); abort; } function readJsonBody() { var raw = getHttpRequestData().content; if (isNull(raw)) raw = ""; if (!len(trim(raw))) return {}; try { var data = deserializeJSON(raw); if (isStruct(data)) return data; } catch (any e) {} return {}; } // Parse cron expression and calculate next run time // Supports: minute hour day month weekday // Examples: "0 9 * * *" (daily 9am), "0 9 * * 1-5" (weekdays 9am), "30 14 * * *" (daily 2:30pm) function calculateNextRun(required string cronExpression) { var parts = listToArray(cronExpression, " "); if (arrayLen(parts) != 5) { return dateAdd("d", 1, now()); // Fallback: tomorrow } var cronMinute = parts[1]; var cronHour = parts[2]; var cronDay = parts[3]; var cronMonth = parts[4]; var cronWeekday = parts[5]; // Start from next minute var checkDate = dateAdd("n", 1, now()); checkDate = createDateTime(year(checkDate), month(checkDate), day(checkDate), hour(checkDate), minute(checkDate), 0); // Check up to 400 days ahead (handles yearly schedules) var maxIterations = 400 * 24 * 60; // 400 days in minutes var iterations = 0; while (iterations < maxIterations) { var matchMinute = (cronMinute == "*" || cronMinute == minute(checkDate) || (isNumeric(cronMinute) && minute(checkDate) == int(cronMinute))); var matchHour = (cronHour == "*" || cronHour == hour(checkDate) || (isNumeric(cronHour) && hour(checkDate) == int(cronHour))); var matchDay = (cronDay == "*" || cronDay == day(checkDate) || (isNumeric(cronDay) && day(checkDate) == int(cronDay))); var matchMonth = (cronMonth == "*" || cronMonth == month(checkDate) || (isNumeric(cronMonth) && month(checkDate) == int(cronMonth))); // Weekday: 0=Sunday, 1=Monday, ... 6=Saturday (Lucee dayOfWeek: 1=Sunday) var dow = dayOfWeek(checkDate) - 1; // Convert to 0-based var matchWeekday = (cronWeekday == "*"); if (!matchWeekday) { // Handle ranges like 1-5 (Mon-Fri) if (find("-", cronWeekday)) { var range = listToArray(cronWeekday, "-"); if (arrayLen(range) == 2 && isNumeric(range[1]) && isNumeric(range[2])) { matchWeekday = (dow >= int(range[1]) && dow <= int(range[2])); } } else if (isNumeric(cronWeekday)) { matchWeekday = (dow == int(cronWeekday)); } } if (matchMinute && matchHour && matchDay && matchMonth && matchWeekday) { return checkDate; } // Move to next minute checkDate = dateAdd("n", 1, checkDate); iterations++; } // Fallback if no match found return dateAdd("d", 1, now()); } try { data = readJsonBody(); // Get BusinessID businessID = 0; httpHeaders = getHttpRequestData().headers; if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) { businessID = int(data.BusinessID); } else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) { businessID = int(httpHeaders["X-Business-ID"]); } if (businessID == 0) { apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); } // Get fields taskID = structKeyExists(data, "ScheduledTaskID") && isNumeric(data.ScheduledTaskID) ? int(data.ScheduledTaskID) : 0; taskName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : ""; taskTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : ""; taskDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : ""; categoryID = structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0 ? int(data.CategoryID) : javaCast("null", ""); cronExpression = structKeyExists(data, "CronExpression") ? trim(toString(data.CronExpression)) : ""; isActive = structKeyExists(data, "IsActive") ? (data.IsActive ? 1 : 0) : 1; // New interval scheduling fields scheduleType = structKeyExists(data, "ScheduleType") ? trim(toString(data.ScheduleType)) : "cron"; intervalMinutes = structKeyExists(data, "IntervalMinutes") && isNumeric(data.IntervalMinutes) ? int(data.IntervalMinutes) : javaCast("null", ""); // Validate required fields if (!len(taskName)) { apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" }); } if (!len(taskTitle)) { apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" }); } // Validate based on schedule type if (scheduleType == "interval" || scheduleType == "interval_after_completion") { // Interval-based scheduling if (isNull(intervalMinutes) || intervalMinutes < 1) { apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "IntervalMinutes is required for interval scheduling (minimum 1)" }); } // Set a placeholder cron expression for interval type if (!len(cronExpression)) { cronExpression = "* * * * *"; } // For NEW tasks: run immediately. For UPDATES: next run = NOW + interval if (taskID == 0) { nextRunOn = now(); // Run immediately on first creation } else { nextRunOn = dateAdd("n", intervalMinutes, now()); } } else { // Cron-based scheduling if (!len(cronExpression)) { apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "CronExpression is required" }); } // Validate cron format (5 parts) cronParts = listToArray(cronExpression, " "); if (arrayLen(cronParts) != 5) { apiAbort({ "OK": false, "ERROR": "invalid_cron", "MESSAGE": "Cron expression must have 5 parts: minute hour day month weekday" }); } // Calculate next run time from cron nextRunOn = calculateNextRun(cronExpression); } if (taskID > 0) { // UPDATE existing qCheck = queryExecute(" SELECT ID FROM ScheduledTaskDefinitions WHERE ID = :id AND BusinessID = :businessID ", { id: { value: taskID, cfsqltype: "cf_sql_integer" }, businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); if (qCheck.recordCount == 0) { apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" }); } queryExecute(" UPDATE ScheduledTaskDefinitions SET Name = :name, Title = :title, Details = :details, TaskCategoryID = :categoryID, CronExpression = :cron, ScheduleType = :scheduleType, IntervalMinutes = :intervalMinutes, IsActive = :isActive, NextRunOn = :nextRun WHERE ID = :id ", { name: { value: taskName, cfsqltype: "cf_sql_varchar" }, title: { value: taskTitle, cfsqltype: "cf_sql_varchar" }, details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) }, categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" }, scheduleType: { value: scheduleType, cfsqltype: "cf_sql_varchar" }, intervalMinutes: { value: intervalMinutes, cfsqltype: "cf_sql_integer", null: isNull(intervalMinutes) }, isActive: { value: isActive, cfsqltype: "cf_sql_bit" }, nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" }, id: { value: taskID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); apiAbort({ "OK": true, "SCHEDULED_TASK_ID": taskID, "NEXT_RUN": dateTimeFormat(nextRunOn, "yyyy-mm-dd HH:nn:ss"), "MESSAGE": "Scheduled task updated" }); } else { // INSERT new queryExecute(" INSERT INTO ScheduledTaskDefinitions ( BusinessID, Name, Title, Details, TaskCategoryID, CronExpression, ScheduleType, IntervalMinutes, IsActive, NextRunOn ) VALUES ( :businessID, :name, :title, :details, :categoryID, :cron, :scheduleType, :intervalMinutes, :isActive, :nextRun ) ", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, name: { value: taskName, cfsqltype: "cf_sql_varchar" }, title: { value: taskTitle, cfsqltype: "cf_sql_varchar" }, details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) }, categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" }, scheduleType: { value: scheduleType, cfsqltype: "cf_sql_varchar" }, intervalMinutes: { value: intervalMinutes, cfsqltype: "cf_sql_integer", null: isNull(intervalMinutes) }, isActive: { value: isActive, cfsqltype: "cf_sql_bit" }, nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" } }, { datasource: "payfrit" }); qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); newScheduledTaskID = qNew.newID; // Create the first task immediately queryExecute(" INSERT INTO Tasks ( BusinessID, CategoryID, TaskTypeID, Title, Details, CreatedOn, ClaimedByUserID ) VALUES ( :businessID, :categoryID, :typeID, :title, :details, NOW(), 0 ) ", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, typeID: { value: 0, cfsqltype: "cf_sql_integer" }, title: { value: taskTitle, cfsqltype: "cf_sql_varchar" }, details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) } }, { datasource: "payfrit" }); qTask = queryExecute("SELECT LAST_INSERT_ID() as taskID", [], { datasource: "payfrit" }); // Now set the NEXT run time (not the immediate one we just created) if (scheduleType == "interval" || scheduleType == "interval_after_completion") { if (scheduleType == "interval_after_completion") { // After-completion: don't schedule next until task is completed actualNextRun = javaCast("null", ""); } else { // Fixed interval: next run = NOW + interval actualNextRun = dateAdd("n", intervalMinutes, now()); } } else { // Cron-based actualNextRun = calculateNextRun(cronExpression); } queryExecute(" UPDATE ScheduledTaskDefinitions SET LastRunOn = NOW(), NextRunOn = :nextRun WHERE ID = :id ", { nextRun: { value: actualNextRun, cfsqltype: "cf_sql_timestamp", null: isNull(actualNextRun) }, id: { value: newScheduledTaskID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); apiAbort({ "OK": true, "SCHEDULED_TASK_ID": newScheduledTaskID, "TASK_ID": qTask.taskID, "NEXT_RUN": isNull(actualNextRun) ? "" : dateTimeFormat(actualNextRun, "yyyy-mm-dd HH:nn:ss"), "MESSAGE": "Scheduled task created and first task added" }); } } catch (any e) { apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message }); }