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 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-24 01:51:41 -08:00
parent 43a8d18541
commit ed3f9192d5
15 changed files with 1922 additions and 0 deletions

View file

@ -0,0 +1,105 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// 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
});
}
</cfscript>

View file

@ -0,0 +1,83 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// 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
});
}
</cfscript>

View file

@ -0,0 +1,100 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// 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
});
}
</cfscript>

View file

@ -0,0 +1,151 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// 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
});
}
</cfscript>

View file

@ -0,0 +1,47 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// 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
});
}
</cfscript>

View file

@ -0,0 +1,82 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// 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
});
}
</cfscript>

View file

@ -0,0 +1,101 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// 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
});
}
</cfscript>

View file

@ -0,0 +1,104 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// 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
});
}
</cfscript>

View file

@ -0,0 +1,143 @@
<cfsetting showdebugoutput="false" requesttimeout="60">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// 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
});
}
</cfscript>

View file

@ -0,0 +1,218 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// 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
});
}
</cfscript>

View file

@ -0,0 +1,48 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// 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
});
}
</cfscript>

View file

@ -0,0 +1,155 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// 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
});
}
</cfscript>

View file

@ -73,6 +73,12 @@
</svg>
<span>Services</span>
</a>
<a href="#admin-tasks" class="nav-item" data-page="admin-tasks">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Task Admin</span>
</a>
<a href="#settings" class="nav-item" data-page="settings">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
@ -416,6 +422,40 @@
</div>
</section>
<!-- Admin Tasks Page -->
<section class="page" id="page-admin-tasks">
<div class="admin-tasks-layout">
<!-- Quick Task Templates Section -->
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3>Quick Tasks</h3>
<button class="btn btn-sm btn-primary" onclick="Portal.showAddQuickTaskModal()">+ Add Template</button>
</div>
<div class="card-body">
<p style="color:#666;margin-bottom:16px;">Create common tasks instantly with one tap.</p>
<div class="quick-tasks-grid" id="quickTasksGrid">
<div class="empty-state">Loading...</div>
</div>
<div id="quickTasksManageList" style="margin-top:24px;"></div>
</div>
</div>
<!-- Scheduled Tasks Section -->
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3>Scheduled Tasks</h3>
<button class="btn btn-sm btn-primary" onclick="Portal.showAddScheduledTaskModal()">+ Add Scheduled Task</button>
</div>
<div class="card-body">
<p style="color:#666;margin-bottom:16px;">Define recurring tasks that automatically create entries in the Tasks system.</p>
<div class="list-group" id="scheduledTasksList">
<div class="empty-state">Loading...</div>
</div>
</div>
</div>
</div>
</section>
<!-- Settings Page -->
<section class="page" id="page-settings">
<div class="settings-grid">

View file

@ -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;
}

View file

@ -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 = '<div class="empty-state">Failed to load quick task templates</div>';
}
} catch (err) {
console.error('[Portal] Error loading quick task templates:', err);
document.getElementById('quickTasksGrid').innerHTML = '<div class="empty-state">Error loading templates</div>';
}
},
renderQuickTaskTemplates() {
const gridContainer = document.getElementById('quickTasksGrid');
const manageContainer = document.getElementById('quickTasksManageList');
if (!this.quickTaskTemplates.length) {
gridContainer.innerHTML = '<div class="empty-state">No quick task templates. Click "+ Add Template" to create one.</div>';
manageContainer.innerHTML = '';
return;
}
// Shortcut buttons grid
gridContainer.innerHTML = this.quickTaskTemplates.map(t => `
<button class="quick-task-btn" onclick="Portal.createQuickTask(${t.QuickTaskTemplateID})"
style="background:${t.Color || '#6366f1'};color:#fff;border:none;padding:16px;border-radius:8px;cursor:pointer;text-align:left;transition:transform 0.1s,box-shadow 0.1s;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
${this.getServiceIconSvg(t.Icon || 'add_box')}
<strong>${this.escapeHtml(t.Name)}</strong>
</div>
<div style="font-size:12px;opacity:0.9;">${this.escapeHtml(t.Title)}</div>
</button>
`).join('');
// Management list
manageContainer.innerHTML = `
<h4 style="margin-bottom:12px;color:#666;">Manage Templates</h4>
<div class="list-group">
${this.quickTaskTemplates.map(t => `
<div class="list-group-item" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;">
<div style="display:flex;align-items:center;gap:12px;">
<div style="width:8px;height:36px;background:${t.Color || '#6366f1'};border-radius:4px;"></div>
<div>
<strong>${this.escapeHtml(t.Name)}</strong>
<div style="color:#666;font-size:12px;">${this.escapeHtml(t.Title)}</div>
</div>
</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-sm btn-secondary" onclick="Portal.editQuickTask(${t.QuickTaskTemplateID})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="Portal.deleteQuickTask(${t.QuickTaskTemplateID})">Delete</button>
</div>
</div>
`).join('')}
</div>
`;
},
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 =>
`<option value="${c.TaskCategoryID}" ${template.CategoryID == c.TaskCategoryID ? 'selected' : ''}>${this.escapeHtml(c.TaskCategoryName)}</option>`
).join('');
// Build icon options
const iconOptions = Object.keys(this.serviceIcons).map(key =>
`<option value="${key}" ${(template.Icon || 'add_box') === key ? 'selected' : ''}>${this.serviceIcons[key].label}</option>`
).join('');
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Quick Task Template' : 'Add Quick Task Template';
document.getElementById('modalBody').innerHTML = `
<form id="quickTaskForm" class="form">
<input type="hidden" id="quickTaskTemplateId" value="${templateId || ''}">
<div class="form-group">
<label>Template Name</label>
<input type="text" id="quickTaskName" class="form-input" value="${this.escapeHtml(template.Name || '')}" required placeholder="e.g., Check Trash">
<small style="color:#666;">Name shown on the shortcut button</small>
</div>
<div class="form-group">
<label>Task Title</label>
<input type="text" id="quickTaskTitle" class="form-input" value="${this.escapeHtml(template.Title || '')}" required placeholder="e.g., Check and empty trash bins">
<small style="color:#666;">Title shown on the created task</small>
</div>
<div class="form-group">
<label>Task Details (optional)</label>
<textarea id="quickTaskDetails" class="form-textarea" rows="2" placeholder="Optional instructions">${this.escapeHtml(template.Details || '')}</textarea>
</div>
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-group">
<label>Category (optional)</label>
<select id="quickTaskCategory" class="form-input">
<option value="">None</option>
${categoryOptions}
</select>
</div>
<div class="form-group">
<label>Icon</label>
<select id="quickTaskIcon" class="form-input">
${iconOptions}
</select>
</div>
</div>
<div class="form-group">
<label>Button Color</label>
<input type="color" id="quickTaskColor" class="form-input" value="${template.Color || '#6366f1'}" style="height:40px;padding:4px;">
</div>
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Create Template'}</button>
</div>
</form>
`;
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 = '<div class="empty-state">Failed to load scheduled tasks</div>';
}
} catch (err) {
console.error('[Portal] Error loading scheduled tasks:', err);
document.getElementById('scheduledTasksList').innerHTML = '<div class="empty-state">Error loading scheduled tasks</div>';
}
},
renderScheduledTasks() {
const container = document.getElementById('scheduledTasksList');
if (!this.scheduledTasks.length) {
container.innerHTML = '<div class="empty-state">No scheduled tasks configured. Click "+ Add Scheduled Task" to create one.</div>';
return;
}
container.innerHTML = this.scheduledTasks.map(s => `
<div class="list-group-item" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;">
<div style="flex:1;">
<div style="display:flex;align-items:center;gap:8px;">
<span class="status-badge ${s.IsActive ? 'active' : 'inactive'}">${s.IsActive ? 'Active' : 'Paused'}</span>
<strong>${this.escapeHtml(s.Name)}</strong>
</div>
<div style="color:#666;font-size:12px;margin-top:4px;">
${this.escapeHtml(s.Title)} | Schedule: <code>${this.escapeHtml(s.CronExpression)}</code>
</div>
${s.NextRunOn ? `<div style="color:#999;font-size:11px;">Next run: ${s.NextRunOn}</div>` : ''}
${s.LastRunOn ? `<div style="color:#999;font-size:11px;">Last run: ${s.LastRunOn}</div>` : ''}
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn btn-sm btn-secondary" onclick="Portal.runScheduledTaskNow(${s.ScheduledTaskID})" title="Run Now">
Run Now
</button>
<button class="btn btn-sm btn-secondary" onclick="Portal.toggleScheduledTask(${s.ScheduledTaskID}, ${!s.IsActive})">
${s.IsActive ? 'Pause' : 'Enable'}
</button>
<button class="btn btn-sm btn-secondary" onclick="Portal.editScheduledTask(${s.ScheduledTaskID})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="Portal.deleteScheduledTask(${s.ScheduledTaskID})">Delete</button>
</div>
</div>
`).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 =>
`<option value="${c.TaskCategoryID}" ${task.CategoryID == c.TaskCategoryID ? 'selected' : ''}>${this.escapeHtml(c.TaskCategoryName)}</option>`
).join('');
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Scheduled Task' : 'Add Scheduled Task';
document.getElementById('modalBody').innerHTML = `
<form id="scheduledTaskForm" class="form">
<input type="hidden" id="scheduledTaskId" value="${taskId || ''}">
<div class="form-group">
<label>Definition Name</label>
<input type="text" id="scheduledTaskName" class="form-input" value="${this.escapeHtml(task.Name || '')}" required placeholder="e.g., Daily Opening Checklist">
<small style="color:#666;">Internal name for this scheduled task</small>
</div>
<div class="form-group">
<label>Task Title</label>
<input type="text" id="scheduledTaskTitle" class="form-input" value="${this.escapeHtml(task.Title || '')}" required placeholder="e.g., Complete Opening Checklist">
<small style="color:#666;">Title shown on the created task</small>
</div>
<div class="form-group">
<label>Task Details (optional)</label>
<textarea id="scheduledTaskDetails" class="form-textarea" rows="2" placeholder="Optional instructions">${this.escapeHtml(task.Details || '')}</textarea>
</div>
<div class="form-group">
<label>Category (optional)</label>
<select id="scheduledTaskCategory" class="form-input">
<option value="">None</option>
${categoryOptions}
</select>
</div>
<div class="form-group">
<label>Schedule (Cron Expression)</label>
<input type="text" id="scheduledTaskCron" class="form-input" value="${task.CronExpression || '0 9 * * *'}" required placeholder="0 9 * * *">
<small style="color:#666;">Format: minute hour day month weekday<br>
Examples: <code>0 9 * * *</code> (daily 9am), <code>0 9 * * 1-5</code> (weekdays 9am), <code>30 14 * * *</code> (daily 2:30pm)</small>
</div>
<div class="form-group">
<label><input type="checkbox" id="scheduledTaskActive" ${task.IsActive !== false ? 'checked' : ''}> Active</label>
</div>
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Create'}</button>
</div>
</form>
`;
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');
}
}
};