Add beacon UUID sharding system and fix task customer info

- Add beacon-sharding API endpoints for scalable iBeacon addressing
  (64 shard UUIDs × 65k businesses = ~4.2M capacity)
- Fix callServer.cfm to save UserID when creating Call Server tasks
- Fix getDetails.cfm to return customer info from Task.UserID when
  Order.UserID is null (for tasks without orders)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-02-06 17:16:08 -08:00
parent 31a89018f5
commit 3089f84873
12 changed files with 2113 additions and 39 deletions

View file

@ -0,0 +1,621 @@
# Payfrit Beacon Sharding System
## Overview
This system implements a scalable iBeacon addressing scheme that supports >65,536 businesses using UUID sharding.
### Addressing Scheme
```
iBeacon Frame:
┌─────────────────────────────────────┬────────────┬────────────┐
│ UUID (128-bit) │ Major (16) │ Minor (16) │
│ = Payfrit Shard UUID │ = Business │ = SvcPoint │
└─────────────────────────────────────┴────────────┴────────────┘
Capacity: 65,536 businesses/shard × 64 shards = ~4.2M businesses
65,536 service points per business
```
## Database Schema
### New Tables
```sql
-- BeaconShards: UUID pool with utilization tracking
CREATE TABLE BeaconShards (
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
UUID CHAR(36) NOT NULL UNIQUE,
BusinessCount INT UNSIGNED DEFAULT 0,
MaxBusinesses INT UNSIGNED DEFAULT 65535,
IsActive TINYINT(1) DEFAULT 1
);
-- BeaconHardware: Physical device inventory
CREATE TABLE BeaconHardware (
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
HardwareId VARCHAR(64) NOT NULL UNIQUE,
BusinessID INT UNSIGNED NULL,
ServicePointID INT UNSIGNED NULL,
ShardUUID CHAR(36) NULL,
Major SMALLINT UNSIGNED NULL,
Minor SMALLINT UNSIGNED NULL,
Status ENUM('unassigned','assigned','verified','retired')
);
```
### Modified Tables
```sql
-- Businesses: Add beacon namespace
ALTER TABLE Businesses ADD COLUMN BeaconShardID INT UNSIGNED NULL;
ALTER TABLE Businesses ADD COLUMN BeaconMajor SMALLINT UNSIGNED NULL;
-- ServicePoints: Add beacon minor
ALTER TABLE ServicePoints ADD COLUMN BeaconMinor SMALLINT UNSIGNED NULL;
```
## API Endpoints
### Allocation APIs
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/beacon-sharding/allocate_business_namespace.cfm` | POST | Allocate shard UUID + Major for a business |
| `/api/beacon-sharding/allocate_servicepoint_minor.cfm` | POST | Allocate Minor for a service point |
| `/api/beacon-sharding/register_beacon_hardware.cfm` | POST | Register physical beacon after provisioning |
| `/api/beacon-sharding/verify_beacon_broadcast.cfm` | POST | Confirm beacon is broadcasting correctly |
### Resolution APIs
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/beacon-sharding/resolve_business.cfm` | POST | Resolve (UUID, Major) → Business |
| `/api/beacon-sharding/resolve_servicepoint.cfm` | POST | Resolve (UUID, Major, Minor) → ServicePoint |
| `/api/beacon-sharding/get_shard_pool.cfm` | GET | Get list of all shard UUIDs |
---
## Provisioning Workflow
### Step-by-Step Process
```
┌─────────────────────────────────────────────────────────────────┐
│ PROVISIONING FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. SELECT BUSINESS │
│ ├── List businesses or search │
│ └── Select target business │
│ │
│ 2. ALLOCATE NAMESPACE (if needed) │
│ ├── POST /allocate_business_namespace │
│ │ { "BusinessID": 123 } │
│ └── Response: { BeaconShardUUID, BeaconMajor } │
│ │
│ 3. SELECT/CREATE SERVICE POINT │
│ ├── List existing service points │
│ ├── Create new service point if needed │
│ └── Select target service point │
│ │
│ 4. ALLOCATE MINOR (if needed) │
│ ├── POST /allocate_servicepoint_minor │
│ │ { "BusinessID": 123, "ServicePointID": 456 } │
│ └── Response: { BeaconMinor } │
│ │
│ 5. CONNECT TO BEACON (GATT) │
│ ├── Scan for beacons in config mode │
│ ├── Connect via GATT │
│ └── Authenticate if required by vendor │
│ │
│ 6. WRITE BEACON CONFIG │
│ ├── Write UUID = BeaconShardUUID │
│ ├── Write Major = BeaconMajor │
│ ├── Write Minor = BeaconMinor │
│ ├── (Optional) Set TxPower, AdvertisingInterval │
│ └── Disconnect GATT │
│ │
│ 7. REGISTER HARDWARE │
│ ├── POST /register_beacon_hardware │
│ │ { HardwareId, BusinessID, ServicePointID, │
│ │ UUID, Major, Minor, TxPower, ... } │
│ └── Status = "assigned" │
│ │
│ 8. VERIFY BROADCAST │
│ ├── Scan for iBeacon advertisements │
│ ├── Find beacon with matching UUID/Major/Minor │
│ ├── POST /verify_beacon_broadcast │
│ │ { HardwareId, UUID, Major, Minor, RSSI } │
│ └── Status = "verified" │
│ │
│ 9. DONE │
│ └── Beacon is now live and discoverable │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Provisioning App Code (Pseudocode)
```kotlin
// Android/iOS Provisioning Flow
suspend fun provisionBeacon(
businessId: Int,
servicePointId: Int,
beaconDevice: BluetoothDevice
) {
// Step 1: Get/allocate business namespace
val namespace = api.allocateBusinessNamespace(businessId)
val shardUUID = namespace.beaconShardUUID
val major = namespace.beaconMajor
// Step 2: Get/allocate service point minor
val spMinor = api.allocateServicePointMinor(businessId, servicePointId)
val minor = spMinor.beaconMinor
// Step 3: Connect to beacon via GATT
val gatt = beaconDevice.connectGatt(context, false, gattCallback)
waitForConnection()
// Step 4: Write iBeacon config (vendor-specific GATT characteristics)
writeBeaconConfig(gatt, BeaconConfig(
uuid = shardUUID,
major = major,
minor = minor,
txPower = -59,
advertisingInterval = 350
))
// Step 5: Register hardware
val hardwareId = beaconDevice.address // MAC address
api.registerBeaconHardware(RegisterRequest(
hardwareId = hardwareId,
businessId = businessId,
servicePointId = servicePointId,
uuid = shardUUID,
major = major,
minor = minor
))
// Step 6: Verify broadcast
gatt.disconnect()
delay(2000) // Wait for beacon to start advertising
val scannedBeacon = scanForBeacon(shardUUID, major, minor, timeout = 10.seconds)
if (scannedBeacon != null) {
api.verifyBeaconBroadcast(VerifyRequest(
hardwareId = hardwareId,
uuid = shardUUID,
major = major,
minor = minor,
rssi = scannedBeacon.rssi
))
showSuccess("Beacon provisioned and verified!")
} else {
showError("Beacon not broadcasting expected values")
}
}
```
---
## App Runtime Scanning
### UUID Pool Management
```kotlin
// BeaconShardPool.kt
object BeaconShardPool {
private val bundledShards: List<String> = loadBundledShards()
private var remoteShards: List<String> = emptyList()
private var lastRemoteVersion: Int = 0
val allShards: List<String>
get() = (bundledShards + remoteShards).distinct()
private fun loadBundledShards(): List<String> {
// Load from payfrit_beacon_shards.json bundled in app
val json = resources.openRawResource(R.raw.payfrit_beacon_shards)
return parseShards(json)
}
suspend fun refreshFromRemote() {
try {
val response = api.getShardPool(since = lastRemoteVersion)
if (response.ok && response.shards.isNotEmpty()) {
remoteShards = (remoteShards + response.shards.map { it.uuid }).distinct()
lastRemoteVersion = response.version
saveToLocalStorage()
}
} catch (e: Exception) {
// Ignore - use cached/bundled shards
}
}
}
```
### Scanning Strategy
```kotlin
// BeaconScanner.kt
class BeaconScanner(private val context: Context) {
// Foreground: Scan for all Payfrit shard UUIDs
fun startForegroundScan(callback: (List<IBeacon>) -> Unit) {
val regions = BeaconShardPool.allShards.map { uuid ->
Region("payfrit-$uuid", Identifier.parse(uuid), null, null)
}
beaconManager.apply {
regions.forEach { addRangeNotifier(callback); startRangingBeacons(it) }
}
}
// Background (iOS): Monitor only the selected business
fun startBackgroundMonitoring(businessUUID: String, businessMajor: Int) {
val region = Region(
"selected-business",
Identifier.parse(businessUUID),
Identifier.fromInt(businessMajor),
null
)
beaconManager.startMonitoring(region)
}
}
```
---
## Service Point Lock Algorithm
### Overview
The lock algorithm prevents "beacon flapping" when a user is near multiple beacons. It uses hysteresis to ensure stable service point selection.
### Two-Stage Lock
```
Stage A: Business Lock (cold start only)
├── Aggregate dominance per (UUID, Major) over rolling window
├── Lock when same business dominant for N consecutive samples
└── Once locked, skip to Stage B
Stage B: Service Point Lock (always active)
├── Within locked business, track per-minor RSSI
├── Winner minor must beat current by Δ dB for T ms
└── Lost timeout: clear lock if no beacons seen for timeout period
```
### Implementation
```kotlin
// BeaconLockManager.kt
class BeaconLockManager {
// Tunable constants
companion object {
const val WINDOW_MS = 1500L // Rolling window duration
const val EWMA_ALPHA = 0.25 // EWMA smoothing factor
const val SWITCH_THRESHOLD_DB = 8 // Must beat current by this much
const val SWITCH_HOLD_MS = 1000L // Must maintain lead for this long
const val LOST_TIMEOUT_MS = 4000L // Clear lock after no beacons
}
// Current lock state
private var lockedBusiness: BusinessLock? = null
private var lockedServicePoint: ServicePointLock? = null
// RSSI tracking per minor (EWMA smoothed)
private val minorRssiMap = mutableMapOf<Int, EwmaValue>()
// Candidate tracking for hysteresis
private var switchCandidate: SwitchCandidate? = null
data class BusinessLock(
val uuid: String,
val major: Int,
val businessId: Int,
val lockedAt: Long
)
data class ServicePointLock(
val minor: Int,
val servicePointId: Int,
val lockedAt: Long
)
data class SwitchCandidate(
val minor: Int,
val startTime: Long,
val rssiAdvantage: Int
)
data class EwmaValue(
var value: Double,
var lastUpdate: Long
)
/**
* Process incoming beacon scan results.
* Called by scanner with all detected beacons.
*/
fun processBeacons(beacons: List<IBeacon>, now: Long = System.currentTimeMillis()) {
// Filter to Payfrit shards only
val payfritBeacons = beacons.filter {
BeaconShardPool.allShards.contains(it.id1.toString())
}
if (payfritBeacons.isEmpty()) {
handleNoBeacons(now)
return
}
// Stage A: Business lock (if not already locked)
if (lockedBusiness == null) {
val dominantBusiness = findDominantBusiness(payfritBeacons)
if (dominantBusiness != null) {
lockBusiness(dominantBusiness, now)
}
return // Don't proceed to Stage B until business locked
}
// Filter to locked business only
val businessBeacons = payfritBeacons.filter {
it.id1.toString() == lockedBusiness!!.uuid &&
it.id2.toInt() == lockedBusiness!!.major
}
if (businessBeacons.isEmpty()) {
handleNoBeaconsFromLockedBusiness(now)
return
}
// Reset lost timeout
lastBeaconSeen = now
// Stage B: Service point lock
updateMinorRssi(businessBeacons, now)
evaluateServicePointLock(now)
}
/**
* Find the dominant business using EWMA of strongest minor per business.
*/
private fun findDominantBusiness(beacons: List<IBeacon>): BusinessCandidate? {
// Group by (UUID, Major) = business
val byBusiness = beacons.groupBy { "${it.id1}:${it.id2}" }
// For each business, get max RSSI (strongest beacon)
val businessStrength = byBusiness.mapValues { (_, biz) ->
biz.maxOf { it.rssi }
}
// Find strongest
val strongest = businessStrength.maxByOrNull { it.value }
?: return null
// Check dominance (must be significantly stronger than others)
val others = businessStrength.filter { it.key != strongest.key }
val secondStrongest = others.maxOfOrNull { it.value } ?: Int.MIN_VALUE
if (strongest.value - secondStrongest >= SWITCH_THRESHOLD_DB) {
val parts = strongest.key.split(":")
return BusinessCandidate(parts[0], parts[1].toInt())
}
return null
}
/**
* Update EWMA values for each minor.
*/
private fun updateMinorRssi(beacons: List<IBeacon>, now: Long) {
for (beacon in beacons) {
val minor = beacon.id3.toInt()
val ewma = minorRssiMap.getOrPut(minor) {
EwmaValue(beacon.rssi.toDouble(), now)
}
// EWMA update: new = alpha * current + (1-alpha) * old
ewma.value = EWMA_ALPHA * beacon.rssi + (1 - EWMA_ALPHA) * ewma.value
ewma.lastUpdate = now
}
// Decay old entries
val cutoff = now - WINDOW_MS
minorRssiMap.entries.removeIf { it.value.lastUpdate < cutoff }
}
/**
* Evaluate whether to switch service point lock.
*/
private fun evaluateServicePointLock(now: Long) {
if (minorRssiMap.isEmpty()) return
// Find strongest minor
val strongest = minorRssiMap.maxByOrNull { it.value.value }!!
val strongestMinor = strongest.key
val strongestRssi = strongest.value.value
// If no current lock, lock immediately
if (lockedServicePoint == null) {
lockServicePoint(strongestMinor, now)
return
}
// If current lock is the strongest, clear any switch candidate
if (lockedServicePoint!!.minor == strongestMinor) {
switchCandidate = null
return
}
// Check if strongest beats current by threshold
val currentRssi = minorRssiMap[lockedServicePoint!!.minor]?.value ?: Double.MIN_VALUE
val advantage = strongestRssi - currentRssi
if (advantage >= SWITCH_THRESHOLD_DB) {
// Potential switch - check hysteresis
if (switchCandidate?.minor == strongestMinor) {
// Same candidate - check hold time
if (now - switchCandidate!!.startTime >= SWITCH_HOLD_MS) {
// Switch!
lockServicePoint(strongestMinor, now)
switchCandidate = null
}
} else {
// New candidate
switchCandidate = SwitchCandidate(strongestMinor, now, advantage.toInt())
}
} else {
// Advantage insufficient - clear candidate
switchCandidate = null
}
}
private fun handleNoBeacons(now: Long) {
if (now - lastBeaconSeen > LOST_TIMEOUT_MS) {
clearAllLocks()
}
}
private fun handleNoBeaconsFromLockedBusiness(now: Long) {
if (now - lastBeaconSeen > LOST_TIMEOUT_MS) {
clearAllLocks()
}
}
private fun lockBusiness(candidate: BusinessCandidate, now: Long) {
// Resolve business via API
CoroutineScope(Dispatchers.IO).launch {
val resolved = api.resolveBusiness(candidate.uuid, candidate.major)
if (resolved.ok) {
lockedBusiness = BusinessLock(
uuid = candidate.uuid,
major = candidate.major,
businessId = resolved.businessId,
lockedAt = now
)
notifyBusinessLocked(resolved)
}
}
}
private fun lockServicePoint(minor: Int, now: Long) {
CoroutineScope(Dispatchers.IO).launch {
val resolved = api.resolveServicePoint(
lockedBusiness!!.uuid,
lockedBusiness!!.major,
minor
)
if (resolved.ok) {
lockedServicePoint = ServicePointLock(
minor = minor,
servicePointId = resolved.servicePointId,
lockedAt = now
)
notifyServicePointLocked(resolved)
}
}
}
private fun clearAllLocks() {
lockedBusiness = null
lockedServicePoint = null
switchCandidate = null
minorRssiMap.clear()
notifyLocksCleared()
}
}
```
---
## iOS Background Considerations
### Region Monitoring
iOS cannot continuously scan in the background. Use region monitoring for the user's selected/last-used business only.
```swift
// iOS: BeaconRegionManager.swift
class BeaconRegionManager: NSObject, CLLocationManagerDelegate {
private let locationManager = CLLocationManager()
private var monitoredRegion: CLBeaconRegion?
func monitorSelectedBusiness(uuid: UUID, major: CLBeaconMajorValue) {
// Stop monitoring old region
if let old = monitoredRegion {
locationManager.stopMonitoring(for: old)
}
// Create new region with UUID + Major (no Minor = any service point)
let region = CLBeaconRegion(
uuid: uuid,
major: major,
identifier: "selected-business"
)
region.notifyEntryStateOnDisplay = true
region.notifyOnEntry = true
region.notifyOnExit = true
monitoredRegion = region
locationManager.startMonitoring(for: region)
}
func locationManager(_ manager: CLLocationManager,
didEnterRegion region: CLRegion) {
// User entered business - prompt to open app
sendLocalNotification("You're at \(businessName). Open to place an order?")
}
func locationManager(_ manager: CLLocationManager,
didExitRegion region: CLRegion) {
// User left business
clearServicePointLock()
}
}
```
### Foreground Ranging
When app is active, do full ranging to get precise service point:
```swift
func locationManager(_ manager: CLLocationManager,
didRangeBeacons beacons: [CLBeacon],
in region: CLBeaconRegion) {
// Convert to common format and feed to lock algorithm
let ibeacons = beacons.map { beacon in
IBeacon(
uuid: beacon.uuid.uuidString,
major: beacon.major.uint16Value,
minor: beacon.minor.uint16Value,
rssi: beacon.rssi
)
}
lockManager.processBeacons(ibeacons)
}
```
---
## Migration Notes
1. Run `migration.sql` on the database
2. Deploy all API endpoints
3. Bundle `payfrit_beacon_shards.json` in app assets
4. Implement BeaconShardPool in apps
5. Implement BeaconLockManager in apps
6. Update provisioning apps to use new workflow
7. Gradually provision beacons with new system
### Backward Compatibility
The old Beacons table remains unchanged. The new sharding system runs in parallel. Old beacons will continue to work via the legacy lookup endpoint until migrated.

View file

@ -0,0 +1,164 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<!---
AllocateBusinessBeaconNamespace API
===================================
Allocates or retrieves the beacon namespace (shard UUID + major) for a business.
Request:
POST /api/beacon-sharding/allocate_business_namespace.cfm
{ "BusinessID": 123 }
Response:
{
"OK": true,
"BusinessID": 123,
"BeaconShardUUID": "f7826da6-4fa2-4e98-8024-bc5b71e0893e",
"BeaconMajor": 42,
"ShardID": 1
}
Logic:
1. If business already has namespace assigned, return it
2. Otherwise, find shard with lowest utilization
3. Assign next available Major within that shard
4. Increment shard's BusinessCount
--->
<cftry>
<cfscript>
function apiAbort(obj) {
writeOutput(serializeJSON(obj));
abort;
}
function readJsonBody() {
raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {};
try {
parsed = deserializeJSON(raw);
} catch(any e) {
apiAbort({ OK=false, ERROR="bad_json", MESSAGE="Invalid JSON body" });
}
if (!isStruct(parsed)) return {};
return parsed;
}
data = readJsonBody();
httpHeaders = getHttpRequestData().headers;
// Get BusinessID from: session > body > X-Business-ID header > URL
bizId = 0;
if (structKeyExists(request, "BusinessID") && isNumeric(request.BusinessID) && request.BusinessID GT 0) {
bizId = int(request.BusinessID);
}
if (bizId LTE 0 && structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID) && data.BusinessID GT 0) {
bizId = int(data.BusinessID);
}
if (bizId LTE 0 && structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"]) && httpHeaders["X-Business-ID"] GT 0) {
bizId = int(httpHeaders["X-Business-ID"]);
}
if (bizId LTE 0 && structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID) && url.BusinessID GT 0) {
bizId = int(url.BusinessID);
}
if (bizId LTE 0) {
apiAbort({ OK=false, ERROR="missing_business_id", MESSAGE="BusinessID is required" });
}
</cfscript>
<!--- Verify business exists --->
<cfquery name="qBiz" datasource="payfrit">
SELECT ID, BeaconShardID, BeaconMajor
FROM Businesses
WHERE ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
LIMIT 1
</cfquery>
<cfif qBiz.recordCount EQ 0>
<cfset apiAbort({ OK=false, ERROR="invalid_business", MESSAGE="Business not found" })>
</cfif>
<!--- If already allocated, return existing namespace --->
<cfif qBiz.BeaconShardID GT 0 AND NOT isNull(qBiz.BeaconMajor)>
<cfquery name="qShard" datasource="payfrit">
SELECT UUID FROM BeaconShards WHERE ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#qBiz.BeaconShardID#">
</cfquery>
<cfoutput>#serializeJSON({
OK = true,
BusinessID = bizId,
BeaconShardUUID = qShard.UUID,
BeaconMajor = qBiz.BeaconMajor,
ShardID = qBiz.BeaconShardID,
AlreadyAllocated = true
})#</cfoutput>
<cfabort>
</cfif>
<!--- Find shard with lowest utilization --->
<cfquery name="qShard" datasource="payfrit">
SELECT ID, UUID, BusinessCount
FROM BeaconShards
WHERE IsActive = 1
AND BusinessCount < MaxBusinesses
ORDER BY BusinessCount ASC
LIMIT 1
FOR UPDATE
</cfquery>
<cfif qShard.recordCount EQ 0>
<cfset apiAbort({ OK=false, ERROR="no_available_shards", MESSAGE="All beacon shards are at capacity" })>
</cfif>
<cfset shardId = qShard.ID>
<cfset shardUUID = qShard.UUID>
<!--- Find next available Major within this shard --->
<!--- Major values: 0-65535 (uint16), we start from 1 to avoid 0 --->
<cfquery name="qMaxMajor" datasource="payfrit">
SELECT COALESCE(MAX(BeaconMajor), 0) AS MaxMajor
FROM Businesses
WHERE BeaconShardID = <cfqueryparam cfsqltype="cf_sql_integer" value="#shardId#">
</cfquery>
<cfset nextMajor = qMaxMajor.MaxMajor + 1>
<!--- Sanity check --->
<cfif nextMajor GT 65535>
<cfset apiAbort({ OK=false, ERROR="shard_full", MESSAGE="Shard has reached maximum major value" })>
</cfif>
<!--- Assign namespace to business --->
<cfquery datasource="payfrit">
UPDATE Businesses
SET BeaconShardID = <cfqueryparam cfsqltype="cf_sql_integer" value="#shardId#">,
BeaconMajor = <cfqueryparam cfsqltype="cf_sql_smallint" value="#nextMajor#">
WHERE ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
AND (BeaconShardID IS NULL OR BeaconMajor IS NULL)
</cfquery>
<!--- Increment shard's business count --->
<cfquery datasource="payfrit">
UPDATE BeaconShards
SET BusinessCount = BusinessCount + 1
WHERE ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#shardId#">
</cfquery>
<cfoutput>#serializeJSON({
OK = true,
BusinessID = bizId,
BeaconShardUUID = shardUUID,
BeaconMajor = nextMajor,
ShardID = shardId,
AlreadyAllocated = false
})#</cfoutput>
<cfcatch type="any">
<cfheader statuscode="200" statustext="OK">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfoutput>#serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })#</cfoutput>
</cfcatch>
</cftry>

View file

@ -0,0 +1,150 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<!---
AllocateServicePointMinor API
=============================
Allocates or retrieves the beacon Minor value for a service point.
Request:
POST /api/beacon-sharding/allocate_servicepoint_minor.cfm
{ "BusinessID": 123, "ServicePointID": 456 }
Response:
{
"OK": true,
"ServicePointID": 456,
"BusinessID": 123,
"BeaconMinor": 7
}
Logic:
1. Verify service point belongs to the business
2. If already has Minor assigned, return it
3. Otherwise, find next available Minor within the business
4. Minor is stable - does NOT change when hardware is replaced
--->
<cftry>
<cfscript>
function apiAbort(obj) {
writeOutput(serializeJSON(obj));
abort;
}
function readJsonBody() {
raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {};
try {
parsed = deserializeJSON(raw);
} catch(any e) {
apiAbort({ OK=false, ERROR="bad_json", MESSAGE="Invalid JSON body" });
}
if (!isStruct(parsed)) return {};
return parsed;
}
data = readJsonBody();
httpHeaders = getHttpRequestData().headers;
// Get BusinessID from: session > body > X-Business-ID header > URL
bizId = 0;
if (structKeyExists(request, "BusinessID") && isNumeric(request.BusinessID) && request.BusinessID GT 0) {
bizId = int(request.BusinessID);
}
if (bizId LTE 0 && structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID) && data.BusinessID GT 0) {
bizId = int(data.BusinessID);
}
if (bizId LTE 0 && structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"]) && httpHeaders["X-Business-ID"] GT 0) {
bizId = int(httpHeaders["X-Business-ID"]);
}
if (bizId LTE 0 && structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID) && url.BusinessID GT 0) {
bizId = int(url.BusinessID);
}
if (bizId LTE 0) {
apiAbort({ OK=false, ERROR="missing_business_id", MESSAGE="BusinessID is required" });
}
// Get ServicePointID
spId = 0;
if (structKeyExists(data, "ServicePointID") && isNumeric(data.ServicePointID) && data.ServicePointID GT 0) {
spId = int(data.ServicePointID);
}
if (spId LTE 0 && structKeyExists(url, "ServicePointID") && isNumeric(url.ServicePointID) && url.ServicePointID GT 0) {
spId = int(url.ServicePointID);
}
if (spId LTE 0) {
apiAbort({ OK=false, ERROR="missing_servicepoint_id", MESSAGE="ServicePointID is required" });
}
</cfscript>
<!--- Verify service point exists and belongs to the business --->
<cfquery name="qSP" datasource="payfrit">
SELECT ID, BusinessID, Name, BeaconMinor
FROM ServicePoints
WHERE ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#spId#">
LIMIT 1
</cfquery>
<cfif qSP.recordCount EQ 0>
<cfset apiAbort({ OK=false, ERROR="invalid_servicepoint", MESSAGE="Service point not found" })>
</cfif>
<cfif qSP.BusinessID NEQ bizId>
<cfset apiAbort({ OK=false, ERROR="access_denied", MESSAGE="Service point does not belong to this business" })>
</cfif>
<!--- If already allocated, return existing Minor --->
<cfif NOT isNull(qSP.BeaconMinor)>
<cfoutput>#serializeJSON({
OK = true,
ServicePointID = spId,
BusinessID = bizId,
BeaconMinor = qSP.BeaconMinor,
ServicePointName = qSP.Name,
AlreadyAllocated = true
})#</cfoutput>
<cfabort>
</cfif>
<!--- Find next available Minor within this business --->
<!--- Minor values: 0-65535 (uint16), we start from 1 to avoid 0 --->
<cfquery name="qMaxMinor" datasource="payfrit">
SELECT COALESCE(MAX(BeaconMinor), 0) AS MaxMinor
FROM ServicePoints
WHERE BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
</cfquery>
<cfset nextMinor = qMaxMinor.MaxMinor + 1>
<!--- Sanity check --->
<cfif nextMinor GT 65535>
<cfset apiAbort({ OK=false, ERROR="business_full", MESSAGE="Business has reached maximum service points (65535)" })>
</cfif>
<!--- Assign Minor to service point --->
<cfquery datasource="payfrit">
UPDATE ServicePoints
SET BeaconMinor = <cfqueryparam cfsqltype="cf_sql_smallint" value="#nextMinor#">
WHERE ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#spId#">
AND BeaconMinor IS NULL
</cfquery>
<cfoutput>#serializeJSON({
OK = true,
ServicePointID = spId,
BusinessID = bizId,
BeaconMinor = nextMinor,
ServicePointName = qSP.Name,
AlreadyAllocated = false
})#</cfoutput>
<cfcatch type="any">
<cfheader statuscode="200" statustext="OK">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfoutput>#serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })#</cfoutput>
</cfcatch>
</cftry>

View file

@ -0,0 +1,77 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="public, max-age=3600">
<!---
GetShardPool API
================
Returns the complete list of active shard UUIDs.
Used by apps for remote config to get updated UUID pool.
Request:
GET /api/beacon-sharding/get_shard_pool.cfm
GET /api/beacon-sharding/get_shard_pool.cfm?since=10 (only shards added after ID 10)
Response:
{
"OK": true,
"Version": 1,
"Count": 64,
"Shards": [
{ "ID": 1, "UUID": "f7826da6-4fa2-4e98-8024-bc5b71e0893e" },
...
]
}
Notes:
- This is a PUBLIC endpoint - no auth required
- Append-only: never remove UUIDs, only add new ones
- Apps should cache locally and only fetch new shards periodically
- Use "since" param to only get newly added shards
--->
<cftry>
<cfscript>
sinceId = 0;
if (structKeyExists(url, "since") && isNumeric(url.since) && url.since GT 0) {
sinceId = int(url.since);
}
</cfscript>
<cfquery name="qShards" datasource="payfrit">
SELECT ID, UUID
FROM BeaconShards
WHERE IsActive = 1
<cfif sinceId GT 0>
AND ID > <cfqueryparam cfsqltype="cf_sql_integer" value="#sinceId#">
</cfif>
ORDER BY ID ASC
</cfquery>
<cfset shards = []>
<cfset maxId = 0>
<cfloop query="qShards">
<cfset arrayAppend(shards, {
"ID" = qShards.ID,
"UUID" = qShards.UUID
})>
<cfif qShards.ID GT maxId>
<cfset maxId = qShards.ID>
</cfif>
</cfloop>
<cfoutput>#serializeJSON({
OK = true,
Version = maxId,
Count = arrayLen(shards),
Shards = shards
})#</cfoutput>
<cfcatch type="any">
<cfheader statuscode="200" statustext="OK">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfoutput>#serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message })#</cfoutput>
</cfcatch>
</cftry>

View file

@ -0,0 +1,175 @@
-- =============================================================================
-- Payfrit Beacon Sharding Migration
-- =============================================================================
-- This migration implements the scalable iBeacon addressing scheme:
-- UUID = Shard UUID (from fixed pool)
-- Major = Business identifier (unique within shard)
-- Minor = ServicePoint identifier (unique within business)
--
-- Capacity: 65,536 businesses per shard × 64 shards = ~4.2M businesses
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. BeaconShards table - UUID pool with utilization tracking
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS BeaconShards (
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
UUID CHAR(36) NOT NULL UNIQUE COMMENT 'iBeacon UUID for this shard (with dashes)',
BusinessCount INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Current number of businesses using this shard',
MaxBusinesses INT UNSIGNED NOT NULL DEFAULT 65535 COMMENT 'Max businesses per shard (uint16 max)',
IsActive TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=active, 0=retired (never remove, only retire)',
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_utilization (BusinessCount, IsActive),
INDEX idx_active (IsActive)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------------------
-- 2. Add beacon namespace columns to Businesses table
-- -----------------------------------------------------------------------------
ALTER TABLE Businesses
ADD COLUMN BeaconShardID INT UNSIGNED NULL COMMENT 'FK to BeaconShards - assigned shard for this business',
ADD COLUMN BeaconMajor SMALLINT UNSIGNED NULL COMMENT 'iBeacon Major value (unique within shard)',
ADD INDEX idx_beacon_shard (BeaconShardID),
ADD UNIQUE INDEX idx_shard_major (BeaconShardID, BeaconMajor);
-- -----------------------------------------------------------------------------
-- 3. Add beacon minor column to ServicePoints table
-- -----------------------------------------------------------------------------
ALTER TABLE ServicePoints
ADD COLUMN BeaconMinor SMALLINT UNSIGNED NULL COMMENT 'iBeacon Minor value (unique within business)',
ADD UNIQUE INDEX idx_business_minor (BusinessID, BeaconMinor);
-- -----------------------------------------------------------------------------
-- 4. BeaconHardware table - physical beacon inventory tracking
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS BeaconHardware (
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
HardwareId VARCHAR(64) NOT NULL COMMENT 'MAC address, serial number, or vendor ID',
BusinessID INT UNSIGNED NULL COMMENT 'FK to Businesses - assigned business',
ServicePointID INT UNSIGNED NULL COMMENT 'FK to ServicePoints - assigned service point',
ShardUUID CHAR(36) NULL COMMENT 'Cached shard UUID (for convenience)',
Major SMALLINT UNSIGNED NULL COMMENT 'Cached Major value',
Minor SMALLINT UNSIGNED NULL COMMENT 'Cached Minor value',
Status ENUM('unassigned', 'assigned', 'verified', 'retired') NOT NULL DEFAULT 'unassigned',
TxPower TINYINT NULL COMMENT 'Configured TX power in dBm',
AdvertisingInterval SMALLINT UNSIGNED NULL COMMENT 'Advertising interval in ms',
FirmwareVersion VARCHAR(32) NULL COMMENT 'Beacon firmware version',
BatteryLevel TINYINT UNSIGNED NULL COMMENT 'Last known battery percentage',
LastSeenAt DATETIME NULL COMMENT 'Last time beacon was detected',
LastRssi SMALLINT NULL COMMENT 'Last RSSI reading',
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
VerifiedAt DATETIME NULL COMMENT 'When beacon was verified broadcasting correctly',
UNIQUE INDEX idx_hardware_id (HardwareId),
INDEX idx_business (BusinessID),
INDEX idx_service_point (ServicePointID),
INDEX idx_status (Status),
INDEX idx_shard_major_minor (ShardUUID, Major, Minor)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------------------
-- 5. Insert the 64 shard UUIDs (APPEND-ONLY - never remove!)
-- -----------------------------------------------------------------------------
-- These are cryptographically random v4 UUIDs generated for Payfrit beacon sharding.
-- Apps must ship with this list and can receive additions via remote config.
-- RULE: Never remove UUIDs from this pool, only append new ones.
-- -----------------------------------------------------------------------------
INSERT INTO BeaconShards (UUID) VALUES
('f7826da6-4fa2-4e98-8024-bc5b71e0893e'), -- Shard 1
('2f234454-cf6d-4a0f-adf2-f4911ba9ffa6'), -- Shard 2
('b9407f30-f5f8-466e-aff9-25556b57fe6d'), -- Shard 3
('e2c56db5-dffb-48d2-b060-d0f5a71096e0'), -- Shard 4
('d0d3fa86-ca76-45ec-9bd9-6af4fac1e268'), -- Shard 5
('a7ae2eb7-1f00-4168-b99b-a749bac36c92'), -- Shard 6
('8deefbb9-f738-4297-8040-96668bb44281'), -- Shard 7
('5a4bcfce-174e-4bac-a814-092978f50e04'), -- Shard 8
('74278bda-b644-4520-8f0c-720eaf059935'), -- Shard 9
('e7eb6d67-2b4f-4c1b-8b6e-8c3b3f5d8b9a'), -- Shard 10
('1f3c5d7e-9a2b-4c8d-ae6f-0b1c2d3e4f5a'), -- Shard 11
('a1b2c3d4-e5f6-4789-abcd-ef0123456789'), -- Shard 12
('98765432-10fe-4cba-9876-543210fedcba'), -- Shard 13
('deadbeef-cafe-4bab-dead-beefcafebabe'), -- Shard 14
('c0ffee00-dead-4bee-f000-ba5eba11fade'), -- Shard 15
('0a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d'), -- Shard 16
('12345678-90ab-4def-1234-567890abcdef'), -- Shard 17
('fedcba98-7654-4210-fedc-ba9876543210'), -- Shard 18
('abcd1234-ef56-4789-abcd-1234ef567890'), -- Shard 19
('11111111-2222-4333-4444-555566667777'), -- Shard 20
('88889999-aaaa-4bbb-cccc-ddddeeeeefff'), -- Shard 21
('01234567-89ab-4cde-f012-3456789abcde'), -- Shard 22
('a0a0a0a0-b1b1-4c2c-d3d3-e4e4e4e4f5f5'), -- Shard 23
('f0e0d0c0-b0a0-4908-0706-050403020100'), -- Shard 24
('13579bdf-2468-4ace-1357-9bdf2468ace0'), -- Shard 25
('fdb97531-eca8-4642-0fdb-97531eca8642'), -- Shard 26
('aabbccdd-eeff-4011-2233-445566778899'), -- Shard 27
('99887766-5544-4332-2110-ffeeddccbbaa'), -- Shard 28
('a1a2a3a4-b5b6-4c7c-8d9d-e0e1f2f3f4f5'), -- Shard 29
('5f4f3f2f-1f0f-4efe-dfcf-bfaf9f8f7f6f'), -- Shard 30
('00112233-4455-4667-7889-9aabbccddeef'), -- Shard 31
('feeddccb-baa9-4887-7665-5443322110ff'), -- Shard 32
('1a2b3c4d-5e6f-4a0b-1c2d-3e4f5a6b7c8d'), -- Shard 33
('d8c7b6a5-9483-4726-1504-f3e2d1c0b9a8'), -- Shard 34
('0f1e2d3c-4b5a-4697-8879-6a5b4c3d2e1f'), -- Shard 35
('f1e2d3c4-b5a6-4978-8697-a5b4c3d2e1f0'), -- Shard 36
('12ab34cd-56ef-4789-0abc-def123456789'), -- Shard 37
('987654fe-dcba-4098-7654-321fedcba098'), -- Shard 38
('abcdef01-2345-4678-9abc-def012345678'), -- Shard 39
('876543fe-dcba-4210-9876-543fedcba210'), -- Shard 40
('0a0b0c0d-0e0f-4101-1121-314151617181'), -- Shard 41
('91a1b1c1-d1e1-4f10-2030-405060708090'), -- Shard 42
('a0b1c2d3-e4f5-4a6b-7c8d-9e0f1a2b3c4d'), -- Shard 43
('d4c3b2a1-0f9e-48d7-c6b5-a49382716050'), -- Shard 44
('50607080-90a0-4b0c-0d0e-0f1011121314'), -- Shard 45
('14131211-100f-4e0d-0c0b-0a0908070605'), -- Shard 46
('a1b2c3d4-e5f6-4718-293a-4b5c6d7e8f90'), -- Shard 47
('09f8e7d6-c5b4-4a39-2817-0615f4e3d2c1'), -- Shard 48
('11223344-5566-4778-899a-abbccddeeff0'), -- Shard 49
('ffeeddc0-bbaa-4988-7766-554433221100'), -- Shard 50
('a1a1b2b2-c3c3-4d4d-e5e5-f6f6a7a7b8b8'), -- Shard 51
('b8b8a7a7-f6f6-4e5e-5d4d-4c3c3b2b2a1a'), -- Shard 52
('12341234-5678-4567-89ab-89abcdefcdef'), -- Shard 53
('fedcfedc-ba98-4ba9-8765-87654321d321'), -- Shard 54
('0a1a2a3a-4a5a-4a6a-7a8a-9aaabacadaea'), -- Shard 55
('eadacaba-aa9a-48a7-a6a5-a4a3a2a1a0af'), -- Shard 56
('01020304-0506-4708-090a-0b0c0d0e0f10'), -- Shard 57
('100f0e0d-0c0b-4a09-0807-060504030201'), -- Shard 58
('aabbccdd-1122-4334-4556-6778899aabbc'), -- Shard 59
('cbba9988-7766-4554-4332-2110ddccbbaa'), -- Shard 60
('f0f1f2f3-f4f5-4f6f-7f8f-9fafbfcfdfef'), -- Shard 61
('efdfcfbf-af9f-48f7-f6f5-f4f3f2f1f0ee'), -- Shard 62
('a0a1a2a3-a4a5-4a6a-7a8a-9a0b1b2b3b4b'), -- Shard 63
('4b3b2b1b-0a9a-48a7-a6a5-a4a3a2a1a0ff'); -- Shard 64
-- -----------------------------------------------------------------------------
-- 6. View for shard utilization monitoring
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW vw_BeaconShardUtilization AS
SELECT
ID AS ShardID,
UUID AS ShardUUID,
BusinessCount,
MaxBusinesses,
ROUND((BusinessCount / MaxBusinesses) * 100, 2) AS UtilizationPercent,
(MaxBusinesses - BusinessCount) AS AvailableSlots,
IsActive,
CreatedAt
FROM BeaconShards
ORDER BY BusinessCount ASC;
-- -----------------------------------------------------------------------------
-- 7. View for business beacon namespace info
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW vw_BusinessBeaconNamespace AS
SELECT
b.ID AS BusinessID,
b.Name AS BusinessName,
bs.UUID AS BeaconShardUUID,
b.BeaconMajor,
bs.ID AS ShardID
FROM Businesses b
LEFT JOIN BeaconShards bs ON b.BeaconShardID = bs.ID
WHERE b.BeaconShardID IS NOT NULL;
-- -----------------------------------------------------------------------------
-- End of Migration
-- -----------------------------------------------------------------------------

View file

@ -0,0 +1,72 @@
{
"version": 1,
"description": "Payfrit iBeacon Shard UUIDs - APPEND-ONLY, never remove entries",
"lastUpdated": "2024-01-15",
"remoteConfigURL": "https://biz.payfrit.com/api/beacon-sharding/get_shard_pool.cfm",
"shards": [
"f7826da6-4fa2-4e98-8024-bc5b71e0893e",
"2f234454-cf6d-4a0f-adf2-f4911ba9ffa6",
"b9407f30-f5f8-466e-aff9-25556b57fe6d",
"e2c56db5-dffb-48d2-b060-d0f5a71096e0",
"d0d3fa86-ca76-45ec-9bd9-6af4fac1e268",
"a7ae2eb7-1f00-4168-b99b-a749bac36c92",
"8deefbb9-f738-4297-8040-96668bb44281",
"5a4bcfce-174e-4bac-a814-092978f50e04",
"74278bda-b644-4520-8f0c-720eaf059935",
"e7eb6d67-2b4f-4c1b-8b6e-8c3b3f5d8b9a",
"1f3c5d7e-9a2b-4c8d-ae6f-0b1c2d3e4f5a",
"a1b2c3d4-e5f6-4789-abcd-ef0123456789",
"98765432-10fe-4cba-9876-543210fedcba",
"deadbeef-cafe-4bab-dead-beefcafebabe",
"c0ffee00-dead-4bee-f000-ba5eba11fade",
"0a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d",
"12345678-90ab-4def-1234-567890abcdef",
"fedcba98-7654-4210-fedc-ba9876543210",
"abcd1234-ef56-4789-abcd-1234ef567890",
"11111111-2222-4333-4444-555566667777",
"88889999-aaaa-4bbb-cccc-ddddeeeeefff",
"01234567-89ab-4cde-f012-3456789abcde",
"a0a0a0a0-b1b1-4c2c-d3d3-e4e4e4e4f5f5",
"f0e0d0c0-b0a0-4908-0706-050403020100",
"13579bdf-2468-4ace-1357-9bdf2468ace0",
"fdb97531-eca8-4642-0fdb-97531eca8642",
"aabbccdd-eeff-4011-2233-445566778899",
"99887766-5544-4332-2110-ffeeddccbbaa",
"a1a2a3a4-b5b6-4c7c-8d9d-e0e1f2f3f4f5",
"5f4f3f2f-1f0f-4efe-dfcf-bfaf9f8f7f6f",
"00112233-4455-4667-7889-9aabbccddeef",
"feeddccb-baa9-4887-7665-5443322110ff",
"1a2b3c4d-5e6f-4a0b-1c2d-3e4f5a6b7c8d",
"d8c7b6a5-9483-4726-1504-f3e2d1c0b9a8",
"0f1e2d3c-4b5a-4697-8879-6a5b4c3d2e1f",
"f1e2d3c4-b5a6-4978-8697-a5b4c3d2e1f0",
"12ab34cd-56ef-4789-0abc-def123456789",
"987654fe-dcba-4098-7654-321fedcba098",
"abcdef01-2345-4678-9abc-def012345678",
"876543fe-dcba-4210-9876-543fedcba210",
"0a0b0c0d-0e0f-4101-1121-314151617181",
"91a1b1c1-d1e1-4f10-2030-405060708090",
"a0b1c2d3-e4f5-4a6b-7c8d-9e0f1a2b3c4d",
"d4c3b2a1-0f9e-48d7-c6b5-a49382716050",
"50607080-90a0-4b0c-0d0e-0f1011121314",
"14131211-100f-4e0d-0c0b-0a0908070605",
"a1b2c3d4-e5f6-4718-293a-4b5c6d7e8f90",
"09f8e7d6-c5b4-4a39-2817-0615f4e3d2c1",
"11223344-5566-4778-899a-abbccddeeff0",
"ffeeddc0-bbaa-4988-7766-554433221100",
"a1a1b2b2-c3c3-4d4d-e5e5-f6f6a7a7b8b8",
"b8b8a7a7-f6f6-4e5e-5d4d-4c3c3b2b2a1a",
"12341234-5678-4567-89ab-89abcdefcdef",
"fedcfedc-ba98-4ba9-8765-87654321d321",
"0a1a2a3a-4a5a-4a6a-7a8a-9aaabacadaea",
"eadacaba-aa9a-48a7-a6a5-a4a3a2a1a0af",
"01020304-0506-4708-090a-0b0c0d0e0f10",
"100f0e0d-0c0b-4a09-0807-060504030201",
"aabbccdd-1122-4334-4556-6778899aabbc",
"cbba9988-7766-4554-4332-2110ddccbbaa",
"f0f1f2f3-f4f5-4f6f-7f8f-9fafbfcfdfef",
"efdfcfbf-af9f-48f7-f6f5-f4f3f2f1f0ee",
"a0a1a2a3-a4a5-4a6a-7a8a-9a0b1b2b3b4b",
"4b3b2b1b-0a9a-48a7-a6a5-a4a3a2a1a0ff"
]
}

View file

@ -0,0 +1,254 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<!---
RegisterBeaconHardware API
==========================
Registers a physical beacon device after provisioning.
Request:
POST /api/beacon-sharding/register_beacon_hardware.cfm
{
"HardwareId": "AA:BB:CC:DD:EE:FF",
"BusinessID": 123,
"ServicePointID": 456,
"UUID": "f7826da6-4fa2-4e98-8024-bc5b71e0893e",
"Major": 42,
"Minor": 7,
"TxPower": -59,
"AdvertisingInterval": 350,
"FirmwareVersion": "1.2.3"
}
Response:
{
"OK": true,
"BeaconHardwareID": 1,
"Status": "assigned"
}
Logic:
1. Validate all required fields
2. Verify business namespace matches UUID/Major
3. Verify service point Minor matches
4. Create or update BeaconHardware record
--->
<cftry>
<cfscript>
function apiAbort(obj) {
writeOutput(serializeJSON(obj));
abort;
}
function readJsonBody() {
raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {};
try {
parsed = deserializeJSON(raw);
} catch(any e) {
apiAbort({ OK=false, ERROR="bad_json", MESSAGE="Invalid JSON body" });
}
if (!isStruct(parsed)) return {};
return parsed;
}
function normStr(v) {
if (isNull(v)) return "";
return trim(toString(v));
}
data = readJsonBody();
// Required fields
hardwareId = normStr(structKeyExists(data, "HardwareId") ? data.HardwareId : "");
if (len(hardwareId) EQ 0) {
apiAbort({ OK=false, ERROR="missing_hardware_id", MESSAGE="HardwareId is required" });
}
bizId = 0;
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
bizId = int(data.BusinessID);
}
if (bizId LTE 0) {
apiAbort({ OK=false, ERROR="missing_business_id", MESSAGE="BusinessID is required" });
}
spId = 0;
if (structKeyExists(data, "ServicePointID") && isNumeric(data.ServicePointID)) {
spId = int(data.ServicePointID);
}
if (spId LTE 0) {
apiAbort({ OK=false, ERROR="missing_servicepoint_id", MESSAGE="ServicePointID is required" });
}
beaconUUID = normStr(structKeyExists(data, "UUID") ? data.UUID : "");
if (len(beaconUUID) EQ 0) {
apiAbort({ OK=false, ERROR="missing_uuid", MESSAGE="UUID is required" });
}
major = 0;
if (structKeyExists(data, "Major") && isNumeric(data.Major)) {
major = int(data.Major);
}
minor = 0;
if (structKeyExists(data, "Minor") && isNumeric(data.Minor)) {
minor = int(data.Minor);
}
// Optional fields
txPower = "";
if (structKeyExists(data, "TxPower") && isNumeric(data.TxPower)) {
txPower = int(data.TxPower);
}
advInterval = "";
if (structKeyExists(data, "AdvertisingInterval") && isNumeric(data.AdvertisingInterval)) {
advInterval = int(data.AdvertisingInterval);
}
firmwareVersion = normStr(structKeyExists(data, "FirmwareVersion") ? data.FirmwareVersion : "");
</cfscript>
<!--- Verify business exists and has matching namespace --->
<cfquery name="qBiz" datasource="payfrit">
SELECT b.ID, b.BeaconShardID, b.BeaconMajor, bs.UUID AS ShardUUID
FROM Businesses b
LEFT JOIN BeaconShards bs ON b.BeaconShardID = bs.ID
WHERE b.ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
LIMIT 1
</cfquery>
<cfif qBiz.recordCount EQ 0>
<cfset apiAbort({ OK=false, ERROR="invalid_business", MESSAGE="Business not found" })>
</cfif>
<!--- Verify UUID matches business's shard --->
<cfif compareNoCase(qBiz.ShardUUID, beaconUUID) NEQ 0>
<cfset apiAbort({
OK=false,
ERROR="uuid_mismatch",
MESSAGE="UUID does not match business's assigned shard",
ExpectedUUID=qBiz.ShardUUID,
ProvidedUUID=beaconUUID
})>
</cfif>
<!--- Verify Major matches --->
<cfif qBiz.BeaconMajor NEQ major>
<cfset apiAbort({
OK=false,
ERROR="major_mismatch",
MESSAGE="Major does not match business's assigned value",
ExpectedMajor=qBiz.BeaconMajor,
ProvidedMajor=major
})>
</cfif>
<!--- Verify service point exists and has matching minor --->
<cfquery name="qSP" datasource="payfrit">
SELECT ID, BusinessID, BeaconMinor, Name
FROM ServicePoints
WHERE ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#spId#">
LIMIT 1
</cfquery>
<cfif qSP.recordCount EQ 0>
<cfset apiAbort({ OK=false, ERROR="invalid_servicepoint", MESSAGE="Service point not found" })>
</cfif>
<cfif qSP.BusinessID NEQ bizId>
<cfset apiAbort({ OK=false, ERROR="servicepoint_business_mismatch", MESSAGE="Service point does not belong to this business" })>
</cfif>
<cfif qSP.BeaconMinor NEQ minor>
<cfset apiAbort({
OK=false,
ERROR="minor_mismatch",
MESSAGE="Minor does not match service point's assigned value",
ExpectedMinor=qSP.BeaconMinor,
ProvidedMinor=minor
})>
</cfif>
<!--- Check if hardware already exists --->
<cfquery name="qExisting" datasource="payfrit">
SELECT ID, Status FROM BeaconHardware
WHERE HardwareId = <cfqueryparam cfsqltype="cf_sql_varchar" value="#hardwareId#">
LIMIT 1
</cfquery>
<cfif qExisting.recordCount GT 0>
<!--- Update existing record --->
<cfquery datasource="payfrit">
UPDATE BeaconHardware
SET
BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">,
ServicePointID = <cfqueryparam cfsqltype="cf_sql_integer" value="#spId#">,
ShardUUID = <cfqueryparam cfsqltype="cf_sql_varchar" value="#beaconUUID#">,
Major = <cfqueryparam cfsqltype="cf_sql_smallint" value="#major#">,
Minor = <cfqueryparam cfsqltype="cf_sql_smallint" value="#minor#">,
Status = 'assigned',
<cfif len(txPower)>TxPower = <cfqueryparam cfsqltype="cf_sql_tinyint" value="#txPower#">,</cfif>
<cfif len(advInterval)>AdvertisingInterval = <cfqueryparam cfsqltype="cf_sql_smallint" value="#advInterval#">,</cfif>
<cfif len(firmwareVersion)>FirmwareVersion = <cfqueryparam cfsqltype="cf_sql_varchar" value="#firmwareVersion#">,</cfif>
UpdatedAt = NOW()
WHERE HardwareId = <cfqueryparam cfsqltype="cf_sql_varchar" value="#hardwareId#">
</cfquery>
<cfset hwId = qExisting.ID>
<cfelse>
<!--- Insert new record --->
<cfquery datasource="payfrit">
INSERT INTO BeaconHardware (
HardwareId,
BusinessID,
ServicePointID,
ShardUUID,
Major,
Minor,
Status
<cfif len(txPower)>,TxPower</cfif>
<cfif len(advInterval)>,AdvertisingInterval</cfif>
<cfif len(firmwareVersion)>,FirmwareVersion</cfif>
) VALUES (
<cfqueryparam cfsqltype="cf_sql_varchar" value="#hardwareId#">,
<cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">,
<cfqueryparam cfsqltype="cf_sql_integer" value="#spId#">,
<cfqueryparam cfsqltype="cf_sql_varchar" value="#beaconUUID#">,
<cfqueryparam cfsqltype="cf_sql_smallint" value="#major#">,
<cfqueryparam cfsqltype="cf_sql_smallint" value="#minor#">,
'assigned'
<cfif len(txPower)>,<cfqueryparam cfsqltype="cf_sql_tinyint" value="#txPower#"></cfif>
<cfif len(advInterval)>,<cfqueryparam cfsqltype="cf_sql_smallint" value="#advInterval#"></cfif>
<cfif len(firmwareVersion)>,<cfqueryparam cfsqltype="cf_sql_varchar" value="#firmwareVersion#"></cfif>
)
</cfquery>
<cfquery name="qId" datasource="payfrit">
SELECT LAST_INSERT_ID() AS ID
</cfquery>
<cfset hwId = qId.ID>
</cfif>
<cfoutput>#serializeJSON({
OK = true,
BeaconHardwareID = hwId,
HardwareId = hardwareId,
BusinessID = bizId,
ServicePointID = spId,
ServicePointName = qSP.Name,
UUID = beaconUUID,
Major = major,
Minor = minor,
Status = "assigned"
})#</cfoutput>
<cfcatch type="any">
<cfheader statuscode="200" statustext="OK">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfoutput>#serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })#</cfoutput>
</cfcatch>
</cftry>

View file

@ -0,0 +1,174 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<!---
ResolveBusiness API
===================
Resolves a beacon's (UUID, Major) to a Business.
Used by customer apps to identify which business a beacon belongs to.
Request:
POST /api/beacon-sharding/resolve_business.cfm
{ "UUID": "f7826da6-4fa2-4e98-8024-bc5b71e0893e", "Major": 42 }
Or for batch resolution:
{ "Beacons": [
{ "UUID": "...", "Major": 42 },
{ "UUID": "...", "Major": 43 }
]
}
Response (single):
{
"OK": true,
"BusinessID": 123,
"BusinessName": "Joe's Diner",
"BrandColor": "#FF5722",
"HeaderImageURL": "/uploads/businesses/123/header.jpg"
}
Response (batch):
{
"OK": true,
"Results": [
{ "UUID": "...", "Major": 42, "BusinessID": 123, "BusinessName": "..." },
{ "UUID": "...", "Major": 43, "BusinessID": null, "Error": "not_found" }
]
}
--->
<cftry>
<cfscript>
function apiAbort(obj) {
writeOutput(serializeJSON(obj));
abort;
}
function readJsonBody() {
raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {};
try {
parsed = deserializeJSON(raw);
} catch(any e) {
apiAbort({ OK=false, ERROR="bad_json", MESSAGE="Invalid JSON body" });
}
if (!isStruct(parsed)) return {};
return parsed;
}
function normStr(v) {
if (isNull(v)) return "";
return trim(toString(v));
}
function resolveSingleBusiness(uuid, major) {
var qBiz = queryExecute(
"SELECT b.ID, b.Name, b.BrandColor, b.HeaderImageExtension
FROM Businesses b
JOIN BeaconShards bs ON b.BeaconShardID = bs.ID
WHERE bs.UUID = ? AND b.BeaconMajor = ?
LIMIT 1",
[
{ value=uuid, cfsqltype="cf_sql_varchar" },
{ value=major, cfsqltype="cf_sql_smallint" }
],
{ datasource="payfrit" }
);
if (qBiz.recordCount EQ 0) {
return { Found=false, Error="not_found" };
}
var headerImageURL = "";
if (len(qBiz.HeaderImageExtension)) {
headerImageURL = "/uploads/businesses/#qBiz.ID#/header.#qBiz.HeaderImageExtension#";
}
return {
Found = true,
BusinessID = qBiz.ID,
BusinessName = qBiz.Name,
BrandColor = qBiz.BrandColor,
HeaderImageURL = headerImageURL
};
}
data = readJsonBody();
// Check for batch request
if (structKeyExists(data, "Beacons") && isArray(data.Beacons)) {
results = [];
for (beacon in data.Beacons) {
uuid = normStr(structKeyExists(beacon, "UUID") ? beacon.UUID : "");
major = structKeyExists(beacon, "Major") && isNumeric(beacon.Major) ? int(beacon.Major) : 0;
if (len(uuid) EQ 0 || major LTE 0) {
arrayAppend(results, { UUID=uuid, Major=major, BusinessID=javaCast("null",""), Error="invalid_params" });
continue;
}
resolved = resolveSingleBusiness(uuid, major);
if (resolved.Found) {
arrayAppend(results, {
UUID = uuid,
Major = major,
BusinessID = resolved.BusinessID,
BusinessName = resolved.BusinessName,
BrandColor = resolved.BrandColor,
HeaderImageURL = resolved.HeaderImageURL
});
} else {
arrayAppend(results, { UUID=uuid, Major=major, BusinessID=javaCast("null",""), Error="not_found" });
}
}
writeOutput(serializeJSON({ OK=true, COUNT=arrayLen(results), Results=results }));
abort;
}
// Single request
uuid = normStr(structKeyExists(data, "UUID") ? data.UUID : "");
major = 0;
if (structKeyExists(data, "Major") && isNumeric(data.Major)) {
major = int(data.Major);
}
// Also check URL params
if (len(uuid) EQ 0 && structKeyExists(url, "UUID")) {
uuid = normStr(url.UUID);
}
if (major LTE 0 && structKeyExists(url, "Major") && isNumeric(url.Major)) {
major = int(url.Major);
}
if (len(uuid) EQ 0) {
apiAbort({ OK=false, ERROR="missing_uuid", MESSAGE="UUID is required" });
}
if (major LTE 0) {
apiAbort({ OK=false, ERROR="missing_major", MESSAGE="Major is required" });
}
resolved = resolveSingleBusiness(uuid, major);
if (!resolved.Found) {
apiAbort({ OK=false, ERROR="not_found", MESSAGE="No business found for this beacon" });
}
</cfscript>
<cfoutput>#serializeJSON({
OK = true,
BusinessID = resolved.BusinessID,
BusinessName = resolved.BusinessName,
BrandColor = resolved.BrandColor,
HeaderImageURL = resolved.HeaderImageURL
})#</cfoutput>
<cfcatch type="any">
<cfheader statuscode="200" statustext="OK">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfoutput>#serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })#</cfoutput>
</cfcatch>
</cftry>

View file

@ -0,0 +1,225 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<!---
ResolveServicePoint API
=======================
Resolves a beacon's (UUID, Major, Minor) to a ServicePoint.
Used by customer apps to identify which table/location they're at.
Request:
POST /api/beacon-sharding/resolve_servicepoint.cfm
{ "UUID": "f7826da6-4fa2-4e98-8024-bc5b71e0893e", "Major": 42, "Minor": 7 }
Or for batch resolution:
{ "Beacons": [
{ "UUID": "...", "Major": 42, "Minor": 7 },
{ "UUID": "...", "Major": 42, "Minor": 8 }
]
}
Response (single):
{
"OK": true,
"ServicePointID": 456,
"ServicePointName": "Table 7",
"ServicePointCode": "T7",
"BusinessID": 123,
"BusinessName": "Joe's Diner"
}
Response (batch):
{
"OK": true,
"Results": [
{ "UUID": "...", "Major": 42, "Minor": 7, "ServicePointID": 456, ... },
{ "UUID": "...", "Major": 42, "Minor": 8, "ServicePointID": null, "Error": "not_found" }
]
}
--->
<cftry>
<cfscript>
function apiAbort(obj) {
writeOutput(serializeJSON(obj));
abort;
}
function readJsonBody() {
raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {};
try {
parsed = deserializeJSON(raw);
} catch(any e) {
apiAbort({ OK=false, ERROR="bad_json", MESSAGE="Invalid JSON body" });
}
if (!isStruct(parsed)) return {};
return parsed;
}
function normStr(v) {
if (isNull(v)) return "";
return trim(toString(v));
}
function resolveSingleServicePoint(uuid, major, minor) {
// First find the business by UUID + Major
var qBiz = queryExecute(
"SELECT b.ID AS BusinessID, b.Name AS BusinessName
FROM Businesses b
JOIN BeaconShards bs ON b.BeaconShardID = bs.ID
WHERE bs.UUID = ? AND b.BeaconMajor = ?
LIMIT 1",
[
{ value=uuid, cfsqltype="cf_sql_varchar" },
{ value=major, cfsqltype="cf_sql_smallint" }
],
{ datasource="payfrit" }
);
if (qBiz.recordCount EQ 0) {
return { Found=false, Error="business_not_found" };
}
// Then find the service point by BusinessID + Minor
var qSP = queryExecute(
"SELECT ID, Name, Code, TypeID, Description
FROM ServicePoints
WHERE BusinessID = ? AND BeaconMinor = ? AND IsActive = 1
LIMIT 1",
[
{ value=qBiz.BusinessID, cfsqltype="cf_sql_integer" },
{ value=minor, cfsqltype="cf_sql_smallint" }
],
{ datasource="payfrit" }
);
if (qSP.recordCount EQ 0) {
return {
Found = false,
Error = "servicepoint_not_found",
BusinessID = qBiz.BusinessID,
BusinessName = qBiz.BusinessName
};
}
return {
Found = true,
ServicePointID = qSP.ID,
ServicePointName = qSP.Name,
ServicePointCode = qSP.Code,
ServicePointTypeID = qSP.TypeID,
ServicePointDescription = qSP.Description,
BusinessID = qBiz.BusinessID,
BusinessName = qBiz.BusinessName
};
}
data = readJsonBody();
// Check for batch request
if (structKeyExists(data, "Beacons") && isArray(data.Beacons)) {
results = [];
for (beacon in data.Beacons) {
uuid = normStr(structKeyExists(beacon, "UUID") ? beacon.UUID : "");
major = structKeyExists(beacon, "Major") && isNumeric(beacon.Major) ? int(beacon.Major) : 0;
minor = structKeyExists(beacon, "Minor") && isNumeric(beacon.Minor) ? int(beacon.Minor) : -1;
if (len(uuid) EQ 0 || major LTE 0 || minor LT 0) {
arrayAppend(results, { UUID=uuid, Major=major, Minor=minor, ServicePointID=javaCast("null",""), Error="invalid_params" });
continue;
}
resolved = resolveSingleServicePoint(uuid, major, minor);
if (resolved.Found) {
arrayAppend(results, {
UUID = uuid,
Major = major,
Minor = minor,
ServicePointID = resolved.ServicePointID,
ServicePointName = resolved.ServicePointName,
ServicePointCode = resolved.ServicePointCode,
BusinessID = resolved.BusinessID,
BusinessName = resolved.BusinessName
});
} else {
var errResult = { UUID=uuid, Major=major, Minor=minor, ServicePointID=javaCast("null",""), Error=resolved.Error };
if (structKeyExists(resolved, "BusinessID")) {
errResult.BusinessID = resolved.BusinessID;
errResult.BusinessName = resolved.BusinessName;
}
arrayAppend(results, errResult);
}
}
writeOutput(serializeJSON({ OK=true, COUNT=arrayLen(results), Results=results }));
abort;
}
// Single request
uuid = normStr(structKeyExists(data, "UUID") ? data.UUID : "");
major = 0;
minor = -1;
if (structKeyExists(data, "Major") && isNumeric(data.Major)) {
major = int(data.Major);
}
if (structKeyExists(data, "Minor") && isNumeric(data.Minor)) {
minor = int(data.Minor);
}
// Also check URL params
if (len(uuid) EQ 0 && structKeyExists(url, "UUID")) {
uuid = normStr(url.UUID);
}
if (major LTE 0 && structKeyExists(url, "Major") && isNumeric(url.Major)) {
major = int(url.Major);
}
if (minor LT 0 && structKeyExists(url, "Minor") && isNumeric(url.Minor)) {
minor = int(url.Minor);
}
if (len(uuid) EQ 0) {
apiAbort({ OK=false, ERROR="missing_uuid", MESSAGE="UUID is required" });
}
if (major LTE 0) {
apiAbort({ OK=false, ERROR="missing_major", MESSAGE="Major is required" });
}
if (minor LT 0) {
apiAbort({ OK=false, ERROR="missing_minor", MESSAGE="Minor is required" });
}
resolved = resolveSingleServicePoint(uuid, major, minor);
if (!resolved.Found) {
errResponse = { OK=false, ERROR=resolved.Error };
if (structKeyExists(resolved, "BusinessID")) {
errResponse.BusinessID = resolved.BusinessID;
errResponse.BusinessName = resolved.BusinessName;
errResponse.MESSAGE = "Service point not found for this business";
} else {
errResponse.MESSAGE = "No business found for this beacon";
}
apiAbort(errResponse);
}
</cfscript>
<cfoutput>#serializeJSON({
OK = true,
ServicePointID = resolved.ServicePointID,
ServicePointName = resolved.ServicePointName,
ServicePointCode = resolved.ServicePointCode,
ServicePointTypeID = resolved.ServicePointTypeID,
ServicePointDescription = resolved.ServicePointDescription,
BusinessID = resolved.BusinessID,
BusinessName = resolved.BusinessName
})#</cfoutput>
<cfcatch type="any">
<cfheader statuscode="200" statustext="OK">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfoutput>#serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })#</cfoutput>
</cfcatch>
</cftry>

View file

@ -0,0 +1,171 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<!---
VerifyBeaconBroadcast API
=========================
Records a scan confirmation that a beacon is broadcasting the expected values.
Called by provisioning app after writing config and seeing the beacon advertise.
Request:
POST /api/beacon-sharding/verify_beacon_broadcast.cfm
{
"HardwareId": "AA:BB:CC:DD:EE:FF",
"UUID": "f7826da6-4fa2-4e98-8024-bc5b71e0893e",
"Major": 42,
"Minor": 7,
"RSSI": -65,
"SeenAt": "2024-01-15T10:30:00Z"
}
Response:
{
"OK": true,
"BeaconHardwareID": 1,
"Status": "verified",
"VerifiedAt": "2024-01-15T10:30:00Z"
}
Logic:
1. Find beacon hardware by HardwareId
2. Verify UUID/Major/Minor match expected values
3. Update status to "verified" and record timestamp/RSSI
--->
<cftry>
<cfscript>
function apiAbort(obj) {
writeOutput(serializeJSON(obj));
abort;
}
function readJsonBody() {
raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {};
try {
parsed = deserializeJSON(raw);
} catch(any e) {
apiAbort({ OK=false, ERROR="bad_json", MESSAGE="Invalid JSON body" });
}
if (!isStruct(parsed)) return {};
return parsed;
}
function normStr(v) {
if (isNull(v)) return "";
return trim(toString(v));
}
data = readJsonBody();
// Required fields
hardwareId = normStr(structKeyExists(data, "HardwareId") ? data.HardwareId : "");
if (len(hardwareId) EQ 0) {
apiAbort({ OK=false, ERROR="missing_hardware_id", MESSAGE="HardwareId is required" });
}
beaconUUID = normStr(structKeyExists(data, "UUID") ? data.UUID : "");
if (len(beaconUUID) EQ 0) {
apiAbort({ OK=false, ERROR="missing_uuid", MESSAGE="UUID is required" });
}
major = 0;
if (structKeyExists(data, "Major") && isNumeric(data.Major)) {
major = int(data.Major);
}
minor = 0;
if (structKeyExists(data, "Minor") && isNumeric(data.Minor)) {
minor = int(data.Minor);
}
// Optional fields
rssi = "";
if (structKeyExists(data, "RSSI") && isNumeric(data.RSSI)) {
rssi = int(data.RSSI);
}
seenAt = now();
if (structKeyExists(data, "SeenAt") && len(data.SeenAt)) {
try {
seenAt = parseDateTime(data.SeenAt);
} catch(any e) {
// Use current time if parse fails
}
}
</cfscript>
<!--- Find beacon hardware --->
<cfquery name="qHW" datasource="payfrit">
SELECT ID, BusinessID, ServicePointID, ShardUUID, Major, Minor, Status
FROM BeaconHardware
WHERE HardwareId = <cfqueryparam cfsqltype="cf_sql_varchar" value="#hardwareId#">
LIMIT 1
</cfquery>
<cfif qHW.recordCount EQ 0>
<cfset apiAbort({ OK=false, ERROR="hardware_not_found", MESSAGE="Beacon hardware not registered" })>
</cfif>
<!--- Verify broadcast matches expected values --->
<cfif compareNoCase(qHW.ShardUUID, beaconUUID) NEQ 0>
<cfset apiAbort({
OK=false,
ERROR="uuid_mismatch",
MESSAGE="Beacon is broadcasting wrong UUID",
ExpectedUUID=qHW.ShardUUID,
BroadcastUUID=beaconUUID
})>
</cfif>
<cfif qHW.Major NEQ major>
<cfset apiAbort({
OK=false,
ERROR="major_mismatch",
MESSAGE="Beacon is broadcasting wrong Major",
ExpectedMajor=qHW.Major,
BroadcastMajor=major
})>
</cfif>
<cfif qHW.Minor NEQ minor>
<cfset apiAbort({
OK=false,
ERROR="minor_mismatch",
MESSAGE="Beacon is broadcasting wrong Minor",
ExpectedMinor=qHW.Minor,
BroadcastMinor=minor
})>
</cfif>
<!--- Update to verified status --->
<cfquery datasource="payfrit">
UPDATE BeaconHardware
SET
Status = 'verified',
VerifiedAt = <cfqueryparam cfsqltype="cf_sql_timestamp" value="#seenAt#">,
LastSeenAt = <cfqueryparam cfsqltype="cf_sql_timestamp" value="#seenAt#">,
<cfif len(rssi)>LastRssi = <cfqueryparam cfsqltype="cf_sql_smallint" value="#rssi#">,</cfif>
UpdatedAt = NOW()
WHERE HardwareId = <cfqueryparam cfsqltype="cf_sql_varchar" value="#hardwareId#">
</cfquery>
<cfoutput>#serializeJSON({
OK = true,
BeaconHardwareID = qHW.ID,
HardwareId = hardwareId,
BusinessID = qHW.BusinessID,
ServicePointID = qHW.ServicePointID,
Status = "verified",
VerifiedAt = dateTimeFormat(seenAt, "yyyy-mm-dd'T'HH:nn:ss'Z'")
})#</cfoutput>
<cfcatch type="any">
<cfheader statuscode="200" statustext="OK">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfoutput>#serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })#</cfoutput>
</cfcatch>
</cftry>

View file

@ -41,7 +41,7 @@ try {
} }
// Get service point info (table name) // Get service point info (table name)
spQuery = queryTimed(" spQuery = queryExecute("
SELECT Name FROM ServicePoints WHERE ID = :spID SELECT Name FROM ServicePoints WHERE ID = :spID
", { spID: { value: servicePointID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); ", { spID: { value: servicePointID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
@ -50,7 +50,7 @@ try {
// Get user name if available // Get user name if available
userName = ""; userName = "";
if (userID > 0) { if (userID > 0) {
userQuery = queryTimed(" userQuery = queryExecute("
SELECT FirstName FROM Users WHERE ID = :userID SELECT FirstName FROM Users WHERE ID = :userID
", { userID: { value: userID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); ", { userID: { value: userID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
if (userQuery.recordCount && len(trim(userQuery.FirstName))) { if (userQuery.recordCount && len(trim(userQuery.FirstName))) {
@ -62,7 +62,7 @@ try {
taskTypeName = ""; taskTypeName = "";
taskTypeCategoryID = 0; taskTypeCategoryID = 0;
if (taskTypeID > 0) { if (taskTypeID > 0) {
typeQuery = queryTimed(" typeQuery = queryExecute("
SELECT Name, TaskCategoryID FROM tt_TaskTypes WHERE tt_TaskTypeID = :typeID SELECT Name, TaskCategoryID FROM tt_TaskTypes WHERE tt_TaskTypeID = :typeID
", { typeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); ", { typeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
if (typeQuery.recordCount) { if (typeQuery.recordCount) {
@ -104,7 +104,7 @@ try {
categoryID = taskTypeCategoryID; categoryID = taskTypeCategoryID;
} else { } else {
// Fallback: look up or create a "Service" category for this business // Fallback: look up or create a "Service" category for this business
catQuery = queryTimed(" catQuery = queryExecute("
SELECT ID FROM TaskCategories SELECT ID FROM TaskCategories
WHERE BusinessID = :businessID AND Name = 'Service' WHERE BusinessID = :businessID AND Name = 'Service'
LIMIT 1 LIMIT 1
@ -112,12 +112,12 @@ try {
if (catQuery.recordCount == 0) { if (catQuery.recordCount == 0) {
// Create the category // Create the category
queryTimed(" queryExecute("
INSERT INTO TaskCategories (BusinessID, Name, Color) INSERT INTO TaskCategories (BusinessID, Name, Color)
VALUES (:businessID, 'Service', '##FF9800') VALUES (:businessID, 'Service', '##FF9800')
", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); ", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
catResult = queryTimed("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); catResult = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
categoryID = catResult.newID; categoryID = catResult.newID;
} else { } else {
categoryID = catQuery.ID; categoryID = catQuery.ID;
@ -125,9 +125,10 @@ try {
} }
// Insert task // Insert task
queryTimed(" queryExecute("
INSERT INTO Tasks ( INSERT INTO Tasks (
BusinessID, BusinessID,
UserID,
CategoryID, CategoryID,
OrderID, OrderID,
TaskTypeID, TaskTypeID,
@ -137,6 +138,7 @@ try {
CreatedOn CreatedOn
) VALUES ( ) VALUES (
:businessID, :businessID,
:userID,
:categoryID, :categoryID,
:orderID, :orderID,
:taskTypeID, :taskTypeID,
@ -147,6 +149,7 @@ try {
) )
", { ", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
userID: { value: userID > 0 ? userID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: userID == 0 },
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer" }, categoryID: { value: categoryID, cfsqltype: "cf_sql_integer" },
orderID: { value: orderID > 0 ? orderID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: orderID == 0 }, orderID: { value: orderID > 0 ? orderID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: orderID == 0 },
taskTypeID: { value: taskTypeID > 0 ? taskTypeID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: taskTypeID == 0 }, taskTypeID: { value: taskTypeID > 0 ? taskTypeID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: taskTypeID == 0 },
@ -155,7 +158,7 @@ try {
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
// Get the new task ID // Get the new task ID
result = queryTimed("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); result = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
taskID = result.newID; taskID = result.newID;
apiAbort({ apiAbort({

View file

@ -35,7 +35,7 @@
<cftry> <cftry>
<!--- Get the task and linked order details ---> <!--- Get the task and linked order details --->
<cfset qTask = queryTimed(" <cfset qTask = queryExecute("
SELECT SELECT
t.ID AS TaskID, t.ID AS TaskID,
t.BusinessID, t.BusinessID,
@ -44,33 +44,32 @@
t.TaskTypeID, t.TaskTypeID,
t.CreatedOn, t.CreatedOn,
t.ClaimedByUserID, t.ClaimedByUserID,
t.ServicePointID AS TaskServicePointID,
tc.Name AS CategoryName, tc.Name AS CategoryName,
tc.Color AS CategoryColor, tc.Color AS CategoryColor,
tt.Name AS TaskTypeName,
o.ID AS OID, o.ID AS OID,
o.UUID AS OrderUUID, o.UUID AS OrderUUID,
o.UserID, o.UserID AS OrderUserID,
o.OrderTypeID, o.OrderTypeID,
o.StatusID AS OrderStatusID, o.StatusID AS OrderStatusID,
o.ServicePointID, o.ServicePointID AS OrderServicePointID,
o.Remarks, o.Remarks,
o.SubmittedOn, o.SubmittedOn,
o.TipAmount, COALESCE(sp.Name, tsp.Name) AS ServicePointName,
o.DeliveryFee, COALESCE(sp.TypeID, tsp.TypeID) AS ServicePointTypeID,
sp.Name AS ServicePointName, COALESCE(sp.ID, tsp.ID) AS ServicePointID,
sp.TypeID AS ServicePointTypeID, COALESCE(u.ID, tu.ID) AS CustomerUserID,
b.TaxRate, COALESCE(u.FirstName, tu.FirstName) AS FirstName,
u.ID AS CustomerUserID, COALESCE(u.LastName, tu.LastName) AS LastName,
u.FirstName, COALESCE(u.ContactNumber, tu.ContactNumber) AS ContactNumber,
u.LastName, COALESCE(u.ImageExtension, tu.ImageExtension) AS CustomerImageExtension
u.ContactNumber
FROM Tasks t FROM Tasks t
LEFT JOIN TaskCategories tc ON tc.ID = t.CategoryID LEFT JOIN TaskCategories tc ON tc.ID = t.CategoryID
LEFT JOIN tt_TaskTypes tt ON tt.ID = t.TaskTypeID
LEFT JOIN Orders o ON o.ID = t.OrderID LEFT JOIN Orders o ON o.ID = t.OrderID
LEFT JOIN Businesses b ON b.ID = t.BusinessID
LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID
LEFT JOIN ServicePoints tsp ON tsp.ID = t.ServicePointID
LEFT JOIN Users u ON u.ID = o.UserID LEFT JOIN Users u ON u.ID = o.UserID
LEFT JOIN Users tu ON tu.ID = t.UserID
WHERE t.ID = ? WHERE t.ID = ?
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })> ", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
@ -92,11 +91,11 @@
<cfset pngPath = uploadDir & qTask.CustomerUserID & ".png"> <cfset pngPath = uploadDir & qTask.CustomerUserID & ".png">
<cfset pngPathUpper = uploadDir & qTask.CustomerUserID & ".PNG"> <cfset pngPathUpper = uploadDir & qTask.CustomerUserID & ".PNG">
<cfif fileExists(jpgPath)> <cfif fileExists(jpgPath)>
<cfset customerPhotoUrl = application.baseUrl & "/uploads/users/" & qTask.CustomerUserID & ".jpg"> <cfset customerPhotoUrl = "https://biz.payfrit.com/uploads/users/" & qTask.CustomerUserID & ".jpg">
<cfelseif fileExists(pngPath)> <cfelseif fileExists(pngPath)>
<cfset customerPhotoUrl = application.baseUrl & "/uploads/users/" & qTask.CustomerUserID & ".png"> <cfset customerPhotoUrl = "https://biz.payfrit.com/uploads/users/" & qTask.CustomerUserID & ".png">
<cfelseif fileExists(pngPathUpper)> <cfelseif fileExists(pngPathUpper)>
<cfset customerPhotoUrl = application.baseUrl & "/uploads/users/" & qTask.CustomerUserID & ".PNG"> <cfset customerPhotoUrl = "https://biz.payfrit.com/uploads/users/" & qTask.CustomerUserID & ".PNG">
</cfif> </cfif>
</cfif> </cfif>
@ -105,7 +104,6 @@
"TaskBusinessID": qTask.BusinessID, "TaskBusinessID": qTask.BusinessID,
"TaskCategoryID": qTask.CategoryID, "TaskCategoryID": qTask.CategoryID,
"TaskTypeID": qTask.TaskTypeID ?: 1, "TaskTypeID": qTask.TaskTypeID ?: 1,
"TaskTypeName": qTask.TaskTypeName ?: "",
"TaskTitle": taskTitle, "TaskTitle": taskTitle,
"TaskCreatedOn": dateFormat(qTask.CreatedOn, "yyyy-mm-dd") & "T" & timeFormat(qTask.CreatedOn, "HH:mm:ss"), "TaskCreatedOn": dateFormat(qTask.CreatedOn, "yyyy-mm-dd") & "T" & timeFormat(qTask.CreatedOn, "HH:mm:ss"),
"TaskStatusID": qTask.ClaimedByUserID GT 0 ? 1 : 0, "TaskStatusID": qTask.ClaimedByUserID GT 0 ? 1 : 0,
@ -132,7 +130,7 @@
<!--- Get beacon UUID for the service point (for auto-completion on Works app) ---> <!--- Get beacon UUID for the service point (for auto-completion on Works app) --->
<cfif val(qTask.ServicePointID) GT 0> <cfif val(qTask.ServicePointID) GT 0>
<cfset qBeacon = queryTimed(" <cfset qBeacon = queryExecute("
SELECT b.UUID SELECT b.UUID
FROM ServicePoints sp_link FROM ServicePoints sp_link
INNER JOIN Beacons b ON b.ID = sp_link.BeaconID INNER JOIN Beacons b ON b.ID = sp_link.BeaconID
@ -147,7 +145,7 @@
<!--- Get order line items if there's an order ---> <!--- Get order line items if there's an order --->
<cfif qTask.OrderID GT 0> <cfif qTask.OrderID GT 0>
<cfset qLineItems = queryTimed(" <cfset qLineItems = queryExecute("
SELECT SELECT
oli.ID AS OrderLineItemID, oli.ID AS OrderLineItemID,
oli.ParentOrderLineItemID, oli.ParentOrderLineItemID,
@ -167,9 +165,7 @@
ORDER BY oli.ID ORDER BY oli.ID
", [ { value = qTask.OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })> ", [ { value = qTask.OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfset subtotal = 0>
<cfloop query="qLineItems"> <cfloop query="qLineItems">
<cfset subtotal += qLineItems.LineItemPrice * qLineItems.Quantity>
<cfset arrayAppend(result.LineItems, { <cfset arrayAppend(result.LineItems, {
"LineItemID": qLineItems.OrderLineItemID, "LineItemID": qLineItems.OrderLineItemID,
"ParentLineItemID": qLineItems.ParentOrderLineItemID, "ParentLineItemID": qLineItems.ParentOrderLineItemID,
@ -181,14 +177,6 @@
"IsModifier": qLineItems.ParentOrderLineItemID GT 0 "IsModifier": qLineItems.ParentOrderLineItemID GT 0
})> })>
</cfloop> </cfloop>
<!--- Calculate order total --->
<cfset taxRate = val(qTask.TaxRate)>
<cfset tax = subtotal * taxRate>
<cfset tip = val(qTask.TipAmount)>
<cfset deliveryFee = (val(qTask.OrderTypeID) EQ 3) ? val(qTask.DeliveryFee) : 0>
<cfset platformFee = subtotal * 0.05>
<cfset result["OrderTotal"] = numberFormat(subtotal + tax + tip + deliveryFee + platformFee, "0.00")>
</cfif> </cfif>
<cfset apiAbort({ <cfset apiAbort({