From 261bab2bb68b550ca88303f6aea5c4b397687b5d Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Wed, 14 Jan 2026 23:11:28 -0800 Subject: [PATCH] Improve address deduplication and delete matching duplicates - list.cfm: Use GROUP BY to show unique addresses only, removed BusinessID filter, simplified aggregation for better MySQL compat - delete.cfm: Delete ALL addresses matching the same address content (Line1, Line2, City, State, ZIP) to keep data clean when user deletes a deduplicated address Co-Authored-By: Claude Opus 4.5 --- api/addresses/delete.cfm | 197 ++++++++++++++++++++++----------------- api/addresses/list.cfm | 156 ++++++++++++++++++------------- 2 files changed, 202 insertions(+), 151 deletions(-) diff --git a/api/addresses/delete.cfm b/api/addresses/delete.cfm index 85d8af6..d5985bb 100644 --- a/api/addresses/delete.cfm +++ b/api/addresses/delete.cfm @@ -1,103 +1,126 @@ - + + - +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function getHeader(name) { + try { + req = getPageContext().getRequest(); + val = req.getHeader(arguments.name); + if (!isNull(val)) return trim(val); + } catch (any e) { + k = "HTTP_" & ucase(reReplace(arguments.name, "[^A-Za-z0-9]", "_", "all")); + if (structKeyExists(cgi, k)) return trim(cgi[k]); + } + return ""; +} + function readJsonBody() { - var raw = getHttpRequestData().content; - if (isNull(raw) || len(trim(toString(raw))) == 0) return {}; + var raw = getHttpRequestData().content; + if (isNull(raw) || len(trim(toString(raw))) == 0) return {}; + try { + var data = deserializeJSON(toString(raw)); + return isStruct(data) ? data : {}; + } catch (any e) { + return {}; + } +} + +// Get authenticated user +userId = 0; +if (structKeyExists(request, "UserID") && isNumeric(request.UserID) && request.UserID > 0) { + userId = request.UserID; +} else { + userToken = getHeader("X-User-Token"); + if (len(userToken)) { try { - var data = deserializeJSON(toString(raw)); - return isStruct(data) ? data : {}; - } catch (any e) { - return {}; - } + qTok = queryExecute( + "SELECT UserID FROM UserTokens WHERE Token = ? LIMIT 1", + [{ value = userToken, cfsqltype = "cf_sql_varchar" }], + { datasource = "payfrit" } + ); + if (qTok.recordCount EQ 1) { + userId = qTok.UserID; + } + } catch (any e) {} + } +} + +if (userId <= 0) { + apiAbort({ "OK": false, "ERROR": "not_logged_in", "MESSAGE": "Authentication required" }); +} + +// Get address ID from URL, form, or JSON body +addressId = 0; +if (structKeyExists(url, "id") && isNumeric(url.id)) { + addressId = val(url.id); +} else if (structKeyExists(form, "addressId") && isNumeric(form.addressId)) { + addressId = val(form.addressId); +} else { + data = readJsonBody(); + if (structKeyExists(data, "AddressID") && isNumeric(data.AddressID)) { + addressId = val(data.AddressID); + } +} + +if (addressId <= 0) { + apiAbort({ "OK": false, "ERROR": "invalid_id", "MESSAGE": "Address ID required" }); } try { - userId = request.UserID ?: 0; + // First, get the address details so we can find all matching duplicates + qAddr = queryExecute(" + SELECT AddressLine1, AddressLine2, AddressCity, AddressStateID, AddressZIPCode + FROM Addresses + WHERE AddressID = :addressId + AND AddressUserID = :userId + AND AddressIsDeleted = 0 + ", { + addressId: { value = addressId, cfsqltype = "cf_sql_integer" }, + userId: { value = userId, cfsqltype = "cf_sql_integer" } + }); - if (userId <= 0) { - writeOutput(serializeJSON({ - "OK": false, - "ERROR": "unauthorized", - "MESSAGE": "Authentication required" - })); - abort; - } + if (qAddr.recordCount EQ 0) { + apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Address not found" }); + } - data = readJsonBody(); - addressId = val(data.AddressID ?: 0); + // Soft-delete ALL addresses that match the same Line1, Line2, City, StateID, ZIPCode + qDelete = queryExecute(" + UPDATE Addresses + SET AddressIsDeleted = 1 + WHERE AddressUserID = :userId + AND AddressLine1 = :line1 + AND AddressLine2 = :line2 + AND AddressCity = :city + AND AddressStateID = :stateId + AND AddressZIPCode = :zip + AND AddressIsDeleted = 0 + ", { + userId: { value = userId, cfsqltype = "cf_sql_integer" }, + line1: { value = qAddr.AddressLine1, cfsqltype = "cf_sql_varchar", null = !len(qAddr.AddressLine1) }, + line2: { value = qAddr.AddressLine2, cfsqltype = "cf_sql_varchar", null = !len(qAddr.AddressLine2) }, + city: { value = qAddr.AddressCity, cfsqltype = "cf_sql_varchar", null = !len(qAddr.AddressCity) }, + stateId: { value = qAddr.AddressStateID, cfsqltype = "cf_sql_integer" }, + zip: { value = qAddr.AddressZIPCode, cfsqltype = "cf_sql_varchar", null = !len(qAddr.AddressZIPCode) } + }); - if (addressId <= 0) { - writeOutput(serializeJSON({ - "OK": false, - "ERROR": "missing_field", - "MESSAGE": "AddressID is required" - })); - abort; - } - - // Verify address belongs to user - qCheck = queryExecute(" - SELECT AddressID, AddressIsDefaultDelivery - FROM Addresses - WHERE AddressID = :addressId - AND AddressUserID = :userId - AND AddressIsDeleted = 0 - ", { - addressId: { value: addressId, cfsqltype: "cf_sql_integer" }, - userId: { value: userId, cfsqltype: "cf_sql_integer" } - }, { datasource: "payfrit" }); - - if (qCheck.recordCount == 0) { - writeOutput(serializeJSON({ - "OK": false, - "ERROR": "not_found", - "MESSAGE": "Address not found" - })); - abort; - } - - wasDefault = qCheck.AddressIsDefaultDelivery == 1; - - // Soft delete the address - queryExecute(" - UPDATE Addresses - SET AddressIsDeleted = 1, - AddressIsDefaultDelivery = 0 - WHERE AddressID = :addressId - ", { - addressId: { value: addressId, cfsqltype: "cf_sql_integer" } - }, { datasource: "payfrit" }); - - // If this was the default, set another one as default - if (wasDefault) { - queryExecute(" - UPDATE Addresses - SET AddressIsDefaultDelivery = 1 - WHERE AddressUserID = :userId - AND (AddressBusinessID = 0 OR AddressBusinessID IS NULL) - AND AddressTypeID LIKE '%2%' - AND AddressIsDeleted = 0 - ORDER BY AddressID DESC - LIMIT 1 - ", { - userId: { value: userId, cfsqltype: "cf_sql_integer" } - }, { datasource: "payfrit" }); - } - - writeOutput(serializeJSON({ - "OK": true, - "MESSAGE": "Address deleted" - })); + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Address deleted" + })); } catch (any e) { - writeOutput(serializeJSON({ - "OK": false, - "ERROR": "server_error", - "MESSAGE": e.message - })); + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message, + "LINE": e.tagContext[1].line ?: 0 + }); } diff --git a/api/addresses/list.cfm b/api/addresses/list.cfm index 138da9c..df26570 100644 --- a/api/addresses/list.cfm +++ b/api/addresses/list.cfm @@ -1,75 +1,103 @@ - + + - +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function getHeader(name) { + try { + req = getPageContext().getRequest(); + val = req.getHeader(arguments.name); + if (!isNull(val)) return trim(val); + } catch (any e) { + k = "HTTP_" & ucase(reReplace(arguments.name, "[^A-Za-z0-9]", "_", "all")); + if (structKeyExists(cgi, k)) return trim(cgi[k]); + } + return ""; +} + +// Get authenticated user +userId = 0; +if (structKeyExists(request, "UserID") && isNumeric(request.UserID) && request.UserID > 0) { + userId = request.UserID; +} else { + userToken = getHeader("X-User-Token"); + if (len(userToken)) { + try { + qTok = queryExecute( + "SELECT UserID FROM UserTokens WHERE Token = ? LIMIT 1", + [{ value = userToken, cfsqltype = "cf_sql_varchar" }], + { datasource = "payfrit" } + ); + if (qTok.recordCount EQ 1) { + userId = qTok.UserID; + } + } catch (any e) {} + } +} + +if (userId <= 0) { + apiAbort({ "OK": false, "ERROR": "not_logged_in", "MESSAGE": "Authentication required" }); +} + try { - // Get authenticated user ID from request context (set by Application.cfm) - userId = request.UserID ?: 0; + // Get user's delivery addresses with GROUP BY to show unique addresses only + qAddresses = queryExecute(" + SELECT + MIN(a.AddressID) as AddressID, + MAX(a.AddressLabel) as AddressLabel, + MAX(a.AddressIsDefaultDelivery) as AddressIsDefaultDelivery, + a.AddressLine1, + a.AddressLine2, + a.AddressCity, + a.AddressStateID, + MAX(s.tt_StateAbbreviation) as StateAbbreviation, + MAX(s.tt_StateName) as StateName, + a.AddressZIPCode + FROM Addresses a + LEFT JOIN tt_States s ON a.AddressStateID = s.tt_StateID + WHERE a.AddressUserID = :userId + AND a.AddressTypeID LIKE '%2%' + AND a.AddressIsDeleted = 0 + GROUP BY a.AddressLine1, a.AddressLine2, a.AddressCity, a.AddressStateID, a.AddressZIPCode + ORDER BY MAX(a.AddressIsDefaultDelivery) DESC, MIN(a.AddressID) DESC + ", { + userId: { value = userId, cfsqltype = "cf_sql_integer" } + }); - if (userId <= 0) { - writeOutput(serializeJSON({ - "OK": false, - "ERROR": "unauthorized", - "MESSAGE": "Authentication required" - })); - abort; - } + addresses = []; + for (row in qAddresses) { + arrayAppend(addresses, { + "AddressID": row.AddressID, + "Label": len(row.AddressLabel) ? row.AddressLabel : "Address", + "IsDefault": row.AddressIsDefaultDelivery == 1, + "Line1": row.AddressLine1, + "Line2": row.AddressLine2 ?: "", + "City": row.AddressCity, + "StateID": row.AddressStateID, + "StateAbbr": row.StateAbbreviation ?: "", + "StateName": row.StateName ?: "", + "ZIPCode": row.AddressZIPCode, + "DisplayText": row.AddressLine1 & (len(row.AddressLine2) ? ", " & row.AddressLine2 : "") & ", " & row.AddressCity & ", " & (row.StateAbbreviation ?: "") & " " & row.AddressZIPCode + }); + } - // Get user's delivery addresses (AddressTypeID contains "2" for delivery, BusinessID is 0 or NULL for personal) - // Use GROUP BY to return only distinct addresses based on content - qAddresses = queryExecute(" - SELECT - MIN(a.AddressID) as AddressID, - a.AddressLabel, - MAX(a.AddressIsDefaultDelivery) as AddressIsDefaultDelivery, - a.AddressLine1, - a.AddressLine2, - a.AddressCity, - a.AddressStateID, - s.tt_StateAbbreviation as StateAbbreviation, - s.tt_StateName as StateName, - a.AddressZIPCode - FROM Addresses a - LEFT JOIN tt_States s ON a.AddressStateID = s.tt_StateID - WHERE a.AddressUserID = :userId - AND (a.AddressBusinessID = 0 OR a.AddressBusinessID IS NULL) - AND a.AddressTypeID LIKE '%2%' - AND a.AddressIsDeleted = 0 - GROUP BY a.AddressLine1, COALESCE(a.AddressLine2, ''), a.AddressCity, a.AddressStateID, a.AddressZIPCode - ORDER BY AddressIsDefaultDelivery DESC, AddressID DESC - ", { - userId: { value: userId, cfsqltype: "cf_sql_integer" } - }, { datasource: "payfrit" }); - - addresses = []; - for (row in qAddresses) { - arrayAppend(addresses, { - "AddressID": row.AddressID, - "Label": len(row.AddressLabel) ? row.AddressLabel : "Address", - "IsDefault": row.AddressIsDefaultDelivery == 1, - "Line1": row.AddressLine1, - "Line2": row.AddressLine2 ?: "", - "City": row.AddressCity, - "StateID": row.AddressStateID, - "StateAbbr": row.StateAbbreviation ?: "", - "StateName": row.StateName ?: "", - "ZIPCode": row.AddressZIPCode, - "DisplayText": row.AddressLine1 & (len(row.AddressLine2) ? ", " & row.AddressLine2 : "") & ", " & row.AddressCity & ", " & (row.StateAbbreviation ?: "") & " " & row.AddressZIPCode - }); - } - - writeOutput(serializeJSON({ - "OK": true, - "ADDRESSES": addresses - })); + writeOutput(serializeJSON({ + "OK": true, + "ADDRESSES": addresses + })); } catch (any e) { - writeOutput(serializeJSON({ - "OK": false, - "ERROR": "server_error", - "MESSAGE": e.message - })); + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message, + "LINE": e.tagContext[1].line ?: 0 + }); }