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:
parent
31a89018f5
commit
3089f84873
12 changed files with 2113 additions and 39 deletions
621
api/beacon-sharding/IMPLEMENTATION.md
Normal file
621
api/beacon-sharding/IMPLEMENTATION.md
Normal 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.
|
||||||
164
api/beacon-sharding/allocate_business_namespace.cfm
Normal file
164
api/beacon-sharding/allocate_business_namespace.cfm
Normal 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>
|
||||||
150
api/beacon-sharding/allocate_servicepoint_minor.cfm
Normal file
150
api/beacon-sharding/allocate_servicepoint_minor.cfm
Normal 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>
|
||||||
77
api/beacon-sharding/get_shard_pool.cfm
Normal file
77
api/beacon-sharding/get_shard_pool.cfm
Normal 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>
|
||||||
175
api/beacon-sharding/migration.sql
Normal file
175
api/beacon-sharding/migration.sql
Normal 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
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
72
api/beacon-sharding/payfrit_beacon_shards.json
Normal file
72
api/beacon-sharding/payfrit_beacon_shards.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
254
api/beacon-sharding/register_beacon_hardware.cfm
Normal file
254
api/beacon-sharding/register_beacon_hardware.cfm
Normal 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>
|
||||||
174
api/beacon-sharding/resolve_business.cfm
Normal file
174
api/beacon-sharding/resolve_business.cfm
Normal 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>
|
||||||
225
api/beacon-sharding/resolve_servicepoint.cfm
Normal file
225
api/beacon-sharding/resolve_servicepoint.cfm
Normal 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>
|
||||||
171
api/beacon-sharding/verify_beacon_broadcast.cfm
Normal file
171
api/beacon-sharding/verify_beacon_broadcast.cfm
Normal 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>
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Reference in a new issue