- New api/tabs/ directory with 13 endpoints: open, close, cancel, get, getActive, addOrder, increaseAuth, addMember, removeMember, getPresence, approveOrder, rejectOrder, pendingOrders - New api/presence/heartbeat.cfm for beacon-based user presence tracking - New cron/expireTabs.cfm for idle tab expiry and presence cleanup - Modified submit.cfm for tab-aware order submission (skip payment, update running total) - Modified getOrCreateCart.cfm to auto-detect active tab and set TabID on new carts - Modified webhook.cfm to handle tab capture events (metadata type=tab_close) - Modified businesses/get.cfm and updateTabs.cfm with new tab config columns - Updated portal tab settings UI with auth amounts, max members, approval toggle - Added tab and presence endpoints to Application.cfm public allowlist Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
346 lines
11 KiB
Text
346 lines
11 KiB
Text
<cfsetting showdebugoutput="false">
|
|
<cfsetting enablecfoutputonly="true">
|
|
|
|
<cffunction name="readJsonBody" access="public" returntype="struct" output="false">
|
|
<cfset var raw = getHttpRequestData().content>
|
|
<cfif isNull(raw) OR len(trim(raw)) EQ 0>
|
|
<cfreturn {}>
|
|
</cfif>
|
|
<cftry>
|
|
<cfset var data = deserializeJSON(raw)>
|
|
<cfif isStruct(data)>
|
|
<cfreturn data>
|
|
<cfelse>
|
|
<cfreturn {}>
|
|
</cfif>
|
|
<cfcatch>
|
|
<cfreturn {}>
|
|
</cfcatch>
|
|
</cftry>
|
|
</cffunction>
|
|
|
|
<cffunction name="apiAbort" access="public" returntype="void" output="true">
|
|
<cfargument name="payload" type="struct" required="true">
|
|
<cfcontent type="application/json; charset=utf-8">
|
|
<cfoutput>#serializeJSON(arguments.payload)#</cfoutput>
|
|
<cfabort>
|
|
</cffunction>
|
|
|
|
<cffunction name="buildLineItemsGraph" access="public" returntype="struct" output="false">
|
|
<cfargument name="OrderID" type="numeric" required="true">
|
|
|
|
<cfset var out = {}>
|
|
<cfset out.items = {}> <!--- lineItemId -> struct --->
|
|
<cfset out.children = {}> <!--- parentLineItemId -> array(lineItemId) --->
|
|
<cfset out.itemMeta = {}> <!--- ItemID -> struct(meta) --->
|
|
|
|
<cfset var qLI = queryTimed(
|
|
"
|
|
SELECT
|
|
ID,
|
|
ParentOrderLineItemID,
|
|
ItemID,
|
|
IsDeleted
|
|
FROM OrderLineItems
|
|
WHERE OrderID = ?
|
|
ORDER BY ID
|
|
",
|
|
[ { value = arguments.OrderID, cfsqltype = "cf_sql_integer" } ],
|
|
{ datasource = "payfrit" }
|
|
)>
|
|
|
|
<cfif qLI.recordCount EQ 0>
|
|
<cfreturn out>
|
|
</cfif>
|
|
|
|
<cfset var itemIds = []>
|
|
<cfloop query="qLI">
|
|
<cfset out.items[qLI.ID] = {
|
|
"id": qLI.ID,
|
|
"parentId": qLI.ParentOrderLineItemID,
|
|
"itemId": qLI.ItemID,
|
|
"isDeleted": (qLI.IsDeleted EQ true)
|
|
}>
|
|
<cfif NOT structKeyExists(out.children, qLI.ParentOrderLineItemID)>
|
|
<cfset out.children[qLI.ParentOrderLineItemID] = []>
|
|
</cfif>
|
|
<cfset arrayAppend(out.children[qLI.ParentOrderLineItemID], qLI.ID)>
|
|
<cfset arrayAppend(itemIds, qLI.ItemID)>
|
|
</cfloop>
|
|
|
|
<!--- Load meta for involved items --->
|
|
<cfset var uniq = {} >
|
|
<cfloop array="#itemIds#" index="iid">
|
|
<cfset uniq[iid] = true>
|
|
</cfloop>
|
|
<cfset var uniqIds = structKeyArray(uniq)>
|
|
<cfif arrayLen(uniqIds) GT 0>
|
|
<cfset var inList = arrayToList(uniqIds)>
|
|
<cfset var qMeta = queryTimed(
|
|
"
|
|
SELECT
|
|
ID,
|
|
RequiresChildSelection,
|
|
MaxNumSelectionReq
|
|
FROM Items
|
|
WHERE ID IN (#inList#)
|
|
",
|
|
[],
|
|
{ datasource = "payfrit" }
|
|
)>
|
|
|
|
<cfloop query="qMeta">
|
|
<cfset out.itemMeta[qMeta.ID] = {
|
|
"requires": qMeta.RequiresChildSelection,
|
|
"maxSel": qMeta.MaxNumSelectionReq
|
|
}>
|
|
</cfloop>
|
|
</cfif>
|
|
|
|
<cfreturn out>
|
|
</cffunction>
|
|
|
|
<cffunction name="hasSelectedDescendant" access="public" returntype="boolean" output="false">
|
|
<cfargument name="graph" type="struct" required="true">
|
|
<cfargument name="lineItemId" type="numeric" required="true">
|
|
|
|
<cfset var stack = []>
|
|
<cfif structKeyExists(arguments.graph.children, arguments.lineItemId)>
|
|
<cfset stack = duplicate(arguments.graph.children[arguments.lineItemId])>
|
|
</cfif>
|
|
|
|
<cfloop condition="arrayLen(stack) GT 0">
|
|
<cfset var id = stack[arrayLen(stack)]>
|
|
<cfset arrayDeleteAt(stack, arrayLen(stack))>
|
|
|
|
<cfif structKeyExists(arguments.graph.items, id)>
|
|
<cfset var node = arguments.graph.items[id]>
|
|
<cfif NOT node.isDeleted>
|
|
<cfreturn true>
|
|
</cfif>
|
|
<cfif structKeyExists(arguments.graph.children, id)>
|
|
<cfset var kids = arguments.graph.children[id]>
|
|
<cfloop array="#kids#" index="kidId">
|
|
<cfset arrayAppend(stack, kidId)>
|
|
</cfloop>
|
|
</cfif>
|
|
</cfif>
|
|
</cfloop>
|
|
|
|
<cfreturn false>
|
|
</cffunction>
|
|
|
|
<cfinclude template="../grants/_grantUtils.cfm">
|
|
<cfset data = readJsonBody()>
|
|
<cfset OrderID = val( structKeyExists(data,"OrderID") ? data.OrderID : 0 )>
|
|
|
|
<cfif OrderID LTE 0>
|
|
<cfset apiAbort({ "OK": false, "ERROR": "missing_orderid", "MESSAGE": "OrderID is required.", "DETAIL": "" })>
|
|
</cfif>
|
|
|
|
<cftry>
|
|
<cfset qOrder = queryTimed(
|
|
"
|
|
SELECT ID, StatusID, OrderTypeID, BusinessID, ServicePointID,
|
|
GrantID, GrantOwnerBusinessID
|
|
FROM Orders
|
|
WHERE ID = ?
|
|
LIMIT 1
|
|
",
|
|
[ { value = OrderID, cfsqltype = "cf_sql_integer" } ],
|
|
{ datasource = "payfrit" }
|
|
)>
|
|
|
|
<cfif qOrder.recordCount EQ 0>
|
|
<cfset apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Order not found.", "DETAIL": "" })>
|
|
</cfif>
|
|
|
|
<cfif qOrder.StatusID NEQ 0>
|
|
<cfset apiAbort({ "OK": false, "ERROR": "bad_state", "MESSAGE": "Order is not in cart state.", "DETAIL": "" })>
|
|
</cfif>
|
|
|
|
<!--- OrderTypeID: 1=dine-in, 2=takeaway, 3=delivery (0=undecided is invalid for submit) --->
|
|
<cfif qOrder.OrderTypeID LT 1 OR qOrder.OrderTypeID GT 3>
|
|
<cfset apiAbort({ "OK": false, "ERROR": "bad_type", "MESSAGE": "Order type must be set before submitting (1=dine-in, 2=takeaway, 3=delivery).", "DETAIL": "" })>
|
|
</cfif>
|
|
|
|
<!--- Delivery orders require an address --->
|
|
<cfif qOrder.OrderTypeID EQ 3>
|
|
<cfset qAddr = queryTimed(
|
|
"SELECT AddressID FROM Orders WHERE ID = ? LIMIT 1",
|
|
[ { value = OrderID, cfsqltype = "cf_sql_integer" } ],
|
|
{ datasource = "payfrit" }
|
|
)>
|
|
<cfif qAddr.AddressID LTE 0 OR isNull(qAddr.AddressID)>
|
|
<cfset apiAbort({ "OK": false, "ERROR": "missing_address", "MESSAGE": "Delivery orders require a delivery address.", "DETAIL": "" })>
|
|
</cfif>
|
|
</cfif>
|
|
|
|
<!--- SP-SM: Re-validate grant is still active (instant revocation enforcement) --->
|
|
<cfif val(qOrder.GrantID) GT 0>
|
|
<cfset qGrantCheck = queryTimed(
|
|
"SELECT StatusID, TimePolicyType, TimePolicyData
|
|
FROM ServicePointGrants WHERE ID = ? LIMIT 1",
|
|
[ { value = qOrder.GrantID, cfsqltype = "cf_sql_integer" } ],
|
|
{ datasource = "payfrit" }
|
|
)>
|
|
<cfif qGrantCheck.recordCount EQ 0 OR qGrantCheck.StatusID NEQ 1>
|
|
<cfset apiAbort({ "OK": false, "ERROR": "grant_revoked", "MESSAGE": "Access to this service point has been revoked.", "DETAIL": "" })>
|
|
</cfif>
|
|
<cfif NOT isGrantTimeActive(qGrantCheck.TimePolicyType, qGrantCheck.TimePolicyData)>
|
|
<cfset apiAbort({ "OK": false, "ERROR": "grant_time_expired", "MESSAGE": "Service point access is no longer available at this time.", "DETAIL": "" })>
|
|
</cfif>
|
|
</cfif>
|
|
|
|
<!--- Must have at least one non-deleted root line item --->
|
|
<cfset qRoots = queryTimed(
|
|
"
|
|
SELECT COUNT(*) AS Cnt
|
|
FROM OrderLineItems
|
|
WHERE OrderID = ?
|
|
AND ParentOrderLineItemID = 0
|
|
AND IsDeleted = 0
|
|
",
|
|
[ { value = OrderID, cfsqltype = "cf_sql_integer" } ],
|
|
{ datasource = "payfrit" }
|
|
)>
|
|
|
|
<cfif qRoots.Cnt LTE 0>
|
|
<cfset apiAbort({ "OK": false, "ERROR": "empty_order", "MESSAGE": "Order has no items.", "DETAIL": "" })>
|
|
</cfif>
|
|
|
|
<!--- Validate requires-descendant and max immediate selections --->
|
|
<cfset graph = buildLineItemsGraph(OrderID)>
|
|
|
|
<!--- Loop all non-deleted nodes and validate --->
|
|
<cfloop collection="#graph.items#" item="k">
|
|
<cfset node = graph.items[k]>
|
|
<cfif node.isDeleted>
|
|
<!--- skip deleted nodes --->
|
|
<cfcontinue>
|
|
</cfif>
|
|
|
|
<cfset meta = structKeyExists(graph.itemMeta, node.itemId) ? graph.itemMeta[node.itemId] : { "requires": 0, "maxSel": 0 }>
|
|
|
|
<!--- max immediate selections --->
|
|
<cfset maxSel = val(meta.maxSel)>
|
|
<cfif maxSel GT 0>
|
|
<cfset selCount = 0>
|
|
<cfif structKeyExists(graph.children, node.id)>
|
|
<cfset kids = graph.children[node.id]>
|
|
<cfloop array="#kids#" index="kidId">
|
|
<cfif structKeyExists(graph.items, kidId)>
|
|
<cfset kidNode = graph.items[kidId]>
|
|
<cfif NOT kidNode.isDeleted>
|
|
<cfset selCount = selCount + 1>
|
|
</cfif>
|
|
</cfif>
|
|
</cfloop>
|
|
</cfif>
|
|
|
|
<cfif selCount GT maxSel>
|
|
<cfset apiAbort({
|
|
"OK": false,
|
|
"ERROR": "max_selection_exceeded",
|
|
"MESSAGE": "Too many selections under a modifier group.",
|
|
"DETAIL": "LineItemID #node.id# has #selCount# immediate children selected; max is #maxSel#."
|
|
})>
|
|
</cfif>
|
|
</cfif>
|
|
|
|
<!--- requires descendant selection --->
|
|
<cfif val(meta.requires) EQ 1>
|
|
<cfif NOT hasSelectedDescendant(graph, node.id)>
|
|
<cfset apiAbort({
|
|
"OK": false,
|
|
"ERROR": "required_selection_missing",
|
|
"MESSAGE": "A required modifier selection is missing.",
|
|
"DETAIL": "LineItemID #node.id# requires at least one descendant selection."
|
|
})>
|
|
</cfif>
|
|
</cfif>
|
|
</cfloop>
|
|
|
|
<!--- Tab-aware submit: if order is on a tab and user is a member, check approval --->
|
|
<cfset tabID = 0>
|
|
<cfset qOrderTab = queryTimed(
|
|
"SELECT TabID FROM Orders WHERE ID = ? LIMIT 1",
|
|
[ { value = OrderID, cfsqltype = "cf_sql_integer" } ],
|
|
{ datasource = "payfrit" }
|
|
)>
|
|
<cfif val(qOrderTab.TabID) GT 0>
|
|
<cfset tabID = val(qOrderTab.TabID)>
|
|
|
|
<!--- Verify tab is open --->
|
|
<cfset qTabCheck = queryTimed(
|
|
"SELECT StatusID, OwnerUserID FROM Tabs WHERE ID = ? AND StatusID = 1 LIMIT 1",
|
|
[ { value = tabID, cfsqltype = "cf_sql_integer" } ],
|
|
{ datasource = "payfrit" }
|
|
)>
|
|
<cfif qTabCheck.recordCount EQ 0>
|
|
<cfset apiAbort({ "OK": false, "ERROR": "tab_not_open", "MESSAGE": "The tab associated with this order is no longer open." })>
|
|
</cfif>
|
|
|
|
<!--- If order user is NOT the tab owner, check approval status --->
|
|
<cfif qOrder.UserID NEQ qTabCheck.OwnerUserID>
|
|
<cfset qApproval = queryTimed(
|
|
"SELECT ApprovalStatus FROM TabOrders WHERE TabID = ? AND OrderID = ? LIMIT 1",
|
|
[ { value = tabID, cfsqltype = "cf_sql_integer" }, { value = OrderID, cfsqltype = "cf_sql_integer" } ],
|
|
{ datasource = "payfrit" }
|
|
)>
|
|
<cfif qApproval.recordCount EQ 0 OR qApproval.ApprovalStatus NEQ "approved">
|
|
<cfset apiAbort({ "OK": false, "ERROR": "not_approved", "MESSAGE": "This order needs tab owner approval before submitting." })>
|
|
</cfif>
|
|
</cfif>
|
|
</cfif>
|
|
|
|
<!--- Submit: mark submitted + status 1 --->
|
|
<cfset queryTimed(
|
|
"
|
|
UPDATE Orders
|
|
SET
|
|
StatusID = 1,
|
|
SubmittedOn = ?,
|
|
LastEditedOn = ?
|
|
WHERE ID = ?
|
|
",
|
|
[
|
|
{ value = now(), cfsqltype = "cf_sql_timestamp" },
|
|
{ value = now(), cfsqltype = "cf_sql_timestamp" },
|
|
{ value = OrderID, cfsqltype = "cf_sql_integer" }
|
|
],
|
|
{ datasource = "payfrit" }
|
|
)>
|
|
|
|
<!--- If on a tab, update running total and last activity --->
|
|
<cfif tabID GT 0>
|
|
<cfset qOrderTotals = queryTimed(
|
|
"SELECT COALESCE(SUM(Price * Quantity), 0) AS Subtotal FROM OrderLineItems WHERE OrderID = ? AND IsDeleted = 0",
|
|
[ { value = OrderID, cfsqltype = "cf_sql_integer" } ],
|
|
{ datasource = "payfrit" }
|
|
)>
|
|
<cfset qBizTax = queryTimed(
|
|
"SELECT TaxRate FROM Businesses WHERE ID = ?",
|
|
[ { value = qOrder.BusinessID, cfsqltype = "cf_sql_integer" } ],
|
|
{ datasource = "payfrit" }
|
|
)>
|
|
<cfset subtotalCents = round(val(qOrderTotals.Subtotal) * 100)>
|
|
<cfset taxCents = round(subtotalCents * val(qBizTax.TaxRate))>
|
|
<cfset queryTimed(
|
|
"UPDATE Tabs SET LastActivityOn = NOW() WHERE ID = ?",
|
|
[ { value = tabID, cfsqltype = "cf_sql_integer" } ],
|
|
{ datasource = "payfrit" }
|
|
)>
|
|
</cfif>
|
|
|
|
<cfset apiAbort({ "OK": true, "ERROR": "", "OrderID": OrderID, "MESSAGE": "submitted", "TAB_ID": tabID })>
|
|
|
|
<cfcatch>
|
|
<cfset apiAbort({
|
|
"OK": false,
|
|
"ERROR": "server_error",
|
|
"MESSAGE": "DB error submitting order",
|
|
"DETAIL": cfcatch.message
|
|
})>
|
|
</cfcatch>
|
|
</cftry>
|