Instead of calculating change client-side (which doesn't account for balance applied, fees, etc.), the app now uses the Change value returned by the /tasks/complete.php endpoint after processing. Changes: - APIService.completeTask now returns CashCompletionResult with backend values - Added CashCompletionResult struct (cashReceived, orderTotal, change, fees, routing) - CashCollectionSheet shows confirmed backend change after completion - Added ratingRequired error case to APIError enum - Client-side estimate still shown as preview before confirmation
977 lines
36 KiB
Swift
977 lines
36 KiB
Swift
import Foundation
|
|
import os.log
|
|
|
|
private let logger = Logger(subsystem: "com.payfrit.works", category: "API")
|
|
|
|
// MARK: - Dev Flag
|
|
|
|
/// Master flag: flip to `false` for production builds.
|
|
/// Controls API endpoint, dev banner, and magic OTPs.
|
|
let IS_DEV = false
|
|
|
|
// MARK: - API Errors
|
|
|
|
enum APIError: LocalizedError {
|
|
case invalidURL
|
|
case noData
|
|
case decodingError(String)
|
|
case serverError(String)
|
|
case unauthorized
|
|
case networkError(String)
|
|
case ratingRequired(customerUserId: Int)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL: return "Invalid URL"
|
|
case .noData: return "No data received"
|
|
case .decodingError(let msg): return "Decoding error: \(msg)"
|
|
case .serverError(let msg): return msg
|
|
case .unauthorized: return "Unauthorized"
|
|
case .networkError(let msg): return msg
|
|
case .ratingRequired: return "Customer rating is required before completing this task"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Login Response
|
|
|
|
struct LoginResponse {
|
|
let userId: Int
|
|
let userFirstName: String
|
|
let token: String
|
|
let photoUrl: String
|
|
}
|
|
|
|
// MARK: - OTP Response
|
|
|
|
struct SendOtpResponse {
|
|
let uuid: String
|
|
let message: String
|
|
}
|
|
|
|
// MARK: - Chat Messages Result
|
|
|
|
struct ChatMessagesResult {
|
|
let messages: [ChatMessage]
|
|
let chatClosed: Bool
|
|
}
|
|
|
|
/// Backend-authoritative cash completion result.
|
|
/// All monetary values are formatted strings (e.g. "12.50") from the server.
|
|
struct CashCompletionResult {
|
|
let cashReceived: String
|
|
let orderTotal: String
|
|
let change: String
|
|
let customerFee: String?
|
|
let businessFee: String?
|
|
let cashRoutedTo: String // "worker" or "business"
|
|
let balanceApplied: String?
|
|
|
|
var changeDollars: Double {
|
|
Double(change) ?? 0
|
|
}
|
|
}
|
|
|
|
// MARK: - API Service
|
|
|
|
actor APIService {
|
|
static let shared = APIService()
|
|
|
|
private static let devBaseURL = "https://dev.payfrit.com/api"
|
|
private static let prodBaseURL = "https://biz.payfrit.com/api"
|
|
|
|
private var userToken: String?
|
|
private var userId: Int?
|
|
private var businessId: Int = 0
|
|
|
|
var baseURL: String { IS_DEV ? Self.devBaseURL : Self.prodBaseURL }
|
|
|
|
// MARK: - Configuration
|
|
|
|
func setAuth(token: String?, userId: Int?) {
|
|
self.userToken = token
|
|
self.userId = userId
|
|
}
|
|
|
|
func setBusinessId(_ id: Int) {
|
|
self.businessId = id
|
|
}
|
|
|
|
func getToken() -> String? { userToken }
|
|
func getUserId() -> Int? { userId }
|
|
func getBusinessId() -> Int { businessId }
|
|
|
|
// MARK: - Core Request
|
|
|
|
private func postJSON(_ path: String, payload: [String: Any]) async throws -> [String: Any] {
|
|
let urlString = baseURL + (path.hasPrefix("/") ? path : "/\(path)")
|
|
guard let url = URL(string: urlString) else { throw APIError.invalidURL }
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
|
|
|
if let token = userToken, !token.isEmpty {
|
|
request.setValue(token, forHTTPHeaderField: "X-User-Token")
|
|
}
|
|
if businessId > 0 {
|
|
request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID")
|
|
}
|
|
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
if httpResponse.statusCode == 401 { throw APIError.unauthorized }
|
|
guard (200...299).contains(httpResponse.statusCode) else {
|
|
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
|
|
}
|
|
}
|
|
|
|
// Try to parse JSON, handling CFML prefix junk
|
|
if let json = tryDecodeJSON(data) {
|
|
return json
|
|
}
|
|
throw APIError.decodingError("Non-JSON response")
|
|
}
|
|
|
|
private func getJSON(_ path: String) async throws -> [String: Any] {
|
|
let urlString = baseURL + (path.hasPrefix("/") ? path : "/\(path)")
|
|
guard let url = URL(string: urlString) else { throw APIError.invalidURL }
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
|
|
if let token = userToken, !token.isEmpty {
|
|
request.setValue(token, forHTTPHeaderField: "X-User-Token")
|
|
}
|
|
if businessId > 0 {
|
|
request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID")
|
|
}
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
if httpResponse.statusCode == 401 { throw APIError.unauthorized }
|
|
guard (200...299).contains(httpResponse.statusCode) else {
|
|
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
|
|
}
|
|
}
|
|
|
|
if let json = tryDecodeJSON(data) {
|
|
return json
|
|
}
|
|
throw APIError.decodingError("Non-JSON response")
|
|
}
|
|
|
|
private func tryDecodeJSON(_ data: Data) -> [String: Any]? {
|
|
// Try direct parse
|
|
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
return json
|
|
}
|
|
// Try extracting JSON from mixed response
|
|
guard let body = String(data: data, encoding: .utf8),
|
|
let start = body.firstIndex(of: "{"),
|
|
let end = body.lastIndex(of: "}") else { return nil }
|
|
let jsonStr = String(body[start...end])
|
|
guard let jsonData = jsonStr.data(using: .utf8) else { return nil }
|
|
return try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
|
|
}
|
|
|
|
private func ok(_ json: [String: Any]) -> Bool {
|
|
if let b = json["OK"] as? Bool { return b }
|
|
if let b = json["ok"] as? Bool { return b }
|
|
if let i = json["OK"] as? Int { return i == 1 }
|
|
if let s = json["OK"] as? String { return s == "true" || s == "1" || s == "YES" }
|
|
return false
|
|
}
|
|
|
|
private func err(_ json: [String: Any]) -> String {
|
|
(json["ERROR"] as? String) ?? (json["error"] as? String)
|
|
?? (json["Error"] as? String) ?? (json["message"] as? String) ?? ""
|
|
}
|
|
|
|
/// All expected PascalCase keys our models use.
|
|
nonisolated private static let expectedKeys: [String] = [
|
|
"TaskID", "TaskBusinessID", "TaskCategoryID", "TaskTypeID", "TaskTitle",
|
|
"TaskDetails", "TaskCreatedOn", "TaskStatusID", "TaskSourceType", "TaskSourceID",
|
|
"TaskCategoryName", "TaskCategoryColor",
|
|
"EmployeeID", "BusinessID", "BusinessName", "BusinessAddress", "BusinessCity",
|
|
"EmployeeStatusID", "PendingTaskCount",
|
|
"OrderID", "OrderRemarks", "OrderSubmittedOn",
|
|
"ServicePointID", "ServicePointName", "ServicePointTypeID",
|
|
"DeliveryAddress", "DeliveryLat", "DeliveryLng",
|
|
"CustomerUserID", "CustomerFirstName", "CustomerLastName",
|
|
"CustomerPhone", "CustomerPhotoUrl", "BeaconUUID",
|
|
"LineItems", "TableMembers",
|
|
"LineItemID", "ItemName", "Quantity", "PriceCents", "Remark",
|
|
"IsModifier", "ParentLineItemID",
|
|
"UserID", "FirstName", "LastName", "IsHost",
|
|
"MessageID", "SenderUserID", "SenderType", "SenderName", "Text", "MessageText",
|
|
"CreatedOn", "IsRead",
|
|
"OK", "ERROR", "TASKS", "BUSINESSES", "MESSAGES", "TASK",
|
|
"UserFirstName", "Token", "PhotoUrl", "UserPhotoUrl",
|
|
"TIER", "STRIPE", "ACTIVATION",
|
|
"HasAccount", "PayoutsEnabled", "SetupIncomplete",
|
|
"BalanceCents", "CapCents", "RemainingCents", "IsComplete", "ProgressPercent",
|
|
"ENTRIES", "TOTALS", "ID",
|
|
"GrossEarningsCents", "ActivationWithheldCents", "NetTransferCents", "Status", "CreatedAt",
|
|
"TotalGrossCents", "TotalWithheldCents", "TotalNetCents",
|
|
"AccountID", "ACCOUNT_ID", "URL", "url",
|
|
"CHAT_CLOSED", "chat_closed", "PayCents",
|
|
"TaskTypeName", "OrderTotal", "CashReceivedCents"
|
|
]
|
|
|
|
/// Build a lookup: stripped key (no underscores, lowercased) → expected PascalCase key.
|
|
/// This handles ALL CAPS, underscore_separated, camelCase, PascalCase — any format.
|
|
nonisolated private static let keyLookup: [String: String] = {
|
|
var map: [String: String] = [:]
|
|
for k in expectedKeys {
|
|
// Strip underscores and lowercase for fuzzy matching
|
|
let stripped = k.replacingOccurrences(of: "_", with: "").lowercased()
|
|
map[stripped] = k
|
|
// Also add exact uppercased (for direct ALL CAPS match)
|
|
map[k.uppercased()] = k
|
|
}
|
|
return map
|
|
}()
|
|
|
|
/// Normalize dictionary keys: maps ANY casing/format to expected PascalCase.
|
|
/// Strips underscores and lowercases for fuzzy matching against known keys.
|
|
nonisolated static func normalizeKeys(_ dict: [String: Any]) -> [String: Any] {
|
|
var result: [String: Any] = [:]
|
|
for (key, value) in dict {
|
|
// Try exact uppercased match first, then stripped fuzzy match
|
|
let stripped = key.replacingOccurrences(of: "_", with: "").lowercased()
|
|
let normalizedKey = keyLookup[key.uppercased()] ?? keyLookup[stripped] ?? key
|
|
if let arr = value as? [[String: Any]] {
|
|
result[normalizedKey] = arr.map { normalizeKeys($0) }
|
|
} else if let sub = value as? [String: Any] {
|
|
result[normalizedKey] = normalizeKeys(sub)
|
|
} else {
|
|
result[normalizedKey] = value
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// Find an array value in the JSON by trying multiple key variants
|
|
nonisolated static func findArray(_ json: [String: Any], _ keys: [String]) -> [[String: Any]]? {
|
|
for key in keys {
|
|
if let arr = json[key] as? [[String: Any]] { return arr }
|
|
}
|
|
// Fallback: search all values for the first array of dicts
|
|
for (_, value) in json {
|
|
if let arr = value as? [[String: Any]], !arr.isEmpty { return arr }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Auth
|
|
|
|
func login(username: String, password: String) async throws -> LoginResponse {
|
|
let json = try await postJSON("/auth/login.php", payload: [
|
|
"username": username,
|
|
"password": password
|
|
])
|
|
|
|
guard ok(json) else {
|
|
let e = err(json)
|
|
if e == "bad_credentials" {
|
|
throw APIError.serverError("Invalid email/phone or password")
|
|
}
|
|
throw APIError.serverError("Login failed: \(e)")
|
|
}
|
|
|
|
let uid = (json["UserID"] as? Int)
|
|
?? Int(json["UserID"] as? String ?? "")
|
|
?? (json["UserId"] as? Int)
|
|
?? 0
|
|
let token = (json["Token"] as? String)
|
|
?? (json["token"] as? String)
|
|
?? ""
|
|
let firstName = (json["UserFirstName"] as? String)
|
|
?? (json["FirstName"] as? String)
|
|
?? (json["firstName"] as? String)
|
|
?? (json["Name"] as? String)
|
|
?? (json["name"] as? String)
|
|
?? ""
|
|
let photoUrl = (json["UserPhotoUrl"] as? String)
|
|
?? (json["PhotoUrl"] as? String)
|
|
?? (json["photoUrl"] as? String)
|
|
?? ""
|
|
|
|
self.userToken = token
|
|
self.userId = uid
|
|
|
|
return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl)
|
|
}
|
|
|
|
// MARK: - OTP Login
|
|
|
|
func sendLoginOtp(phone: String) async throws -> SendOtpResponse {
|
|
let json = try await postJSON("/auth/loginOTP.php", payload: [
|
|
"phone": phone
|
|
])
|
|
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to send code: \(err(json))")
|
|
}
|
|
|
|
let data = json["DATA"] as? [String: Any] ?? json
|
|
let uuid = (data["UUID"] as? String) ?? (data["uuid"] as? String) ?? ""
|
|
let message = (data["MESSAGE"] as? String) ?? (data["message"] as? String) ?? "OTP sent"
|
|
|
|
return SendOtpResponse(uuid: uuid, message: message)
|
|
}
|
|
|
|
func verifyLoginOtp(uuid: String, otp: String) async throws -> LoginResponse {
|
|
let json = try await postJSON("/auth/verifyLoginOTP.php", payload: [
|
|
"uuid": uuid,
|
|
"otp": otp
|
|
])
|
|
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Invalid code")
|
|
}
|
|
|
|
let data = json["DATA"] as? [String: Any] ?? json
|
|
let uid = (data["UserID"] as? Int)
|
|
?? Int(data["UserID"] as? String ?? "")
|
|
?? (data["USERID"] as? Int)
|
|
?? 0
|
|
let token = (data["Token"] as? String)
|
|
?? (data["TOKEN"] as? String)
|
|
?? (data["token"] as? String)
|
|
?? ""
|
|
let firstName = (data["FirstName"] as? String)
|
|
?? (data["FIRSTNAME"] as? String)
|
|
?? (data["USERFIRSTNAME"] as? String)
|
|
?? ""
|
|
let photoUrl = (data["PhotoUrl"] as? String)
|
|
?? (data["PHOTOURL"] as? String)
|
|
?? ""
|
|
|
|
self.userToken = token
|
|
self.userId = uid
|
|
|
|
return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl)
|
|
}
|
|
|
|
func logout() {
|
|
userToken = nil
|
|
userId = nil
|
|
businessId = 0
|
|
}
|
|
|
|
// MARK: - Profile
|
|
|
|
struct UserProfile {
|
|
let userId: Int
|
|
let firstName: String
|
|
let lastName: String
|
|
let email: String
|
|
let phone: String
|
|
let photoUrl: String
|
|
}
|
|
|
|
func getProfile() async throws -> UserProfile {
|
|
guard let uid = userId, uid > 0 else {
|
|
throw APIError.serverError("User not logged in")
|
|
}
|
|
|
|
let json = try await postJSON("/auth/profile.php", payload: [
|
|
"UserID": uid
|
|
])
|
|
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load profile: \(err(json))")
|
|
}
|
|
|
|
let data = json["DATA"] as? [String: Any] ?? json
|
|
return UserProfile(
|
|
userId: (data["UserID"] as? Int) ?? (data["USERID"] as? Int) ?? uid,
|
|
firstName: (data["FirstName"] as? String) ?? (data["FIRSTNAME"] as? String) ?? "",
|
|
lastName: (data["LastName"] as? String) ?? (data["LASTNAME"] as? String) ?? "",
|
|
email: (data["Email"] as? String) ?? (data["EMAIL"] as? String) ?? (data["EmailAddress"] as? String) ?? "",
|
|
phone: (data["Phone"] as? String) ?? (data["PHONE"] as? String) ?? (data["ContactNumber"] as? String) ?? "",
|
|
photoUrl: Self.resolvePhotoUrl((data["PhotoUrl"] as? String) ?? (data["PHOTOURL"] as? String) ?? "")
|
|
)
|
|
}
|
|
|
|
func updateProfile(firstName: String? = nil, lastName: String? = nil, email: String? = nil, phone: String? = nil) async throws {
|
|
guard let uid = userId, uid > 0 else {
|
|
throw APIError.serverError("User not logged in")
|
|
}
|
|
|
|
var payload: [String: Any] = ["UserID": uid]
|
|
if let fn = firstName { payload["FirstName"] = fn }
|
|
if let ln = lastName { payload["LastName"] = ln }
|
|
if let em = email { payload["Email"] = em }
|
|
if let ph = phone { payload["Phone"] = ph }
|
|
|
|
let json = try await postJSON("/auth/profile.php", payload: payload)
|
|
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to update profile: \(err(json))")
|
|
}
|
|
}
|
|
|
|
// MARK: - Businesses
|
|
|
|
func getMyBusinesses() async throws -> [Employment] {
|
|
guard let uid = userId, uid > 0 else {
|
|
throw APIError.serverError("User not logged in")
|
|
}
|
|
|
|
let json = try await postJSON("/workers/myBusinesses.php", payload: [
|
|
"UserID": uid
|
|
])
|
|
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load businesses: \(err(json))")
|
|
}
|
|
|
|
guard let arr = Self.findArray(json, ["BUSINESSES", "Businesses", "businesses"]) else {
|
|
return []
|
|
}
|
|
return arr.map { Employment(json: $0) }
|
|
}
|
|
|
|
// MARK: - Tasks
|
|
|
|
func listPendingTasks(categoryId: Int? = nil) async throws -> [WorkTask] {
|
|
var payload: [String: Any] = ["BusinessID": businessId]
|
|
if let cid = categoryId, cid > 0 {
|
|
payload["CategoryID"] = cid
|
|
}
|
|
|
|
let json = try await postJSON("/tasks/listPending.php", payload: payload)
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load tasks: \(err(json))")
|
|
}
|
|
|
|
guard let arr = Self.findArray(json, ["TASKS", "Tasks", "tasks"]) else { return [] }
|
|
return arr.map { WorkTask(json: $0) }
|
|
}
|
|
|
|
func acceptTask(taskId: Int) async throws {
|
|
// Flutter only sends TaskID + BusinessID (no UserID)
|
|
let json = try await postJSON("/tasks/accept.php", payload: [
|
|
"TaskID": taskId,
|
|
"BusinessID": businessId
|
|
])
|
|
|
|
guard ok(json) else {
|
|
let e = err(json)
|
|
if e == "already_accepted" {
|
|
throw APIError.serverError("This task has already been claimed by someone else.")
|
|
}
|
|
throw APIError.serverError("Failed to accept task: \(e)")
|
|
}
|
|
}
|
|
|
|
func listMyTasks(filterType: String = "active") async throws -> [WorkTask] {
|
|
var payload: [String: Any] = [
|
|
"UserID": userId ?? 0,
|
|
"FilterType": filterType
|
|
]
|
|
if businessId > 0 {
|
|
payload["BusinessID"] = businessId
|
|
}
|
|
|
|
let json = try await postJSON("/tasks/listMine.php", payload: payload)
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load my tasks: \(err(json))")
|
|
}
|
|
|
|
guard let arr = Self.findArray(json, ["TASKS", "Tasks", "tasks"]) else { return [] }
|
|
return arr.map { WorkTask(json: $0) }
|
|
}
|
|
|
|
/// Complete a task. Returns CashCompletionResult when cashReceivedCents is provided.
|
|
@discardableResult
|
|
func completeTask(taskId: Int, cashReceivedCents: Int? = nil, cancelOrder: Bool = false) async throws -> CashCompletionResult? {
|
|
var payload: [String: Any] = [
|
|
"TaskID": taskId,
|
|
"UserID": userId ?? 0
|
|
]
|
|
if let cents = cashReceivedCents {
|
|
payload["CashReceivedCents"] = cents
|
|
}
|
|
if cancelOrder {
|
|
payload["CancelOrder"] = true
|
|
}
|
|
let json = try await postJSON("/tasks/complete.php", payload: payload)
|
|
guard ok(json) else {
|
|
let errorMsg = err(json)
|
|
if errorMsg == "rating_required" {
|
|
let customerUserId = json["CustomerUserID"] as? Int ?? 0
|
|
throw APIError.ratingRequired(customerUserId: customerUserId)
|
|
}
|
|
throw APIError.serverError("Failed to complete task: \(errorMsg)")
|
|
}
|
|
|
|
// Parse cash completion response from backend (authoritative values)
|
|
if let _ = cashReceivedCents, json["CashProcessed"] as? Bool == true {
|
|
return CashCompletionResult(
|
|
cashReceived: json["CashReceived"] as? String ?? "0.00",
|
|
orderTotal: json["OrderTotal"] as? String ?? "0.00",
|
|
change: json["Change"] as? String ?? "0.00",
|
|
customerFee: json["CustomerFee"] as? String,
|
|
businessFee: json["BusinessFee"] as? String,
|
|
cashRoutedTo: json["CashRoutedTo"] as? String ?? "worker",
|
|
balanceApplied: json["BalanceApplied"] as? String
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func closeChat(taskId: Int) async throws {
|
|
let payload: [String: Any] = [
|
|
"TaskID": taskId
|
|
]
|
|
let json = try await postJSON("/tasks/completeChat.php", payload: payload)
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Close chat failed: \(err(json))")
|
|
}
|
|
}
|
|
|
|
func getTaskDetails(taskId: Int) async throws -> TaskDetails {
|
|
let json = try await postJSON("/tasks/getDetails.php", payload: [
|
|
"TaskID": taskId
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load task details: \(err(json))")
|
|
}
|
|
|
|
// Find the TASK object — try known keys, then fallback to first dict value
|
|
var taskJson: [String: Any]?
|
|
for key in ["TASK", "Task", "task"] {
|
|
if let d = json[key] as? [String: Any] { taskJson = d; break }
|
|
}
|
|
if taskJson == nil {
|
|
// Fallback: look for first nested dict that looks like a task
|
|
for (_, value) in json {
|
|
if let d = value as? [String: Any], d.count > 3 { taskJson = d; break }
|
|
}
|
|
}
|
|
guard let taskJson = taskJson else {
|
|
throw APIError.serverError("Invalid task details response")
|
|
}
|
|
|
|
// Debug: log ALL fields to find customer data
|
|
if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
|
let debugFile = docs.appendingPathComponent("task_debug.txt")
|
|
var lines: [String] = ["=== ALL KEYS ==="]
|
|
for key in taskJson.keys.sorted() {
|
|
let val = taskJson[key]
|
|
if let str = val as? String {
|
|
lines.append("\(key): \(str)")
|
|
} else if let num = val as? Int {
|
|
lines.append("\(key): \(num)")
|
|
} else if val is [[String: Any]] {
|
|
lines.append("\(key): [array]")
|
|
} else {
|
|
lines.append("\(key): \(val ?? "nil")")
|
|
}
|
|
}
|
|
try? lines.joined(separator: "\n").write(to: debugFile, atomically: true, encoding: .utf8)
|
|
}
|
|
|
|
let details = TaskDetails(json: taskJson)
|
|
return details
|
|
}
|
|
|
|
// MARK: - Chat
|
|
|
|
func getChatMessages(taskId: Int, afterMessageId: Int? = nil) async throws -> ChatMessagesResult {
|
|
var payload: [String: Any] = ["TaskID": taskId]
|
|
if let after = afterMessageId, after > 0 {
|
|
payload["AfterMessageID"] = after
|
|
}
|
|
|
|
let json = try await postJSON("/chat/getMessages.php", payload: payload)
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load chat messages")
|
|
}
|
|
|
|
let arr = Self.findArray(json, ["MESSAGES", "Messages", "messages"]) ?? []
|
|
let messages: [ChatMessage] = arr.map { ChatMessage(json: $0) }
|
|
let chatClosed = (json["CHAT_CLOSED"] as? Bool) == true || (json["chat_closed"] as? Bool) == true
|
|
|
|
return ChatMessagesResult(messages: messages, chatClosed: chatClosed)
|
|
}
|
|
|
|
func sendChatMessage(taskId: Int, message: String, userId: Int? = nil, senderType: String? = nil) async throws -> Int {
|
|
var payload: [String: Any] = [
|
|
"TaskID": taskId,
|
|
"Message": message
|
|
]
|
|
if let uid = userId { payload["UserID"] = uid }
|
|
if let st = senderType { payload["SenderType"] = st }
|
|
|
|
let json = try await postJSON("/chat/sendMessage.php", payload: payload)
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to send message")
|
|
}
|
|
|
|
return (json["MessageID"] as? Int) ?? (json["MESSAGE_ID"] as? Int) ?? 0
|
|
}
|
|
|
|
func markChatMessagesRead(taskId: Int, readerType: String) async throws {
|
|
let json = try await postJSON("/chat/markRead.php", payload: [
|
|
"TaskID": taskId,
|
|
"ReaderType": readerType
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to mark messages as read")
|
|
}
|
|
}
|
|
|
|
// MARK: - Payout / Tier Endpoints
|
|
|
|
func getTierStatus() async throws -> TierStatus {
|
|
let json = try await postJSON("/workers/tierStatus.php", payload: [
|
|
"UserID": userId ?? 0
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load tier status: \(err(json))")
|
|
}
|
|
let data = try JSONSerialization.data(withJSONObject: json)
|
|
return (try? JSONDecoder().decode(TierStatus.self, from: data)) ?? TierStatus()
|
|
}
|
|
|
|
func createStripeAccount() async throws -> String {
|
|
let json = try await postJSON("/workers/createAccount.php", payload: [
|
|
"UserID": userId ?? 0
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to create Stripe account: \(err(json))")
|
|
}
|
|
return (json["AccountID"] as? String) ?? (json["ACCOUNT_ID"] as? String) ?? ""
|
|
}
|
|
|
|
func getOnboardingLink() async throws -> String {
|
|
let json = try await postJSON("/workers/onboardingLink.php", payload: [
|
|
"UserID": userId ?? 0
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to get onboarding link: \(err(json))")
|
|
}
|
|
return (json["URL"] as? String) ?? (json["url"] as? String) ?? ""
|
|
}
|
|
|
|
func getEarlyUnlockUrl() async throws -> String {
|
|
let json = try await postJSON("/workers/earlyUnlock.php", payload: [
|
|
"UserID": userId ?? 0
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to get unlock URL: \(err(json))")
|
|
}
|
|
return (json["URL"] as? String) ?? (json["url"] as? String) ?? ""
|
|
}
|
|
|
|
func getLedger() async throws -> LedgerResponse {
|
|
let json = try await postJSON("/workers/ledger.php", payload: [
|
|
"UserID": userId ?? 0
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to load ledger: \(err(json))")
|
|
}
|
|
let data = try JSONSerialization.data(withJSONObject: json)
|
|
return try JSONDecoder().decode(LedgerResponse.self, from: data)
|
|
}
|
|
|
|
// MARK: - Avatar
|
|
|
|
func getAvatarUrl() async throws -> String? {
|
|
let json = try await getJSON("/auth/avatar.php")
|
|
print("[Avatar] Response: \(json)")
|
|
guard ok(json) else {
|
|
print("[Avatar] Response not OK")
|
|
return nil
|
|
}
|
|
|
|
let data = json["DATA"] as? [String: Any] ?? json
|
|
print("[Avatar] Data: \(data)")
|
|
|
|
// Try all possible key variations for avatar URL
|
|
let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url",
|
|
"PhotoUrl", "PHOTOURL", "photoUrl", "photo_url", "PHOTO_URL",
|
|
"UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"]
|
|
for key in keys {
|
|
if let url = data[key] as? String, !url.isEmpty {
|
|
let resolved = Self.resolvePhotoUrl(url)
|
|
print("[Avatar] Found key '\(key)' with value: \(url) -> \(resolved)")
|
|
return resolved
|
|
}
|
|
if let url = json[key] as? String, !url.isEmpty {
|
|
let resolved = Self.resolvePhotoUrl(url)
|
|
print("[Avatar] Found key '\(key)' in json with value: \(url) -> \(resolved)")
|
|
return resolved
|
|
}
|
|
}
|
|
print("[Avatar] No avatar URL found in response")
|
|
return nil
|
|
}
|
|
|
|
/// Get avatar URL for any user by their userId
|
|
func getUserAvatarUrl(userId: Int) async throws -> String? {
|
|
// Try the avatar endpoint with UserID
|
|
let json = try await postJSON("/auth/avatar.php", payload: ["UserID": userId])
|
|
print("[Avatar] getUserAvatarUrl(\(userId)) Response: \(json)")
|
|
|
|
let data = json["DATA"] as? [String: Any] ?? json
|
|
|
|
let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url",
|
|
"PhotoUrl", "PHOTOURL", "photoUrl", "photo_url", "PHOTO_URL",
|
|
"UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"]
|
|
for key in keys {
|
|
if let url = data[key] as? String, !url.isEmpty {
|
|
print("[Avatar] Found avatar for userId \(userId): \(url)")
|
|
return Self.resolvePhotoUrl(url)
|
|
}
|
|
if let url = json[key] as? String, !url.isEmpty {
|
|
print("[Avatar] Found avatar for userId \(userId): \(url)")
|
|
return Self.resolvePhotoUrl(url)
|
|
}
|
|
}
|
|
print("[Avatar] No avatar found for userId \(userId), all keys: \(json.keys.sorted())")
|
|
return nil
|
|
}
|
|
|
|
/// Construct direct avatar URL for a user (fallback pattern)
|
|
nonisolated static func directAvatarUrl(userId: Int) -> String {
|
|
let baseDomain = IS_DEV ? "https://dev.payfrit.com" : "https://biz.payfrit.com"
|
|
return "\(baseDomain)/uploads/avatars/\(userId).jpg"
|
|
}
|
|
|
|
// MARK: - Beacon Sharding
|
|
|
|
func resolveServicePoint(uuid: String, major: Int, minor: Int) async throws -> Int {
|
|
let json = try await postJSON("/beacon-sharding/resolve_servicepoint.php", payload: [
|
|
"UUID": uuid,
|
|
"Major": major,
|
|
"Minor": minor
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to resolve service point: \(err(json))")
|
|
}
|
|
|
|
let data = json["DATA"] as? [String: Any] ?? json
|
|
if let spId = data["ServicePointID"] as? Int { return spId }
|
|
if let spId = data["SERVICEPOINTID"] as? Int { return spId }
|
|
if let spId = data["servicePointId"] as? Int { return spId }
|
|
if let spId = json["ServicePointID"] as? Int { return spId }
|
|
if let spId = json["SERVICEPOINTID"] as? Int { return spId }
|
|
|
|
throw APIError.decodingError("ServicePointID not found in response")
|
|
}
|
|
|
|
func resolveBusiness(uuid: String, major: Int) async throws -> (businessId: Int, businessName: String) {
|
|
let json = try await postJSON("/beacon-sharding/resolve_business.php", payload: [
|
|
"UUID": uuid,
|
|
"Major": major
|
|
])
|
|
guard ok(json) else {
|
|
throw APIError.serverError("Failed to resolve business: \(err(json))")
|
|
}
|
|
|
|
let data = json["DATA"] as? [String: Any] ?? json
|
|
let bizId = (data["BusinessID"] as? Int) ?? (data["BUSINESSID"] as? Int) ?? (json["BusinessID"] as? Int) ?? 0
|
|
let bizName = (data["BusinessName"] as? String) ?? (data["BUSINESSNAME"] as? String) ?? (json["BusinessName"] as? String) ?? ""
|
|
|
|
return (businessId: bizId, businessName: bizName)
|
|
}
|
|
|
|
// MARK: - Debug
|
|
|
|
/// Returns raw JSON string for a given endpoint (for debugging key issues)
|
|
func debugRawJSON(_ path: String, payload: [String: Any]) async -> String {
|
|
let urlString = baseURL + (path.hasPrefix("/") ? path : "/\(path)")
|
|
guard let url = URL(string: urlString) else { return "Invalid URL" }
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
|
if let token = userToken, !token.isEmpty {
|
|
request.setValue(token, forHTTPHeaderField: "X-User-Token")
|
|
}
|
|
if businessId > 0 {
|
|
request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID")
|
|
}
|
|
request.httpBody = try? JSONSerialization.data(withJSONObject: payload)
|
|
guard let (data, _) = try? await URLSession.shared.data(for: request) else {
|
|
return "Network error"
|
|
}
|
|
let raw = String(data: data, encoding: .utf8) ?? "Non-UTF8"
|
|
// Return first 2000 chars
|
|
return String(raw.prefix(2000))
|
|
}
|
|
|
|
// MARK: - About Info
|
|
|
|
func getAboutInfo() async throws -> AboutInfo {
|
|
let json = try await getJSON("app/about.php")
|
|
|
|
guard ok(json) else {
|
|
throw APIError.serverError(err(json))
|
|
}
|
|
|
|
let description = parseString(json["DESCRIPTION"] ?? json["description"])
|
|
let copyright = parseString(json["COPYRIGHT"] ?? json["copyright"])
|
|
|
|
// Parse features
|
|
var features: [AboutFeature] = []
|
|
if let featuresArray = (json["FEATURES"] ?? json["features"]) as? [[String: Any]] {
|
|
for f in featuresArray {
|
|
features.append(AboutFeature(
|
|
icon: parseString(f["ICON"] ?? f["icon"]),
|
|
title: parseString(f["TITLE"] ?? f["title"]),
|
|
description: parseString(f["DESCRIPTION"] ?? f["description"])
|
|
))
|
|
}
|
|
}
|
|
|
|
// Parse contacts
|
|
var contacts: [AboutContact] = []
|
|
if let contactsArray = (json["CONTACTS"] ?? json["contacts"]) as? [[String: Any]] {
|
|
for c in contactsArray {
|
|
contacts.append(AboutContact(
|
|
icon: parseString(c["ICON"] ?? c["icon"]),
|
|
label: parseString(c["LABEL"] ?? c["label"]),
|
|
url: parseString(c["URL"] ?? c["url"])
|
|
))
|
|
}
|
|
}
|
|
|
|
return AboutInfo(
|
|
description: description,
|
|
features: features,
|
|
contacts: contacts,
|
|
copyright: copyright
|
|
)
|
|
}
|
|
|
|
// MARK: - URL Helpers
|
|
|
|
/// Resolve a photo URL — if it starts with "/" prepend the base domain
|
|
nonisolated static func resolvePhotoUrl(_ rawUrl: String) -> String {
|
|
let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty { return "" }
|
|
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { return trimmed }
|
|
let baseDomain = IS_DEV ? "https://dev.payfrit.com" : "https://biz.payfrit.com"
|
|
if trimmed.hasPrefix("/") { return baseDomain + trimmed }
|
|
return baseDomain + "/" + trimmed
|
|
}
|
|
|
|
// MARK: - Date Parsing
|
|
|
|
private static let iso8601Formatter: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return f
|
|
}()
|
|
|
|
private static let iso8601NoFrac: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime]
|
|
return f
|
|
}()
|
|
|
|
private static let simpleDateFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
return f
|
|
}()
|
|
|
|
private static let cfmlDateFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "MMMM, dd yyyy HH:mm:ss Z"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
return f
|
|
}()
|
|
|
|
private static let cfmlShortFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
return f
|
|
}()
|
|
|
|
private static let cfmlAltFormatters: [DateFormatter] = {
|
|
let formats = [
|
|
"MMM dd, yyyy HH:mm:ss",
|
|
"MM/dd/yyyy HH:mm:ss",
|
|
"yyyy-MM-dd HH:mm:ss.S",
|
|
"yyyy-MM-dd'T'HH:mm:ss.S",
|
|
"yyyy-MM-dd'T'HH:mm:ssZ",
|
|
"yyyy-MM-dd'T'HH:mm:ss.SZ",
|
|
]
|
|
return formats.map { fmt in
|
|
let f = DateFormatter()
|
|
f.dateFormat = fmt
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
return f
|
|
}
|
|
}()
|
|
|
|
nonisolated static func parseDate(_ string: String) -> Date? {
|
|
let s = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if s.isEmpty { return nil }
|
|
|
|
// Try epoch (milliseconds or seconds)
|
|
if let epoch = Double(s) {
|
|
if epoch > 1_000_000_000_000 { return Date(timeIntervalSince1970: epoch / 1000) }
|
|
if epoch > 1_000_000_000 { return Date(timeIntervalSince1970: epoch) }
|
|
}
|
|
|
|
if let d = iso8601Formatter.date(from: s) { return d }
|
|
if let d = iso8601NoFrac.date(from: s) { return d }
|
|
if let d = simpleDateFormatter.date(from: s) { return d }
|
|
if let d = cfmlDateFormatter.date(from: s) { return d }
|
|
if let d = cfmlShortFormatter.date(from: s) { return d }
|
|
for formatter in cfmlAltFormatters {
|
|
if let d = formatter.date(from: s) { return d }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - String Parsing Helper
|
|
|
|
/// Parse any value to a string safely
|
|
private func parseString(_ value: Any?) -> String {
|
|
guard let v = value else { return "" }
|
|
if let s = v as? String { return s }
|
|
if let n = v as? NSNumber { return n.stringValue }
|
|
if let i = v as? Int { return String(i) }
|
|
if let d = v as? Double { return String(d) }
|
|
return String(describing: v)
|
|
}
|
|
|
|
// MARK: - About Info Models
|
|
|
|
struct AboutInfo {
|
|
let description: String
|
|
let features: [AboutFeature]
|
|
let contacts: [AboutContact]
|
|
let copyright: String
|
|
}
|
|
|
|
struct AboutFeature {
|
|
let icon: String
|
|
let title: String
|
|
let description: String
|
|
}
|
|
|
|
struct AboutContact {
|
|
let icon: String
|
|
let label: String
|
|
let url: String
|
|
}
|
|
|
|
|