import Foundation enum ApiServiceError: Error, LocalizedError { case sessionExpired(String?) var errorDescription: String? { switch self { case .sessionExpired(let message): return message ?? "Sessao expirada. Faca login novamente." } } } struct ApiEnvelope: Decodable { let error: Bool let code: String? let message: String? let result: T? } final class ApiService { private let client: ApiClient private var tokenStore: TokenStore init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) { self.client = client self.tokenStore = tokenStore } private func send(_ req: ApiRequest) async throws -> T { do { return try await client.send(req) } catch let error as NetworkError { if case .unauthorized(let message) = error { expireSession(message) throw ApiServiceError.sessionExpired(message) } throw error } } private func sendEnvelope(_ req: ApiRequest) async throws -> ApiEnvelope { let envelope: ApiEnvelope = try await send(req) if isSessionExpiredEnvelope(envelope) { expireSession(envelope.message) throw ApiServiceError.sessionExpired(envelope.message) } return envelope } private func isSessionExpiredEnvelope(_ envelope: ApiEnvelope) -> Bool { guard envelope.error else { return false } let code = (envelope.code ?? "").lowercased() let message = (envelope.message ?? "").lowercased() if code.contains("auth") || code.contains("token") || code.contains("unauthorized") { return true } if message.contains("token") && (message.contains("expir") || message.contains("invalid") || message.contains("sess")) { return true } return false } private func expireSession(_ message: String?) { tokenStore.clear() NotificationCenter.default.post(name: .sessionExpired, object: message) } // MARK: - Auth func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope { var payload: [String: String] = [ "name": name, "email": email, "phoneNumber": phoneNumber ] if let birthDate, birthDate.isEmpty == false { payload["birthDate"] = birthDate } let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body) return try await sendEnvelope(req) } func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope { let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: nil) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) return try await sendEnvelope(req) } func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope { let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: otp) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) let response: ApiEnvelope = try await sendEnvelope(req) if let token = response.result?.token { tokenStore.jwt = token } return response } private func makeLoginBody(email: String, phoneNumber: String, otp: String?) throws -> Data { let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) let sanitizedPhone = phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines) guard sanitizedEmail.isEmpty == false, sanitizedPhone.isEmpty == false else { throw NetworkError.httpError(400, "Email e telefone são obrigatórios.") } var payload: [String: String] = [ "email": sanitizedEmail, "phoneNumber": sanitizedPhone, "phone": sanitizedPhone ] if let otp, otp.isEmpty == false { payload["otp"] = otp } guard JSONSerialization.isValidJSONObject(payload) else { throw NetworkError.invalidResponse } return try JSONSerialization.data(withJSONObject: payload, options: []) } func profile() async throws -> ApiEnvelope { let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true) return try await sendEnvelope(req) } func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope { return try await saveCustomerAddress(address, replacingAddressId: nil) } func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope { let currentProfile = try await profile() guard currentProfile.error == false, let customer = currentProfile.result else { throw NetworkError.invalidResponse } let currentAddressBook = customer.addressBook ?? [] var addressBook = currentAddressBook.map(CustomerAddressPayload.init(from:)) let addressPayload = CustomerAddressPayload(from: address) if let replacingAddressId, let replaceIndex = currentAddressBook.firstIndex(where: { $0.id == replacingAddressId }) { addressBook[replaceIndex] = addressPayload } else { addressBook.insert(addressPayload, at: 0) } return try await updateCustomerAddressBook(customerId: customer.id, addressBook: addressBook) } func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope { let currentProfile = try await profile() guard currentProfile.error == false, let customer = currentProfile.result else { throw NetworkError.invalidResponse } var currentAddressBook = customer.addressBook ?? [] if let targetId = address.id, targetId.isEmpty == false { if let index = currentAddressBook.firstIndex(where: { $0.id == targetId }) { currentAddressBook.remove(at: index) } } else if let index = currentAddressBook.firstIndex(where: { matchesAddress($0, address) }) { currentAddressBook.remove(at: index) } let updatedAddressBook = currentAddressBook.map(CustomerAddressPayload.init(from:)) return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook) } func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope { let digits = zipCode.filter(\.isNumber) let normalized = String(digits.prefix(8)) let formatted: String if normalized.count == 8 { let prefix = String(normalized.prefix(5)) let suffix = String(normalized.dropFirst(5)) formatted = "\(prefix)-\(suffix)" } else { formatted = normalized } let req = ApiRequest(path: "/api/public/cep/\(formatted)", method: "GET", module: .none, requiresAuth: true) return try await sendEnvelope(req) } private func updateCustomerAddressBook(customerId: String, addressBook: [CustomerAddressPayload]) async throws -> ApiEnvelope { let payload = CustomerProfileUpdatePayload(addressBook: addressBook) let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body) return try await sendEnvelope(req) } private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool { lhs.label == rhs.label && lhs.address == rhs.address && lhs.number == rhs.number && lhs.complement == rhs.complement && lhs.neighborhood == rhs.neighborhood && lhs.city == rhs.city && lhs.state == rhs.state && lhs.zipCode == rhs.zipCode } // MARK: - Stores func listPublicCategories() async throws -> ApiEnvelope<[PublicCategory]> { let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false) return try await sendEnvelope(req) } func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> { var items: [URLQueryItem] = [] if let lat, let lng { items.append(URLQueryItem(name: "lat", value: String(lat))) items.append(URLQueryItem(name: "lng", value: String(lng))) } if let category { items.append(URLQueryItem(name: "category", value: category)) } if let search { items.append(URLQueryItem(name: "search", value: search)) } let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items) return try await sendEnvelope(req) } func storeInfo(storeId: String) async throws -> ApiEnvelope { let req = ApiRequest(path: "/api/store/\(storeId)/info", method: "GET", module: .store, requiresAuth: true) return try await sendEnvelope(req) } func storeCatalog(storeId: String) async throws -> ApiEnvelope<[StoreCatalogCategory]> { let req = ApiRequest(path: "/api/store/\(storeId)/catalog", method: "GET", module: .store, requiresAuth: true) return try await sendEnvelope(req) } } extension ApiService { static func decodeFlexibleDouble(from container: KeyedDecodingContainer, keys: [K]) -> Double? { for key in keys { if let value = try? container.decode(Double.self, forKey: key) { return value } if let asInt = try? container.decode(Int.self, forKey: key) { return Double(asInt) } if let asString = try? container.decode(String.self, forKey: key) { let normalized = asString .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: ",", with: ".") if let parsed = Double(normalized) { return parsed } } } return nil } }