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 private let profileCachePrefix = "api:profile:" private let ordersCachePrefix = "api:orders:" private let favoritesCachePrefix = "api:favorites:" private let publicCategoriesCacheKey = "api:public-categories" 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() AppContentCache.shared.invalidate(prefix: profileCachePrefix) AppContentCache.shared.invalidate(prefix: ordersCachePrefix) AppContentCache.shared.invalidate(prefix: favoritesCachePrefix) AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey) NotificationCenter.default.post(name: .sessionExpired, object: message) } private func invalidateFavoritesCache() { AppContentCache.shared.invalidate(prefix: profileCachePrefix) AppContentCache.shared.invalidate(prefix: favoritesCachePrefix) } private func scopedCacheSuffix() -> String { let jwt = tokenStore.jwt ?? "anonymous" if jwt.count <= 16 { return jwt } return String(jwt.prefix(16)) } // 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(forceRefresh: Bool = false) async throws -> ApiEnvelope { let cacheKey = "\(profileCachePrefix)\(scopedCacheSuffix())" if forceRefresh == false, let cached: ApiEnvelope = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope.self) { return cached } let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true) let envelope: ApiEnvelope = try await sendEnvelope(req) if envelope.error == false, envelope.result != nil { AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours) } return envelope } func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ProfilePatchEnvelope { let payload = CustomerIdentityUpdatePayload( name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : name, email: email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : email, phoneNumber: phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : phoneNumber, profilePicture: profilePicture ) let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body) let envelope: ProfilePatchEnvelope = try await send(req) AppContentCache.shared.invalidate(prefix: profileCachePrefix) return envelope } 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(forceRefresh: true) 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(forceRefresh: true) 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) let envelope: ApiEnvelope = try await sendEnvelope(req) let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())" if envelope.error == false, envelope.result != nil { AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) } else { invalidateFavoritesCache() } return envelope } func listFavoriteStores(forceRefresh: Bool = false) async throws -> ApiEnvelope<[StoreSummary]> { let cacheKey = "\(favoritesCachePrefix)\(scopedCacheSuffix())" if forceRefresh == false, let cached: ApiEnvelope<[StoreSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[StoreSummary]>.self) { return cached } let req = ApiRequest(path: "/api/customer/favorites", method: "GET", module: .customer, requiresAuth: true) let envelope: ApiEnvelope<[StoreSummary]> = try await sendEnvelope(req) if envelope.error == false, envelope.result != nil { AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours) } return envelope } func addStoreToFavorites(storeId: String) async throws -> ApiEnvelope { let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "POST", module: .customer, requiresAuth: true) let envelope: ApiEnvelope = try await sendEnvelope(req) if envelope.error == false { invalidateFavoritesCache() } return envelope } func removeStoreFromFavorites(storeId: String) async throws -> ApiEnvelope { let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "DELETE", module: .customer, requiresAuth: true) let envelope: ApiEnvelope = try await sendEnvelope(req) if envelope.error == false { invalidateFavoritesCache() } return envelope } func setStoreFavorite(storeId: String, isFavorite: Bool) async throws -> ApiEnvelope { if isFavorite { return try await addStoreToFavorites(storeId: storeId) } return try await removeStoreFromFavorites(storeId: storeId) } 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(forceRefresh: Bool = false) async throws -> ApiEnvelope<[PublicCategory]> { if forceRefresh == false, let cached: ApiEnvelope<[PublicCategory]> = AppContentCache.shared.value(for: publicCategoriesCacheKey, as: ApiEnvelope<[PublicCategory]>.self) { return cached } let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false) let envelope: ApiEnvelope<[PublicCategory]> = try await sendEnvelope(req) if envelope.error == false { AppContentCache.shared.set(envelope, for: publicCategoriesCacheKey, ttl: AppCacheTTL.twoHours) } return envelope } 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) } func createOrder(storeId: String, payload: CreateOrderPayload) async throws -> ApiEnvelope { let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/store/\(storeId)/orders", method: "POST", module: .store, requiresAuth: true, body: body) let envelope: ApiEnvelope = try await sendEnvelope(req) if envelope.error == false { AppContentCache.shared.invalidate(prefix: ordersCachePrefix) } return envelope } func validateDeliveryAddress(storeId: String, payload: ValidateDeliveryAddressPayload) async throws -> ApiEnvelope { let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/store/\(storeId)/delivery/validate-address", method: "POST", module: .store, requiresAuth: true, body: body) return try await sendEnvelope(req) } func listOrders(forceRefresh: Bool = false) async throws -> ApiEnvelope<[AppOrderSummary]> { let cacheKey = "\(ordersCachePrefix)\(scopedCacheSuffix())" if forceRefresh == false, let cached: ApiEnvelope<[AppOrderSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[AppOrderSummary]>.self) { return cached } let req = ApiRequest( path: "/api/app/orders", method: "GET", module: .app, requiresAuth: true, queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))] ) let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req) if envelope.error == false { AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours) } return envelope } func publicOrder(orderId: String) async throws -> ApiEnvelope { let req = ApiRequest( path: "/api/public/orders/\(orderId)", method: "GET", module: .none, requiresAuth: true, queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))] ) return try await sendEnvelope(req) } func submitOrderReview(orderId: String, payload: SubmitOrderReviewPayload) async throws -> ApiEnvelope { let body = try JSONEncoder().encode(payload) let req = ApiRequest( path: "/api/public/orders/\(orderId)/review", method: "POST", module: .none, requiresAuth: true, body: body ) return try await sendEnvelope(req) } func reviewTagsCatalog() async throws -> ApiEnvelope { let req = ApiRequest( path: "/api/public/reviews/tags", method: "GET", module: .none, requiresAuth: false ) return try await sendEnvelope(req) } func publicStoreReviews(storeId: String) async throws -> ApiEnvelope { let req = ApiRequest( path: "/api/public/store/\(storeId)/reviews", method: "GET", module: .none, requiresAuth: false, queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))] ) return try await sendEnvelope(req) } // MARK: - Cards func listCards() async throws -> ApiEnvelope<[SavedCard]> { let req = ApiRequest(path: "/api/customer/cards", method: "GET", module: .customer, requiresAuth: true) return try await sendEnvelope(req) } func saveCard(payload: SaveCardPayload) async throws -> ApiEnvelope { let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer/cards", method: "POST", module: .customer, requiresAuth: true, body: body) return try await sendEnvelope(req) } func updateCard(cardId: String, payload: UpdateCardPayload) async throws -> ApiEnvelope { let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "PATCH", module: .customer, requiresAuth: true, body: body) return try await sendEnvelope(req) } func deleteCard(cardId: String) async throws -> ApiEnvelope { let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "DELETE", module: .customer, requiresAuth: true) return try await sendEnvelope(req) } func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope { let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body) return try await sendEnvelope(req) } // MARK: - Profile CPF func updateProfileCpf(cpf: String) async throws -> ApiEnvelope { let payload = ["cpf": cpf] let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body) let result: ApiEnvelope = try await sendEnvelope(req) if result.error == false { AppContentCache.shared.invalidate(prefix: profileCachePrefix) } return result } } extension ApiService { static func decodeFlexibleString(from container: KeyedDecodingContainer, keys: [K]) -> String? { for key in keys { if let value = try? container.decode(String.self, forKey: key) { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty == false { return trimmed } } if let asInt = try? container.decode(Int.self, forKey: key) { return String(asInt) } if let asDouble = try? container.decode(Double.self, forKey: key) { if asDouble.rounded() == asDouble { return String(Int(asDouble)) } return String(asDouble) } } return nil } 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 } static func decodeFlexibleInt(from container: KeyedDecodingContainer, keys: [K]) -> Int? { for key in keys { if let value = try? container.decode(Int.self, forKey: key) { return value } if let asDouble = try? container.decode(Double.self, forKey: key) { return Int(asDouble) } if let asString = try? container.decode(String.self, forKey: key) { let normalized = asString .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: ".", with: "") .replacingOccurrences(of: ",", with: "") if let parsed = Int(normalized) { return parsed } } } return nil } }