payfrit-beacon-ios/PayfritBeacon/Services/APIClient.swift
Schwifty 157ab6d008 fix: send HardwareId to register_beacon_hardware API
The API requires HardwareId as a mandatory field, but the iOS app was
sending MacAddress (wrong key) and always passing nil. This caused
"HardwareId is required" errors after provisioning.

Since CoreBluetooth doesn't expose raw MAC addresses, we use the
CBPeripheral.identifier UUID as the hardware ID — same concept as
Android's device.address.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:55:13 +00:00

477 lines
18 KiB
Swift

import Foundation
/// Errors from API calls
enum APIError: LocalizedError {
case network(Error)
case httpError(Int)
case decodingError(Error)
case serverError(String)
case unauthorized
case noData
var errorDescription: String? {
switch self {
case .network(let e): return "Network error: \(e.localizedDescription)"
case .httpError(let code): return "HTTP \(code)"
case .decodingError(let e): return "Decode error: \(e.localizedDescription)"
case .serverError(let msg): return msg
case .unauthorized: return "Session expired. Please log in again."
case .noData: return "No data received"
}
}
}
/// Lightweight API client all Payfrit backend calls
actor APIClient {
static let shared = APIClient()
private let session: URLSession
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForResource = APIConfig.readTimeout
config.timeoutIntervalForRequest = APIConfig.connectTimeout
self.session = URLSession(configuration: config)
}
// MARK: - Auth
/// Raw response from /auth/loginOTP.php
/// API returns: { "OK": true, "UUID": "...", "MESSAGE": "..." }
private struct OTPRawResponse: Codable {
let OK: Bool
let UUID: String?
let MESSAGE: String?
let ERROR: String?
}
func sendOTP(phone: String) async throws -> String {
let body: [String: Any] = ["Phone": phone]
let data = try await post(path: "/auth/loginOTP.php", body: body)
let resp = try JSONDecoder().decode(OTPRawResponse.self, from: data)
guard resp.OK, let uuid = resp.UUID, !uuid.isEmpty else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to send OTP")
}
return uuid
}
/// Raw response from /auth/verifyLoginOTP.php
/// API returns: { "OK": true, "UserID": 123, "Token": "...", "FirstName": "..." }
private struct VerifyOTPRawResponse: Codable {
let OK: Bool
let Token: String?
let UserID: IntOrString?
let MESSAGE: String?
let ERROR: String?
}
/// Handles UserID coming as either int or string from API
enum IntOrString: Codable {
case int(Int)
case string(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let i = try? container.decode(Int.self) {
self = .int(i)
} else if let s = try? container.decode(String.self) {
self = .string(s)
} else {
self = .string("")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .int(let i): try container.encode(i)
case .string(let s): try container.encode(s)
}
}
var stringValue: String {
switch self {
case .int(let i): return String(i)
case .string(let s): return s
}
}
}
func verifyOTP(uuid: String, code: String) async throws -> (token: String, userId: String) {
let body: [String: Any] = ["UUID": uuid, "OTP": code]
let data = try await post(path: "/auth/verifyLoginOTP.php", body: body)
let resp = try JSONDecoder().decode(VerifyOTPRawResponse.self, from: data)
guard resp.OK, let token = resp.Token, !token.isEmpty else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Invalid OTP")
}
let userId = resp.UserID?.stringValue ?? ""
return (token, userId)
}
// MARK: - Businesses
/// API returns: { "OK": true, "BUSINESSES": [...], "Businesses": [...] }
private struct BusinessListResponse: Codable {
let OK: Bool
let ERROR: String?
let BUSINESSES: [Business]?
let Businesses: [Business]?
var businesses: [Business] { BUSINESSES ?? Businesses ?? [] }
}
func listBusinesses(token: String) async throws -> [Business] {
let data = try await post(path: "/businesses/list.php", body: [:], token: token)
let resp = try JSONDecoder().decode(BusinessListResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.ERROR ?? "Failed to load businesses")
}
return resp.businesses
}
// MARK: - Service Points
/// API returns: { "OK": true, "SERVICEPOINTS": [...], "GRANTED_SERVICEPOINTS": [...] }
private struct ServicePointListResponse: Codable {
let OK: Bool
let ERROR: String?
let SERVICEPOINTS: [ServicePoint]?
}
func listServicePoints(businessId: String, token: String) async throws -> [ServicePoint] {
let body: [String: Any] = ["BusinessID": businessId]
let data = try await post(path: "/servicepoints/list.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(ServicePointListResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.ERROR ?? "Failed to load service points")
}
return resp.SERVICEPOINTS ?? []
}
/// API returns: { "OK": true, "SERVICEPOINT": { ... } }
private struct ServicePointSaveResponse: Codable {
let OK: Bool
let ERROR: String?
let SERVICEPOINT: ServicePoint?
}
func createServicePoint(name: String, businessId: String, token: String) async throws -> ServicePoint {
let body: [String: Any] = ["Name": name, "BusinessID": businessId]
let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(ServicePointSaveResponse.self, from: data)
guard resp.OK, let sp = resp.SERVICEPOINT else {
throw APIError.serverError(resp.ERROR ?? "Failed to create service point")
}
return sp
}
// MARK: - Beacon Sharding
/// API returns: { "OK": true, "BeaconShardUUID": "...", "BeaconMajor": 5 }
private struct AllocateNamespaceResponse: Codable {
let OK: Bool
let ERROR: String?
let MESSAGE: String?
let BeaconShardUUID: String?
let BeaconMajor: Int?
}
func allocateBusinessNamespace(businessId: String, token: String) async throws -> (uuid: String, major: Int) {
let body: [String: Any] = ["BusinessID": businessId]
let data = try await post(path: "/beacon-sharding/allocate_business_namespace.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(AllocateNamespaceResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate namespace")
}
return (resp.BeaconShardUUID ?? "", resp.BeaconMajor ?? 0)
}
/// API returns: { "OK": true, "BeaconMinor": 3 }
private struct AllocateMinorResponse: Codable {
let OK: Bool
let ERROR: String?
let MESSAGE: String?
let BeaconMinor: Int?
}
func allocateMinor(businessId: String, servicePointId: String, token: String) async throws -> Int {
let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId]
let data = try await post(path: "/beacon-sharding/allocate_servicepoint_minor.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(AllocateMinorResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate minor")
}
guard let minor = resp.BeaconMinor, minor >= 0 else {
throw APIError.serverError("API returned invalid minor value: \(resp.BeaconMinor.map(String.init) ?? "nil"). Service point may not be configured correctly.")
}
return minor
}
/// API returns: { "OK": true, "BeaconHardwareID": 42, ... }
private struct OKResponse: Codable {
let OK: Bool
let ERROR: String?
let MESSAGE: String?
}
func registerBeaconHardware(
businessId: String,
servicePointId: String,
uuid: String,
major: Int,
minor: Int,
hardwareId: String,
beaconType: String,
token: String
) async throws {
var body: [String: Any] = [
"BusinessID": businessId,
"ServicePointID": servicePointId,
"UUID": uuid,
"Major": major,
"Minor": minor,
"HardwareId": hardwareId,
"BeaconType": beaconType
]
let data = try await post(path: "/beacon-sharding/register_beacon_hardware.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to register beacon")
}
}
func verifyBeaconBroadcast(
hardwareId: String,
uuid: String,
major: Int,
minor: Int,
token: String
) async throws {
let body: [String: Any] = ["HardwareId": hardwareId, "UUID": uuid, "Major": major, "Minor": minor]
let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token)
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to verify broadcast")
}
}
/// API returns: { "OK": true, "BusinessName": "...", "BusinessID": 5 }
private struct ResolveBusinessResponse: Codable {
let OK: Bool
let ERROR: String?
let BusinessName: String?
}
func resolveBusiness(uuid: String, major: Int, token: String) async throws -> String {
let body: [String: Any] = ["UUID": uuid, "Major": major]
let data = try await post(path: "/beacon-sharding/resolve_business.php", body: body, token: token)
let resp = try JSONDecoder().decode(ResolveBusinessResponse.self, from: data)
return resp.BusinessName ?? "Unknown"
}
// MARK: - Service Point Management
func deleteServicePoint(servicePointId: String, businessId: String, token: String) async throws {
let body: [String: Any] = ["ID": servicePointId, "BusinessID": businessId]
let data = try await post(path: "/servicepoints/delete.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to delete service point")
}
}
func updateServicePoint(servicePointId: String, name: String, businessId: String, token: String) async throws {
let body: [String: Any] = ["ID": servicePointId, "Name": name, "BusinessID": businessId]
let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to update service point")
}
}
// MARK: - Beacon Management
struct BeaconListItem: Codable {
let id: String?
let ID: String?
let uuid: String?
let UUID: String?
let major: Int?
let Major: Int?
let minor: Int?
let Minor: Int?
let macAddress: String?
let MacAddress: String?
let beaconType: String?
let BeaconType: String?
let servicePointId: String?
let ServicePointID: String?
let isVerified: Bool?
let IsVerified: Bool?
}
/// API returns: { "OK": true, "BEACONS": [...] }
private struct BeaconListAPIResponse: Codable {
let OK: Bool
let ERROR: String?
let BEACONS: [BeaconListItem]?
}
func listBeacons(businessId: String, token: String) async throws -> [BeaconListItem] {
let body: [String: Any] = ["BusinessID": businessId]
let data = try await post(path: "/beacons/list.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(BeaconListAPIResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.ERROR ?? "Failed to list beacons")
}
return resp.BEACONS ?? []
}
func decommissionBeacon(beaconId: String, businessId: String, token: String) async throws {
let body: [String: Any] = ["ID": beaconId, "BusinessID": businessId]
let data = try await post(path: "/beacons/wipe.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to decommission beacon")
}
}
func lookupByMac(macAddress: String, token: String) async throws -> BeaconListItem? {
let body: [String: Any] = ["MacAddress": macAddress]
let data = try await post(path: "/beacons/lookupByMac.php", body: body, token: token)
// This may return a single beacon object or OK: false
struct LookupResponse: Codable {
let OK: Bool
let ID: String?
let UUID: String?
let Major: Int?
let Minor: Int?
let MacAddress: String?
let BeaconType: String?
let ServicePointID: String?
let IsVerified: Bool?
}
let resp = try JSONDecoder().decode(LookupResponse.self, from: data)
guard resp.OK, resp.ID != nil else { return nil }
return BeaconListItem(
id: resp.ID, ID: resp.ID,
uuid: resp.UUID, UUID: resp.UUID,
major: resp.Major, Major: resp.Major,
minor: resp.Minor, Minor: resp.Minor,
macAddress: resp.MacAddress, MacAddress: resp.MacAddress,
beaconType: resp.BeaconType, BeaconType: resp.BeaconType,
servicePointId: resp.ServicePointID, ServicePointID: resp.ServicePointID,
isVerified: resp.IsVerified, IsVerified: resp.IsVerified
)
}
func getBeaconStatus(uuid: String, major: Int, minor: Int, token: String) async throws -> BeaconListItem? {
let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor]
let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token)
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else { return nil }
// The verify endpoint confirms status but doesn't return full beacon details
return BeaconListItem(
id: nil, ID: nil,
uuid: uuid, UUID: uuid,
major: major, Major: major,
minor: minor, Minor: minor,
macAddress: nil, MacAddress: nil,
beaconType: nil, BeaconType: nil,
servicePointId: nil, ServicePointID: nil,
isVerified: true, IsVerified: true
)
}
// MARK: - Beacon Config (server-configured values)
/// API returns: { "OK": true, "UUID": "...", "Major": 5, "Minor": 3, ... }
struct BeaconConfigResponse: Codable {
let OK: Bool
let ERROR: String?
let MESSAGE: String?
let UUID: String?
let Major: Int?
let Minor: Int?
let TxPower: Int?
let MeasuredPower: Int?
let AdvInterval: Int?
var configUUID: String { UUID ?? "" }
var configMajor: Int { Major ?? 0 }
var configMinor: Int { Minor ?? 0 }
var configTxPower: Int { TxPower ?? 1 }
var configMeasuredPower: Int { MeasuredPower ?? -100 }
var configAdvInterval: Int { AdvInterval ?? 2 }
}
func getBeaconConfig(businessId: String, servicePointId: String, token: String) async throws -> BeaconConfigResponse {
let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId]
let data = try await post(path: "/beacon-sharding/get_beacon_config.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(BeaconConfigResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to get beacon config")
}
return resp
}
// MARK: - User Profile
/// Note: /users/profile.php endpoint may not exist on server.
/// Using a flat response decoder matching the standard API format.
struct UserProfile: Codable {
let OK: Bool?
let ID: IntOrString?
let FirstName: String?
let LastName: String?
let ContactNumber: String?
var userId: String { ID?.stringValue ?? "" }
var firstName: String { FirstName ?? "" }
var lastName: String { LastName ?? "" }
}
func getProfile(token: String) async throws -> UserProfile {
let data = try await post(path: "/users/profile.php", body: [:], token: token)
let resp = try JSONDecoder().decode(UserProfile.self, from: data)
return resp
}
// MARK: - Internal
private func post(
path: String,
body: [String: Any],
token: String? = nil,
businessId: String? = nil
) async throws -> Data {
guard let url = URL(string: APIConfig.baseURL + path) else {
throw APIError.serverError("Invalid URL: \(path)")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue(token, forHTTPHeaderField: "X-User-Token")
}
if let bizId = businessId {
request.setValue(bizId, forHTTPHeaderField: "X-Business-Id")
}
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse {
if http.statusCode == 401 { throw APIError.unauthorized }
guard (200...299).contains(http.statusCode) else {
throw APIError.httpError(http.statusCode)
}
}
return data
}
}