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>
477 lines
18 KiB
Swift
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
|
|
}
|
|
}
|