diff --git a/api/beacon-sharding/IMPLEMENTATION.md b/api/beacon-sharding/IMPLEMENTATION.md new file mode 100644 index 0000000..457340c --- /dev/null +++ b/api/beacon-sharding/IMPLEMENTATION.md @@ -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 = loadBundledShards() + private var remoteShards: List = emptyList() + private var lastRemoteVersion: Int = 0 + + val allShards: List + get() = (bundledShards + remoteShards).distinct() + + private fun loadBundledShards(): List { + // 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) -> 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() + + // 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, 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): 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, 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. diff --git a/api/beacon-sharding/allocate_business_namespace.cfm b/api/beacon-sharding/allocate_business_namespace.cfm new file mode 100644 index 0000000..86d6bdb --- /dev/null +++ b/api/beacon-sharding/allocate_business_namespace.cfm @@ -0,0 +1,164 @@ + + + + + + + + + + +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" }); +} + + + + + SELECT ID, BeaconShardID, BeaconMajor + FROM Businesses + WHERE ID = + LIMIT 1 + + + + + + + + + + SELECT UUID FROM BeaconShards WHERE ID = + + #serializeJSON({ + OK = true, + BusinessID = bizId, + BeaconShardUUID = qShard.UUID, + BeaconMajor = qBiz.BeaconMajor, + ShardID = qBiz.BeaconShardID, + AlreadyAllocated = true + })# + + + + + + SELECT ID, UUID, BusinessCount + FROM BeaconShards + WHERE IsActive = 1 + AND BusinessCount < MaxBusinesses + ORDER BY BusinessCount ASC + LIMIT 1 + FOR UPDATE + + + + + + + + + + + + + SELECT COALESCE(MAX(BeaconMajor), 0) AS MaxMajor + FROM Businesses + WHERE BeaconShardID = + + + + + + + + + + + + UPDATE Businesses + SET BeaconShardID = , + BeaconMajor = + WHERE ID = + AND (BeaconShardID IS NULL OR BeaconMajor IS NULL) + + + + + UPDATE BeaconShards + SET BusinessCount = BusinessCount + 1 + WHERE ID = + + +#serializeJSON({ + OK = true, + BusinessID = bizId, + BeaconShardUUID = shardUUID, + BeaconMajor = nextMajor, + ShardID = shardId, + AlreadyAllocated = false +})# + + + + + #serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })# + + diff --git a/api/beacon-sharding/allocate_servicepoint_minor.cfm b/api/beacon-sharding/allocate_servicepoint_minor.cfm new file mode 100644 index 0000000..a3820ad --- /dev/null +++ b/api/beacon-sharding/allocate_servicepoint_minor.cfm @@ -0,0 +1,150 @@ + + + + + + + + + + +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" }); +} + + + + + SELECT ID, BusinessID, Name, BeaconMinor + FROM ServicePoints + WHERE ID = + LIMIT 1 + + + + + + + + + + + + + #serializeJSON({ + OK = true, + ServicePointID = spId, + BusinessID = bizId, + BeaconMinor = qSP.BeaconMinor, + ServicePointName = qSP.Name, + AlreadyAllocated = true + })# + + + + + + + SELECT COALESCE(MAX(BeaconMinor), 0) AS MaxMinor + FROM ServicePoints + WHERE BusinessID = + + + + + + + + + + + + UPDATE ServicePoints + SET BeaconMinor = + WHERE ID = + AND BeaconMinor IS NULL + + +#serializeJSON({ + OK = true, + ServicePointID = spId, + BusinessID = bizId, + BeaconMinor = nextMinor, + ServicePointName = qSP.Name, + AlreadyAllocated = false +})# + + + + + #serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })# + + diff --git a/api/beacon-sharding/get_shard_pool.cfm b/api/beacon-sharding/get_shard_pool.cfm new file mode 100644 index 0000000..15600eb --- /dev/null +++ b/api/beacon-sharding/get_shard_pool.cfm @@ -0,0 +1,77 @@ + + + + + + + + + + +sinceId = 0; +if (structKeyExists(url, "since") && isNumeric(url.since) && url.since GT 0) { + sinceId = int(url.since); +} + + + + SELECT ID, UUID + FROM BeaconShards + WHERE IsActive = 1 + + AND ID > + + ORDER BY ID ASC + + + + + + + + + + + +#serializeJSON({ + OK = true, + Version = maxId, + Count = arrayLen(shards), + Shards = shards +})# + + + + + #serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message })# + + diff --git a/api/beacon-sharding/migration.sql b/api/beacon-sharding/migration.sql new file mode 100644 index 0000000..c831b35 --- /dev/null +++ b/api/beacon-sharding/migration.sql @@ -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 +-- ----------------------------------------------------------------------------- diff --git a/api/beacon-sharding/payfrit_beacon_shards.json b/api/beacon-sharding/payfrit_beacon_shards.json new file mode 100644 index 0000000..f7eb46f --- /dev/null +++ b/api/beacon-sharding/payfrit_beacon_shards.json @@ -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" + ] +} diff --git a/api/beacon-sharding/register_beacon_hardware.cfm b/api/beacon-sharding/register_beacon_hardware.cfm new file mode 100644 index 0000000..7ac0f5f --- /dev/null +++ b/api/beacon-sharding/register_beacon_hardware.cfm @@ -0,0 +1,254 @@ + + + + + + + + + + +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 : ""); + + + + + 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 = + LIMIT 1 + + + + + + + + + + + + + + + + + + + SELECT ID, BusinessID, BeaconMinor, Name + FROM ServicePoints + WHERE ID = + LIMIT 1 + + + + + + + + + + + + + + + + + SELECT ID, Status FROM BeaconHardware + WHERE HardwareId = + LIMIT 1 + + + + + + UPDATE BeaconHardware + SET + BusinessID = , + ServicePointID = , + ShardUUID = , + Major = , + Minor = , + Status = 'assigned', + TxPower = , + AdvertisingInterval = , + FirmwareVersion = , + UpdatedAt = NOW() + WHERE HardwareId = + + + + + + INSERT INTO BeaconHardware ( + HardwareId, + BusinessID, + ServicePointID, + ShardUUID, + Major, + Minor, + Status + ,TxPower + ,AdvertisingInterval + ,FirmwareVersion + ) VALUES ( + , + , + , + , + , + , + 'assigned' + , + , + , + ) + + + SELECT LAST_INSERT_ID() AS ID + + + + +#serializeJSON({ + OK = true, + BeaconHardwareID = hwId, + HardwareId = hardwareId, + BusinessID = bizId, + ServicePointID = spId, + ServicePointName = qSP.Name, + UUID = beaconUUID, + Major = major, + Minor = minor, + Status = "assigned" +})# + + + + + #serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })# + + diff --git a/api/beacon-sharding/resolve_business.cfm b/api/beacon-sharding/resolve_business.cfm new file mode 100644 index 0000000..0dd474d --- /dev/null +++ b/api/beacon-sharding/resolve_business.cfm @@ -0,0 +1,174 @@ + + + + + + + + + + +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" }); +} + + +#serializeJSON({ + OK = true, + BusinessID = resolved.BusinessID, + BusinessName = resolved.BusinessName, + BrandColor = resolved.BrandColor, + HeaderImageURL = resolved.HeaderImageURL +})# + + + + + #serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })# + + diff --git a/api/beacon-sharding/resolve_servicepoint.cfm b/api/beacon-sharding/resolve_servicepoint.cfm new file mode 100644 index 0000000..f8fffd3 --- /dev/null +++ b/api/beacon-sharding/resolve_servicepoint.cfm @@ -0,0 +1,225 @@ + + + + + + + + + + +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); +} + + +#serializeJSON({ + OK = true, + ServicePointID = resolved.ServicePointID, + ServicePointName = resolved.ServicePointName, + ServicePointCode = resolved.ServicePointCode, + ServicePointTypeID = resolved.ServicePointTypeID, + ServicePointDescription = resolved.ServicePointDescription, + BusinessID = resolved.BusinessID, + BusinessName = resolved.BusinessName +})# + + + + + #serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })# + + diff --git a/api/beacon-sharding/verify_beacon_broadcast.cfm b/api/beacon-sharding/verify_beacon_broadcast.cfm new file mode 100644 index 0000000..b9f0d2e --- /dev/null +++ b/api/beacon-sharding/verify_beacon_broadcast.cfm @@ -0,0 +1,171 @@ + + + + + + + + + + +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 + } +} + + + + + SELECT ID, BusinessID, ServicePointID, ShardUUID, Major, Minor, Status + FROM BeaconHardware + WHERE HardwareId = + LIMIT 1 + + + + + + + + + + + + + + + + + + + + + + UPDATE BeaconHardware + SET + Status = 'verified', + VerifiedAt = , + LastSeenAt = , + LastRssi = , + UpdatedAt = NOW() + WHERE HardwareId = + + +#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'") +})# + + + + + #serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })# + + diff --git a/api/tasks/callServer.cfm b/api/tasks/callServer.cfm index 17e6a31..ed4bbdc 100644 --- a/api/tasks/callServer.cfm +++ b/api/tasks/callServer.cfm @@ -41,7 +41,7 @@ try { } // Get service point info (table name) - spQuery = queryTimed(" + spQuery = queryExecute(" SELECT Name FROM ServicePoints WHERE ID = :spID ", { spID: { value: servicePointID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); @@ -50,7 +50,7 @@ try { // Get user name if available userName = ""; if (userID > 0) { - userQuery = queryTimed(" + userQuery = queryExecute(" SELECT FirstName FROM Users WHERE ID = :userID ", { userID: { value: userID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); if (userQuery.recordCount && len(trim(userQuery.FirstName))) { @@ -62,7 +62,7 @@ try { taskTypeName = ""; taskTypeCategoryID = 0; if (taskTypeID > 0) { - typeQuery = queryTimed(" + typeQuery = queryExecute(" SELECT Name, TaskCategoryID FROM tt_TaskTypes WHERE tt_TaskTypeID = :typeID ", { typeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); if (typeQuery.recordCount) { @@ -104,7 +104,7 @@ try { categoryID = taskTypeCategoryID; } else { // Fallback: look up or create a "Service" category for this business - catQuery = queryTimed(" + catQuery = queryExecute(" SELECT ID FROM TaskCategories WHERE BusinessID = :businessID AND Name = 'Service' LIMIT 1 @@ -112,12 +112,12 @@ try { if (catQuery.recordCount == 0) { // Create the category - queryTimed(" + queryExecute(" INSERT INTO TaskCategories (BusinessID, Name, Color) VALUES (:businessID, 'Service', '##FF9800') ", { 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; } else { categoryID = catQuery.ID; @@ -125,9 +125,10 @@ try { } // Insert task - queryTimed(" + queryExecute(" INSERT INTO Tasks ( BusinessID, + UserID, CategoryID, OrderID, TaskTypeID, @@ -137,6 +138,7 @@ try { CreatedOn ) VALUES ( :businessID, + :userID, :categoryID, :orderID, :taskTypeID, @@ -147,6 +149,7 @@ try { ) ", { 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" }, 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 }, @@ -155,7 +158,7 @@ try { }, { datasource: "payfrit" }); // 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; apiAbort({ diff --git a/api/tasks/getDetails.cfm b/api/tasks/getDetails.cfm index 25ffad7..4355e26 100644 --- a/api/tasks/getDetails.cfm +++ b/api/tasks/getDetails.cfm @@ -35,7 +35,7 @@ - @@ -92,11 +91,11 @@ - + - + - + @@ -105,7 +104,6 @@ "TaskBusinessID": qTask.BusinessID, "TaskCategoryID": qTask.CategoryID, "TaskTypeID": qTask.TaskTypeID ?: 1, - "TaskTypeName": qTask.TaskTypeName ?: "", "TaskTitle": taskTitle, "TaskCreatedOn": dateFormat(qTask.CreatedOn, "yyyy-mm-dd") & "T" & timeFormat(qTask.CreatedOn, "HH:mm:ss"), "TaskStatusID": qTask.ClaimedByUserID GT 0 ? 1 : 0, @@ -132,7 +130,7 @@ - - - - - - - - - - - -