The API returns {OK: true, BUSINESSES: [...]} but the iOS client was
decoding {Success: true, Data: [...]} which never matched — causing
"Failed to load businesses" on every call. Also fixes Business model
(BusinessID/Name vs ID/BusinessName) and ServicePoint model
(ServicePointID vs ID). All response decoders now match the real API.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
473 lines
18 KiB
Swift
473 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")
|
|
}
|
|
return resp.BeaconMinor ?? 0
|
|
}
|
|
|
|
/// 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,
|
|
macAddress: String?,
|
|
beaconType: String,
|
|
token: String
|
|
) async throws {
|
|
var body: [String: Any] = [
|
|
"BusinessID": businessId,
|
|
"ServicePointID": servicePointId,
|
|
"UUID": uuid,
|
|
"Major": major,
|
|
"Minor": minor,
|
|
"BeaconType": beaconType
|
|
]
|
|
if let mac = macAddress { body["MacAddress"] = mac }
|
|
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(
|
|
uuid: String,
|
|
major: Int,
|
|
minor: Int,
|
|
token: String
|
|
) async throws {
|
|
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 {
|
|
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
|
|
}
|
|
}
|