From ed3f9192d5e2ef45852573cc0d11df7b2de80b11 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Sat, 24 Jan 2026 01:51:41 -0800 Subject: [PATCH] Add Task Admin feature to portal - Add Quick Task Templates: admin creates task shortcuts, tap to create tasks instantly - Add Scheduled Tasks: admin defines recurring tasks with cron expressions - New API endpoints: /api/admin/quickTasks/* and /api/admin/scheduledTasks/* - New database tables: QuickTaskTemplates, ScheduledTaskDefinitions - Portal UI: Task Admin page with shortcut buttons and scheduled task management Co-Authored-By: Claude Opus 4.5 --- api/admin/quickTasks/create.cfm | 105 ++++++ api/admin/quickTasks/delete.cfm | 83 +++++ api/admin/quickTasks/list.cfm | 100 ++++++ api/admin/quickTasks/save.cfm | 151 +++++++++ api/admin/quickTasks/setup.cfm | 47 +++ api/admin/scheduledTasks/delete.cfm | 82 +++++ api/admin/scheduledTasks/list.cfm | 101 ++++++ api/admin/scheduledTasks/run.cfm | 104 ++++++ api/admin/scheduledTasks/runDue.cfm | 143 ++++++++ api/admin/scheduledTasks/save.cfm | 218 ++++++++++++ api/admin/scheduledTasks/setup.cfm | 48 +++ api/admin/scheduledTasks/toggle.cfm | 155 +++++++++ portal/index.html | 40 +++ portal/portal.css | 54 +++ portal/portal.js | 491 ++++++++++++++++++++++++++++ 15 files changed, 1922 insertions(+) create mode 100644 api/admin/quickTasks/create.cfm create mode 100644 api/admin/quickTasks/delete.cfm create mode 100644 api/admin/quickTasks/list.cfm create mode 100644 api/admin/quickTasks/save.cfm create mode 100644 api/admin/quickTasks/setup.cfm create mode 100644 api/admin/scheduledTasks/delete.cfm create mode 100644 api/admin/scheduledTasks/list.cfm create mode 100644 api/admin/scheduledTasks/run.cfm create mode 100644 api/admin/scheduledTasks/runDue.cfm create mode 100644 api/admin/scheduledTasks/save.cfm create mode 100644 api/admin/scheduledTasks/setup.cfm create mode 100644 api/admin/scheduledTasks/toggle.cfm diff --git a/api/admin/quickTasks/create.cfm b/api/admin/quickTasks/create.cfm new file mode 100644 index 0000000..4df3542 --- /dev/null +++ b/api/admin/quickTasks/create.cfm @@ -0,0 +1,105 @@ + + + + + +// Create a task from a quick task template (instant creation) +// Input: BusinessID (required), QuickTaskTemplateID (required) +// Output: { OK: true, 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 {}; +} + +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 template ID + templateID = structKeyExists(data, "QuickTaskTemplateID") && isNumeric(data.QuickTaskTemplateID) ? int(data.QuickTaskTemplateID) : 0; + + if (templateID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "QuickTaskTemplateID is required" }); + } + + // Get template details + qTemplate = queryExecute(" + SELECT + QuickTaskTemplateTitle as Title, + QuickTaskTemplateDetails as Details, + QuickTaskTemplateCategoryID as CategoryID, + QuickTaskTemplateTypeID as TypeID + FROM QuickTaskTemplates + WHERE QuickTaskTemplateID = :id + AND QuickTaskTemplateBusinessID = :businessID + AND QuickTaskTemplateIsActive = 1 + ", { + id: { value: templateID, cfsqltype: "cf_sql_integer" }, + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + if (qTemplate.recordCount == 0) { + apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" }); + } + + // Create the task + queryExecute(" + INSERT INTO Tasks ( + TaskBusinessID, TaskCategoryID, TaskTypeID, + TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn, + TaskSourceType, TaskSourceID + ) VALUES ( + :businessID, :categoryID, :typeID, + :title, :details, 0, NOW(), + 'quicktask', :templateID + ) + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, + categoryID: { value: qTemplate.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qTemplate.CategoryID) }, + typeID: { value: qTemplate.TypeID, cfsqltype: "cf_sql_integer", null: isNull(qTemplate.TypeID) }, + title: { value: qTemplate.Title, cfsqltype: "cf_sql_varchar" }, + details: { value: qTemplate.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qTemplate.Details) }, + templateID: { value: templateID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "TASK_ID": qNew.newID, + "MESSAGE": "Task created" + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/admin/quickTasks/delete.cfm b/api/admin/quickTasks/delete.cfm new file mode 100644 index 0000000..32db69e --- /dev/null +++ b/api/admin/quickTasks/delete.cfm @@ -0,0 +1,83 @@ + + + + + +// Delete (soft) a quick task template +// Input: BusinessID (required), QuickTaskTemplateID (required) +// Output: { OK: true } + +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 {}; +} + +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 template ID + templateID = structKeyExists(data, "QuickTaskTemplateID") && isNumeric(data.QuickTaskTemplateID) ? int(data.QuickTaskTemplateID) : 0; + + if (templateID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "QuickTaskTemplateID is required" }); + } + + // Verify template exists and belongs to this business + qCheck = queryExecute(" + SELECT QuickTaskTemplateID FROM QuickTaskTemplates + WHERE QuickTaskTemplateID = :id AND QuickTaskTemplateBusinessID = :businessID + ", { + id: { value: templateID, cfsqltype: "cf_sql_integer" }, + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + if (qCheck.recordCount == 0) { + apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" }); + } + + // Soft delete by setting IsActive to 0 + queryExecute(" + UPDATE QuickTaskTemplates SET QuickTaskTemplateIsActive = 0 + WHERE QuickTaskTemplateID = :id + ", { + id: { value: templateID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "MESSAGE": "Template deleted" + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/admin/quickTasks/list.cfm b/api/admin/quickTasks/list.cfm new file mode 100644 index 0000000..ad6c544 --- /dev/null +++ b/api/admin/quickTasks/list.cfm @@ -0,0 +1,100 @@ + + + + + +// Returns quick task templates for a business +// Input: BusinessID (required) +// Output: { OK: true, TEMPLATES: [...] } + +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 {}; +} + +try { + data = readJsonBody(); + + // Get BusinessID from body, header, or URL + 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"]); + } else if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) { + businessID = int(url.BusinessID); + } + + if (businessID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); + } + + // Get quick task templates for this business + q = queryExecute(" + SELECT + qt.QuickTaskTemplateID, + qt.QuickTaskTemplateName as Name, + qt.QuickTaskTemplateCategoryID as CategoryID, + qt.QuickTaskTemplateTypeID as TypeID, + qt.QuickTaskTemplateTitle as Title, + qt.QuickTaskTemplateDetails as Details, + qt.QuickTaskTemplateIcon as Icon, + qt.QuickTaskTemplateColor as Color, + qt.QuickTaskTemplateSortOrder as SortOrder, + qt.QuickTaskTemplateIsActive as IsActive, + tc.TaskCategoryName as CategoryName, + tc.TaskCategoryColor as CategoryColor + FROM QuickTaskTemplates qt + LEFT JOIN TaskCategories tc ON qt.QuickTaskTemplateCategoryID = tc.TaskCategoryID + WHERE qt.QuickTaskTemplateBusinessID = :businessID + AND qt.QuickTaskTemplateIsActive = 1 + ORDER BY qt.QuickTaskTemplateSortOrder, qt.QuickTaskTemplateID + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + templates = []; + for (row in q) { + arrayAppend(templates, { + "QuickTaskTemplateID": row.QuickTaskTemplateID, + "Name": row.Name, + "CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID, + "TypeID": isNull(row.TypeID) ? "" : row.TypeID, + "Title": row.Title, + "Details": isNull(row.Details) ? "" : row.Details, + "Icon": isNull(row.Icon) ? "add_box" : row.Icon, + "Color": isNull(row.Color) ? "##6366f1" : row.Color, + "SortOrder": row.SortOrder, + "IsActive": row.IsActive, + "CategoryName": isNull(row.CategoryName) ? "" : row.CategoryName, + "CategoryColor": isNull(row.CategoryColor) ? "" : row.CategoryColor + }); + } + + apiAbort({ + "OK": true, + "TEMPLATES": templates, + "COUNT": arrayLen(templates) + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/admin/quickTasks/save.cfm b/api/admin/quickTasks/save.cfm new file mode 100644 index 0000000..c877224 --- /dev/null +++ b/api/admin/quickTasks/save.cfm @@ -0,0 +1,151 @@ + + + + + +// Create or update a quick task template +// Input: BusinessID (required), QuickTaskTemplateID (optional - for update), +// Name, Title, Details, CategoryID, TypeID, Icon, Color +// Output: { OK: true, TEMPLATE_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 {}; +} + +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 + templateID = structKeyExists(data, "QuickTaskTemplateID") && isNumeric(data.QuickTaskTemplateID) ? int(data.QuickTaskTemplateID) : 0; + templateName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : ""; + templateTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : ""; + templateDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : ""; + categoryID = structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0 ? int(data.CategoryID) : javaCast("null", ""); + typeID = structKeyExists(data, "TypeID") && isNumeric(data.TypeID) && data.TypeID > 0 ? int(data.TypeID) : javaCast("null", ""); + templateIcon = structKeyExists(data, "Icon") && len(trim(data.Icon)) ? trim(toString(data.Icon)) : "add_box"; + templateColor = structKeyExists(data, "Color") && len(trim(data.Color)) ? trim(toString(data.Color)) : "##6366f1"; + + // Validate required fields + if (!len(templateName)) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" }); + } + if (!len(templateTitle)) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" }); + } + + if (templateID > 0) { + // UPDATE existing template + qCheck = queryExecute(" + SELECT QuickTaskTemplateID FROM QuickTaskTemplates + WHERE QuickTaskTemplateID = :id AND QuickTaskTemplateBusinessID = :businessID + ", { + id: { value: templateID, cfsqltype: "cf_sql_integer" }, + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + if (qCheck.recordCount == 0) { + apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" }); + } + + queryExecute(" + UPDATE QuickTaskTemplates SET + QuickTaskTemplateName = :name, + QuickTaskTemplateTitle = :title, + QuickTaskTemplateDetails = :details, + QuickTaskTemplateCategoryID = :categoryID, + QuickTaskTemplateTypeID = :typeID, + QuickTaskTemplateIcon = :icon, + QuickTaskTemplateColor = :color + WHERE QuickTaskTemplateID = :id + ", { + name: { value: templateName, cfsqltype: "cf_sql_varchar" }, + title: { value: templateTitle, cfsqltype: "cf_sql_varchar" }, + details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) }, + categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, + typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) }, + icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" }, + color: { value: templateColor, cfsqltype: "cf_sql_varchar" }, + id: { value: templateID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "TEMPLATE_ID": templateID, + "MESSAGE": "Template updated" + }); + + } else { + // INSERT new template + // Get next sort order + qSort = queryExecute(" + SELECT COALESCE(MAX(QuickTaskTemplateSortOrder), 0) + 1 as nextSort + FROM QuickTaskTemplates WHERE QuickTaskTemplateBusinessID = :businessID + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + nextSort = qSort.nextSort; + + queryExecute(" + INSERT INTO QuickTaskTemplates ( + QuickTaskTemplateBusinessID, QuickTaskTemplateName, QuickTaskTemplateTitle, + QuickTaskTemplateDetails, QuickTaskTemplateCategoryID, QuickTaskTemplateTypeID, + QuickTaskTemplateIcon, QuickTaskTemplateColor, QuickTaskTemplateSortOrder + ) VALUES ( + :businessID, :name, :title, :details, :categoryID, :typeID, :icon, :color, :sortOrder + ) + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, + name: { value: templateName, cfsqltype: "cf_sql_varchar" }, + title: { value: templateTitle, cfsqltype: "cf_sql_varchar" }, + details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) }, + categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, + typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) }, + icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" }, + color: { value: templateColor, cfsqltype: "cf_sql_varchar" }, + sortOrder: { value: nextSort, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "TEMPLATE_ID": qNew.newID, + "MESSAGE": "Template created" + }); + } + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/admin/quickTasks/setup.cfm b/api/admin/quickTasks/setup.cfm new file mode 100644 index 0000000..00706e1 --- /dev/null +++ b/api/admin/quickTasks/setup.cfm @@ -0,0 +1,47 @@ + + + + + +// Creates QuickTaskTemplates table if not exists +// Public endpoint for setup + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +try { + // Create QuickTaskTemplates table + queryExecute(" + CREATE TABLE IF NOT EXISTS QuickTaskTemplates ( + QuickTaskTemplateID INT AUTO_INCREMENT PRIMARY KEY, + QuickTaskTemplateBusinessID INT NOT NULL, + QuickTaskTemplateName VARCHAR(100) NOT NULL, + QuickTaskTemplateCategoryID INT NULL, + QuickTaskTemplateTypeID INT NULL, + QuickTaskTemplateTitle VARCHAR(255) NOT NULL, + QuickTaskTemplateDetails TEXT NULL, + QuickTaskTemplateIcon VARCHAR(30) DEFAULT 'add_box', + QuickTaskTemplateColor VARCHAR(20) DEFAULT '##6366f1', + QuickTaskTemplateSortOrder INT DEFAULT 0, + QuickTaskTemplateIsActive BIT(1) DEFAULT b'1', + QuickTaskTemplateCreatedOn DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_business_active (QuickTaskTemplateBusinessID, QuickTaskTemplateIsActive), + INDEX idx_sort (QuickTaskTemplateBusinessID, QuickTaskTemplateSortOrder) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + ", [], { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "MESSAGE": "QuickTaskTemplates table created/verified" + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/admin/scheduledTasks/delete.cfm b/api/admin/scheduledTasks/delete.cfm new file mode 100644 index 0000000..26c4387 --- /dev/null +++ b/api/admin/scheduledTasks/delete.cfm @@ -0,0 +1,82 @@ + + + + + +// Delete a scheduled task definition (hard delete since it's just a definition) +// Input: BusinessID (required), ScheduledTaskID (required) +// Output: { OK: true } + +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 {}; +} + +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 task ID + taskID = structKeyExists(data, "ScheduledTaskID") && isNumeric(data.ScheduledTaskID) ? int(data.ScheduledTaskID) : 0; + + if (taskID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ScheduledTaskID is required" }); + } + + // Verify exists and belongs to business + qCheck = queryExecute(" + SELECT ScheduledTaskID FROM ScheduledTaskDefinitions + WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :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" }); + } + + // Hard delete the definition + queryExecute(" + DELETE FROM ScheduledTaskDefinitions WHERE ScheduledTaskID = :id + ", { + id: { value: taskID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "MESSAGE": "Scheduled task deleted" + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/admin/scheduledTasks/list.cfm b/api/admin/scheduledTasks/list.cfm new file mode 100644 index 0000000..3ebc1e9 --- /dev/null +++ b/api/admin/scheduledTasks/list.cfm @@ -0,0 +1,101 @@ + + + + + +// Returns scheduled task definitions for a business +// Input: BusinessID (required) +// Output: { OK: true, SCHEDULED_TASKS: [...] } + +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 {}; +} + +try { + data = readJsonBody(); + + // Get BusinessID from body, header, or URL + 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"]); + } else if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) { + businessID = int(url.BusinessID); + } + + if (businessID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); + } + + // Get scheduled task definitions for this business + q = queryExecute(" + SELECT + st.ScheduledTaskID, + st.ScheduledTaskName as Name, + st.ScheduledTaskCategoryID as CategoryID, + st.ScheduledTaskTypeID as TypeID, + st.ScheduledTaskTitle as Title, + st.ScheduledTaskDetails as Details, + st.ScheduledTaskCronExpression as CronExpression, + st.ScheduledTaskIsActive as IsActive, + st.ScheduledTaskLastRunOn as LastRunOn, + st.ScheduledTaskNextRunOn as NextRunOn, + st.ScheduledTaskCreatedOn as CreatedOn, + tc.TaskCategoryName as CategoryName, + tc.TaskCategoryColor as CategoryColor + FROM ScheduledTaskDefinitions st + LEFT JOIN TaskCategories tc ON st.ScheduledTaskCategoryID = tc.TaskCategoryID + WHERE st.ScheduledTaskBusinessID = :businessID + ORDER BY st.ScheduledTaskIsActive DESC, st.ScheduledTaskName + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + scheduledTasks = []; + for (row in q) { + arrayAppend(scheduledTasks, { + "ScheduledTaskID": row.ScheduledTaskID, + "Name": row.Name, + "CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID, + "TypeID": isNull(row.TypeID) ? "" : row.TypeID, + "Title": row.Title, + "Details": isNull(row.Details) ? "" : row.Details, + "CronExpression": row.CronExpression, + "IsActive": row.IsActive ? true : false, + "LastRunOn": isNull(row.LastRunOn) ? "" : dateTimeFormat(row.LastRunOn, "yyyy-mm-dd HH:nn:ss"), + "NextRunOn": isNull(row.NextRunOn) ? "" : dateTimeFormat(row.NextRunOn, "yyyy-mm-dd HH:nn:ss"), + "CreatedOn": dateTimeFormat(row.CreatedOn, "yyyy-mm-dd HH:nn:ss"), + "CategoryName": isNull(row.CategoryName) ? "" : row.CategoryName, + "CategoryColor": isNull(row.CategoryColor) ? "" : row.CategoryColor + }); + } + + apiAbort({ + "OK": true, + "SCHEDULED_TASKS": scheduledTasks, + "COUNT": arrayLen(scheduledTasks) + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/admin/scheduledTasks/run.cfm b/api/admin/scheduledTasks/run.cfm new file mode 100644 index 0000000..1cb1b19 --- /dev/null +++ b/api/admin/scheduledTasks/run.cfm @@ -0,0 +1,104 @@ + + + + + +// Manually trigger a scheduled task (for testing) +// Creates a task from the scheduled task definition without updating next run time +// Input: BusinessID (required), ScheduledTaskID (required) +// Output: { OK: true, 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 {}; +} + +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 scheduled task ID + scheduledTaskID = structKeyExists(data, "ScheduledTaskID") && isNumeric(data.ScheduledTaskID) ? int(data.ScheduledTaskID) : 0; + + if (scheduledTaskID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ScheduledTaskID is required" }); + } + + // Get scheduled task definition + qDef = queryExecute(" + SELECT + ScheduledTaskTitle as Title, + ScheduledTaskDetails as Details, + ScheduledTaskCategoryID as CategoryID, + ScheduledTaskTypeID as TypeID + FROM ScheduledTaskDefinitions + WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID + ", { + id: { value: scheduledTaskID, cfsqltype: "cf_sql_integer" }, + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + if (qDef.recordCount == 0) { + apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" }); + } + + // Create the task + queryExecute(" + INSERT INTO Tasks ( + TaskBusinessID, TaskCategoryID, TaskTypeID, + TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn, + TaskSourceType, TaskSourceID + ) VALUES ( + :businessID, :categoryID, :typeID, + :title, :details, 0, NOW(), + 'scheduled_manual', :scheduledTaskID + ) + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, + categoryID: { value: qDef.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qDef.CategoryID) }, + typeID: { value: qDef.TypeID, cfsqltype: "cf_sql_integer", null: isNull(qDef.TypeID) }, + title: { value: qDef.Title, cfsqltype: "cf_sql_varchar" }, + details: { value: qDef.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qDef.Details) }, + scheduledTaskID: { value: scheduledTaskID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "TASK_ID": qNew.newID, + "MESSAGE": "Task created from scheduled task (manual trigger)" + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/admin/scheduledTasks/runDue.cfm b/api/admin/scheduledTasks/runDue.cfm new file mode 100644 index 0000000..e386033 --- /dev/null +++ b/api/admin/scheduledTasks/runDue.cfm @@ -0,0 +1,143 @@ + + + + + +// Process all due scheduled tasks +// Called by cron every minute (or manually for testing) +// Creates Tasks entries for any ScheduledTaskDefinitions that are due +// Public endpoint - no auth required (should be restricted by IP in production) + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +// Calculate next run time from cron expression +function calculateNextRun(required string cronExpression) { + var parts = listToArray(cronExpression, " "); + if (arrayLen(parts) != 5) { + return dateAdd("d", 1, now()); + } + + var cronMinute = parts[1]; + var cronHour = parts[2]; + var cronDay = parts[3]; + var cronMonth = parts[4]; + var cronWeekday = parts[5]; + + // Start from current time + 1 minute + var checkDate = dateAdd("n", 1, now()); + checkDate = createDateTime(year(checkDate), month(checkDate), day(checkDate), hour(checkDate), minute(checkDate), 0); + + var maxIterations = 400 * 24 * 60; // 400 days in minutes + var iterations = 0; + + while (iterations < maxIterations) { + var matchMinute = (cronMinute == "*" || (isNumeric(cronMinute) && minute(checkDate) == int(cronMinute))); + var matchHour = (cronHour == "*" || (isNumeric(cronHour) && hour(checkDate) == int(cronHour))); + var matchDay = (cronDay == "*" || (isNumeric(cronDay) && day(checkDate) == int(cronDay))); + var matchMonth = (cronMonth == "*" || (isNumeric(cronMonth) && month(checkDate) == int(cronMonth))); + + var dow = dayOfWeek(checkDate) - 1; // Convert to 0-based (0=Sunday) + var matchWeekday = (cronWeekday == "*"); + if (!matchWeekday) { + 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; + } + + checkDate = dateAdd("n", 1, checkDate); + iterations++; + } + + return dateAdd("d", 1, now()); +} + +try { + // Find all active scheduled tasks that are due + dueTasks = queryExecute(" + SELECT + ScheduledTaskID, + ScheduledTaskBusinessID as BusinessID, + ScheduledTaskCategoryID as CategoryID, + ScheduledTaskTypeID as TypeID, + ScheduledTaskTitle as Title, + ScheduledTaskDetails as Details, + ScheduledTaskCronExpression as CronExpression + FROM ScheduledTaskDefinitions + WHERE ScheduledTaskIsActive = 1 + AND ScheduledTaskNextRunOn <= NOW() + ", {}, { datasource: "payfrit" }); + + createdTasks = []; + + for (task in dueTasks) { + // Create the actual task + queryExecute(" + INSERT INTO Tasks ( + TaskBusinessID, TaskCategoryID, TaskTypeID, + TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn, + TaskSourceType, TaskSourceID + ) VALUES ( + :businessID, :categoryID, :typeID, + :title, :details, 0, NOW(), + 'scheduled', :scheduledTaskID + ) + ", { + businessID: { value: task.BusinessID, cfsqltype: "cf_sql_integer" }, + categoryID: { value: task.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(task.CategoryID) }, + typeID: { value: task.TypeID, cfsqltype: "cf_sql_integer", null: isNull(task.TypeID) }, + title: { value: task.Title, cfsqltype: "cf_sql_varchar" }, + details: { value: task.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(task.Details) }, + scheduledTaskID: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); + + // Calculate next run and update the scheduled task + nextRun = calculateNextRun(task.CronExpression); + + queryExecute(" + UPDATE ScheduledTaskDefinitions SET + ScheduledTaskLastRunOn = NOW(), + ScheduledTaskNextRunOn = :nextRun + WHERE ScheduledTaskID = :id + ", { + nextRun: { value: nextRun, cfsqltype: "cf_sql_timestamp" }, + id: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + arrayAppend(createdTasks, { + "ScheduledTaskID": task.ScheduledTaskID, + "TaskID": qNew.newID, + "BusinessID": task.BusinessID, + "Title": task.Title + }); + } + + apiAbort({ + "OK": true, + "MESSAGE": "Processed #arrayLen(createdTasks)# scheduled task(s)", + "CREATED_TASKS": createdTasks, + "CHECKED_COUNT": dueTasks.recordCount, + "RAN_AT": dateTimeFormat(now(), "yyyy-mm-dd HH:nn:ss") + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/admin/scheduledTasks/save.cfm b/api/admin/scheduledTasks/save.cfm new file mode 100644 index 0000000..fcc2354 --- /dev/null +++ b/api/admin/scheduledTasks/save.cfm @@ -0,0 +1,218 @@ + + + + + +// Create or update a scheduled task definition +// Input: BusinessID (required), ScheduledTaskID (optional - for update), +// Name, Title, Details, CategoryID, TypeID, CronExpression, IsActive +// 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", ""); + typeID = structKeyExists(data, "TypeID") && isNumeric(data.TypeID) && data.TypeID > 0 ? int(data.TypeID) : javaCast("null", ""); + cronExpression = structKeyExists(data, "CronExpression") ? trim(toString(data.CronExpression)) : ""; + isActive = structKeyExists(data, "IsActive") ? (data.IsActive ? 1 : 0) : 1; + + // 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" }); + } + 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 + nextRunOn = calculateNextRun(cronExpression); + + if (taskID > 0) { + // UPDATE existing + qCheck = queryExecute(" + SELECT ScheduledTaskID FROM ScheduledTaskDefinitions + WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :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 + ScheduledTaskName = :name, + ScheduledTaskTitle = :title, + ScheduledTaskDetails = :details, + ScheduledTaskCategoryID = :categoryID, + ScheduledTaskTypeID = :typeID, + ScheduledTaskCronExpression = :cron, + ScheduledTaskIsActive = :isActive, + ScheduledTaskNextRunOn = :nextRun + WHERE ScheduledTaskID = :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) }, + typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) }, + cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" }, + 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 ( + ScheduledTaskBusinessID, ScheduledTaskName, ScheduledTaskTitle, + ScheduledTaskDetails, ScheduledTaskCategoryID, ScheduledTaskTypeID, + ScheduledTaskCronExpression, ScheduledTaskIsActive, ScheduledTaskNextRunOn + ) VALUES ( + :businessID, :name, :title, :details, :categoryID, :typeID, :cron, :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) }, + typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) }, + cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" }, + 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" }); + + apiAbort({ + "OK": true, + "SCHEDULED_TASK_ID": qNew.newID, + "NEXT_RUN": dateTimeFormat(nextRunOn, "yyyy-mm-dd HH:nn:ss"), + "MESSAGE": "Scheduled task created" + }); + } + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/admin/scheduledTasks/setup.cfm b/api/admin/scheduledTasks/setup.cfm new file mode 100644 index 0000000..42d7000 --- /dev/null +++ b/api/admin/scheduledTasks/setup.cfm @@ -0,0 +1,48 @@ + + + + + +// Creates ScheduledTaskDefinitions table if not exists +// Public endpoint for setup + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +try { + // Create ScheduledTaskDefinitions table + queryExecute(" + CREATE TABLE IF NOT EXISTS ScheduledTaskDefinitions ( + ScheduledTaskID INT AUTO_INCREMENT PRIMARY KEY, + ScheduledTaskBusinessID INT NOT NULL, + ScheduledTaskName VARCHAR(100) NOT NULL, + ScheduledTaskCategoryID INT NULL, + ScheduledTaskTypeID INT NULL, + ScheduledTaskTitle VARCHAR(255) NOT NULL, + ScheduledTaskDetails TEXT NULL, + ScheduledTaskCronExpression VARCHAR(100) NOT NULL, + ScheduledTaskIsActive BIT(1) DEFAULT b'1', + ScheduledTaskLastRunOn DATETIME NULL, + ScheduledTaskNextRunOn DATETIME NULL, + ScheduledTaskCreatedOn DATETIME DEFAULT CURRENT_TIMESTAMP, + ScheduledTaskCreatedByUserID INT NULL, + INDEX idx_business (ScheduledTaskBusinessID), + INDEX idx_active_next (ScheduledTaskIsActive, ScheduledTaskNextRunOn) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + ", [], { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "MESSAGE": "ScheduledTaskDefinitions table created/verified" + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/admin/scheduledTasks/toggle.cfm b/api/admin/scheduledTasks/toggle.cfm new file mode 100644 index 0000000..a5cefb2 --- /dev/null +++ b/api/admin/scheduledTasks/toggle.cfm @@ -0,0 +1,155 @@ + + + + + +// Enable or disable a scheduled task +// Input: BusinessID (required), ScheduledTaskID (required), IsActive (required) +// Output: { OK: true } + +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 {}; +} + +// Calculate next run time from cron expression +function calculateNextRun(required string cronExpression) { + var parts = listToArray(cronExpression, " "); + if (arrayLen(parts) != 5) { + return dateAdd("d", 1, now()); + } + + var cronMinute = parts[1]; + var cronHour = parts[2]; + var cronDay = parts[3]; + var cronMonth = parts[4]; + var cronWeekday = parts[5]; + + var checkDate = dateAdd("n", 1, now()); + checkDate = createDateTime(year(checkDate), month(checkDate), day(checkDate), hour(checkDate), minute(checkDate), 0); + + var maxIterations = 400 * 24 * 60; + var iterations = 0; + + while (iterations < maxIterations) { + var matchMinute = (cronMinute == "*" || (isNumeric(cronMinute) && minute(checkDate) == int(cronMinute))); + var matchHour = (cronHour == "*" || (isNumeric(cronHour) && hour(checkDate) == int(cronHour))); + var matchDay = (cronDay == "*" || (isNumeric(cronDay) && day(checkDate) == int(cronDay))); + var matchMonth = (cronMonth == "*" || (isNumeric(cronMonth) && month(checkDate) == int(cronMonth))); + + var dow = dayOfWeek(checkDate) - 1; + var matchWeekday = (cronWeekday == "*"); + if (!matchWeekday) { + 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; + } + + checkDate = dateAdd("n", 1, checkDate); + iterations++; + } + + 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 task ID and new state + taskID = structKeyExists(data, "ScheduledTaskID") && isNumeric(data.ScheduledTaskID) ? int(data.ScheduledTaskID) : 0; + isActive = structKeyExists(data, "IsActive") ? (data.IsActive ? 1 : 0) : 0; + + if (taskID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ScheduledTaskID is required" }); + } + + // Verify exists and get cron expression + qCheck = queryExecute(" + SELECT ScheduledTaskID, ScheduledTaskCronExpression as CronExpression + FROM ScheduledTaskDefinitions + WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :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" }); + } + + // If enabling, recalculate next run time + nextRunUpdate = ""; + if (isActive) { + nextRunOn = calculateNextRun(qCheck.CronExpression); + nextRunUpdate = ", ScheduledTaskNextRunOn = :nextRun"; + } + + // Update status + if (isActive) { + queryExecute(" + UPDATE ScheduledTaskDefinitions SET + ScheduledTaskIsActive = :isActive, + ScheduledTaskNextRunOn = :nextRun + WHERE ScheduledTaskID = :id + ", { + isActive: { value: isActive, cfsqltype: "cf_sql_bit" }, + nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" }, + id: { value: taskID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + } else { + queryExecute(" + UPDATE ScheduledTaskDefinitions SET ScheduledTaskIsActive = :isActive + WHERE ScheduledTaskID = :id + ", { + isActive: { value: isActive, cfsqltype: "cf_sql_bit" }, + id: { value: taskID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + } + + apiAbort({ + "OK": true, + "MESSAGE": isActive ? "Scheduled task enabled" : "Scheduled task disabled" + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/portal/index.html b/portal/index.html index 9494385..c41e395 100644 --- a/portal/index.html +++ b/portal/index.html @@ -73,6 +73,12 @@ Services + + + + + Task Admin + @@ -416,6 +422,40 @@ + +
+
+ +
+
+

Quick Tasks

+ +
+
+

Create common tasks instantly with one tap.

+
+
Loading...
+
+
+
+
+ + +
+
+

Scheduled Tasks

+ +
+
+

Define recurring tasks that automatically create entries in the Tasks system.

+
+
Loading...
+
+
+
+
+ +
diff --git a/portal/portal.css b/portal/portal.css index ea3ae33..d45e303 100644 --- a/portal/portal.css +++ b/portal/portal.css @@ -1233,3 +1233,57 @@ body { font-size: 13px; color: var(--gray-500); } + +/* Admin Tasks Page */ +.admin-tasks-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +@media (max-width: 1200px) { + .admin-tasks-layout { + grid-template-columns: 1fr; + } +} + +.quick-tasks-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} + +.quick-task-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} + +.quick-task-btn:active { + transform: translateY(0); +} + +.status-badge.active { + background: var(--success); + color: white; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; +} + +.status-badge.inactive { + background: var(--gray-400); + color: white; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; +} + +code { + background: var(--gray-100); + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + font-size: 12px; +} diff --git a/portal/portal.js b/portal/portal.js index c49555d..1ae5bd9 100644 --- a/portal/portal.js +++ b/portal/portal.js @@ -200,6 +200,7 @@ const Portal = { team: 'Team', beacons: 'Beacons', services: 'Service Requests', + 'admin-tasks': 'Task Admin', settings: 'Settings' }; document.getElementById('pageTitle').textContent = titles[page] || page; @@ -237,6 +238,9 @@ const Portal = { case 'services': await this.loadServicesPage(); break; + case 'admin-tasks': + await this.loadAdminTasksPage(); + break; case 'settings': await this.loadSettings(); break; @@ -2472,6 +2476,493 @@ const Portal = { console.error('[Portal] Error deleting service:', err); this.toast('Error deleting service', 'error'); } + }, + + // ===================== + // Admin Tasks Management + // ===================== + + quickTaskTemplates: [], + scheduledTasks: [], + + async loadAdminTasksPage() { + console.log('[Portal] Loading admin tasks page...'); + await Promise.all([ + this.loadQuickTaskTemplates(), + this.loadScheduledTasks() + ]); + }, + + // Quick Task Templates + async loadQuickTaskTemplates() { + try { + const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/list.cfm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ BusinessID: this.config.businessId }) + }); + const data = await response.json(); + + if (data.OK) { + this.quickTaskTemplates = data.TEMPLATES || []; + this.renderQuickTaskTemplates(); + } else { + document.getElementById('quickTasksGrid').innerHTML = '
Failed to load quick task templates
'; + } + } catch (err) { + console.error('[Portal] Error loading quick task templates:', err); + document.getElementById('quickTasksGrid').innerHTML = '
Error loading templates
'; + } + }, + + renderQuickTaskTemplates() { + const gridContainer = document.getElementById('quickTasksGrid'); + const manageContainer = document.getElementById('quickTasksManageList'); + + if (!this.quickTaskTemplates.length) { + gridContainer.innerHTML = '
No quick task templates. Click "+ Add Template" to create one.
'; + manageContainer.innerHTML = ''; + return; + } + + // Shortcut buttons grid + gridContainer.innerHTML = this.quickTaskTemplates.map(t => ` + + `).join(''); + + // Management list + manageContainer.innerHTML = ` +

Manage Templates

+
+ ${this.quickTaskTemplates.map(t => ` +
+
+
+
+ ${this.escapeHtml(t.Name)} +
${this.escapeHtml(t.Title)}
+
+
+
+ + +
+
+ `).join('')} +
+ `; + }, + + showAddQuickTaskModal(templateId = null) { + const isEdit = templateId !== null; + const template = isEdit ? this.quickTaskTemplates.find(t => t.QuickTaskTemplateID === templateId) : {}; + + // Build category options + const categoryOptions = (this._taskCategories || []).map(c => + `` + ).join(''); + + // Build icon options + const iconOptions = Object.keys(this.serviceIcons).map(key => + `` + ).join(''); + + document.getElementById('modalTitle').textContent = isEdit ? 'Edit Quick Task Template' : 'Add Quick Task Template'; + document.getElementById('modalBody').innerHTML = ` +
+ +
+ + + Name shown on the shortcut button +
+
+ + + Title shown on the created task +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ `; + this.showModal(); + + // Load categories if not loaded + if (!this._taskCategories) { + this.loadTaskCategories(); + } + + document.getElementById('quickTaskForm').addEventListener('submit', (e) => { + e.preventDefault(); + this.saveQuickTask(); + }); + }, + + editQuickTask(templateId) { + this.showAddQuickTaskModal(templateId); + }, + + async loadTaskCategories() { + try { + const response = await fetch(`${this.config.apiBaseUrl}/tasks/listCategories.cfm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ BusinessID: this.config.businessId }) + }); + const data = await response.json(); + if (data.OK) { + this._taskCategories = data.CATEGORIES || []; + } + } catch (err) { + console.error('[Portal] Error loading task categories:', err); + } + }, + + async saveQuickTask() { + const id = document.getElementById('quickTaskTemplateId').value; + const payload = { + BusinessID: this.config.businessId, + Name: document.getElementById('quickTaskName').value, + Title: document.getElementById('quickTaskTitle').value, + Details: document.getElementById('quickTaskDetails').value, + CategoryID: document.getElementById('quickTaskCategory').value || null, + Icon: document.getElementById('quickTaskIcon').value, + Color: document.getElementById('quickTaskColor').value + }; + + if (id) payload.QuickTaskTemplateID = parseInt(id); + + try { + const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/save.cfm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json(); + + if (data.OK) { + this.toast('Template saved!', 'success'); + this.closeModal(); + await this.loadQuickTaskTemplates(); + } else { + this.toast(data.MESSAGE || 'Failed to save', 'error'); + } + } catch (err) { + console.error('[Portal] Error saving quick task template:', err); + this.toast('Error saving template', 'error'); + } + }, + + async deleteQuickTask(templateId) { + if (!confirm('Delete this quick task template?')) return; + + try { + const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/delete.cfm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + BusinessID: this.config.businessId, + QuickTaskTemplateID: templateId + }) + }); + const data = await response.json(); + + if (data.OK) { + this.toast('Template deleted', 'success'); + await this.loadQuickTaskTemplates(); + } else { + this.toast(data.MESSAGE || 'Failed to delete', 'error'); + } + } catch (err) { + console.error('[Portal] Error deleting quick task template:', err); + this.toast('Error deleting template', 'error'); + } + }, + + async createQuickTask(templateId) { + try { + const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/create.cfm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + BusinessID: this.config.businessId, + QuickTaskTemplateID: templateId + }) + }); + const data = await response.json(); + + if (data.OK) { + this.toast('Task created!', 'success'); + } else { + this.toast(data.MESSAGE || 'Failed to create task', 'error'); + } + } catch (err) { + console.error('[Portal] Error creating quick task:', err); + this.toast('Error creating task', 'error'); + } + }, + + // Scheduled Tasks + async loadScheduledTasks() { + try { + const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/list.cfm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ BusinessID: this.config.businessId }) + }); + const data = await response.json(); + + if (data.OK) { + this.scheduledTasks = data.SCHEDULED_TASKS || []; + this.renderScheduledTasks(); + } else { + document.getElementById('scheduledTasksList').innerHTML = '
Failed to load scheduled tasks
'; + } + } catch (err) { + console.error('[Portal] Error loading scheduled tasks:', err); + document.getElementById('scheduledTasksList').innerHTML = '
Error loading scheduled tasks
'; + } + }, + + renderScheduledTasks() { + const container = document.getElementById('scheduledTasksList'); + if (!this.scheduledTasks.length) { + container.innerHTML = '
No scheduled tasks configured. Click "+ Add Scheduled Task" to create one.
'; + return; + } + + container.innerHTML = this.scheduledTasks.map(s => ` +
+
+
+ ${s.IsActive ? 'Active' : 'Paused'} + ${this.escapeHtml(s.Name)} +
+
+ ${this.escapeHtml(s.Title)} | Schedule: ${this.escapeHtml(s.CronExpression)} +
+ ${s.NextRunOn ? `
Next run: ${s.NextRunOn}
` : ''} + ${s.LastRunOn ? `
Last run: ${s.LastRunOn}
` : ''} +
+
+ + + + +
+
+ `).join(''); + }, + + showAddScheduledTaskModal(taskId = null) { + const isEdit = taskId !== null; + const task = isEdit ? this.scheduledTasks.find(t => t.ScheduledTaskID === taskId) : {}; + + // Build category options + const categoryOptions = (this._taskCategories || []).map(c => + `` + ).join(''); + + document.getElementById('modalTitle').textContent = isEdit ? 'Edit Scheduled Task' : 'Add Scheduled Task'; + document.getElementById('modalBody').innerHTML = ` +
+ +
+ + + Internal name for this scheduled task +
+
+ + + Title shown on the created task +
+
+ + +
+
+ + +
+
+ + + Format: minute hour day month weekday
+ Examples: 0 9 * * * (daily 9am), 0 9 * * 1-5 (weekdays 9am), 30 14 * * * (daily 2:30pm)
+
+
+ +
+
+ + +
+
+ `; + this.showModal(); + + // Load categories if not loaded + if (!this._taskCategories) { + this.loadTaskCategories(); + } + + document.getElementById('scheduledTaskForm').addEventListener('submit', (e) => { + e.preventDefault(); + this.saveScheduledTask(); + }); + }, + + editScheduledTask(taskId) { + this.showAddScheduledTaskModal(taskId); + }, + + async saveScheduledTask() { + const id = document.getElementById('scheduledTaskId').value; + const payload = { + BusinessID: this.config.businessId, + Name: document.getElementById('scheduledTaskName').value, + Title: document.getElementById('scheduledTaskTitle').value, + Details: document.getElementById('scheduledTaskDetails').value, + CategoryID: document.getElementById('scheduledTaskCategory').value || null, + CronExpression: document.getElementById('scheduledTaskCron').value, + IsActive: document.getElementById('scheduledTaskActive').checked + }; + + if (id) payload.ScheduledTaskID = parseInt(id); + + try { + const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/save.cfm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json(); + + if (data.OK) { + this.toast(`Scheduled task saved! Next run: ${data.NEXT_RUN}`, 'success'); + this.closeModal(); + await this.loadScheduledTasks(); + } else { + this.toast(data.MESSAGE || 'Failed to save', 'error'); + } + } catch (err) { + console.error('[Portal] Error saving scheduled task:', err); + this.toast('Error saving scheduled task', 'error'); + } + }, + + async deleteScheduledTask(taskId) { + if (!confirm('Delete this scheduled task?')) return; + + try { + const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/delete.cfm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + BusinessID: this.config.businessId, + ScheduledTaskID: taskId + }) + }); + const data = await response.json(); + + if (data.OK) { + this.toast('Scheduled task deleted', 'success'); + await this.loadScheduledTasks(); + } else { + this.toast(data.MESSAGE || 'Failed to delete', 'error'); + } + } catch (err) { + console.error('[Portal] Error deleting scheduled task:', err); + this.toast('Error deleting scheduled task', 'error'); + } + }, + + async toggleScheduledTask(taskId, isActive) { + try { + const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/toggle.cfm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + BusinessID: this.config.businessId, + ScheduledTaskID: taskId, + IsActive: isActive + }) + }); + const data = await response.json(); + + if (data.OK) { + this.toast(isActive ? 'Scheduled task enabled' : 'Scheduled task paused', 'success'); + await this.loadScheduledTasks(); + } else { + this.toast(data.MESSAGE || 'Failed to toggle', 'error'); + } + } catch (err) { + console.error('[Portal] Error toggling scheduled task:', err); + this.toast('Error toggling scheduled task', 'error'); + } + }, + + async runScheduledTaskNow(taskId) { + try { + const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/run.cfm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + BusinessID: this.config.businessId, + ScheduledTaskID: taskId + }) + }); + const data = await response.json(); + + if (data.OK) { + this.toast(`Task #${data.TASK_ID} created!`, 'success'); + } else { + this.toast(data.MESSAGE || 'Failed to run task', 'error'); + } + } catch (err) { + console.error('[Portal] Error running scheduled task:', err); + this.toast('Error running scheduled task', 'error'); + } } };