migration
This commit is contained in:
650
PediFoods/Services/ApiService.swift
Normal file
650
PediFoods/Services/ApiService.swift
Normal file
@@ -0,0 +1,650 @@
|
||||
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<T: Decodable & Sendable>: Decodable, Sendable {
|
||||
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<T: Decodable & Sendable>(_ req: ApiRequest) async throws -> T {
|
||||
do {
|
||||
return try await client.send(req)
|
||||
} catch let error as NetworkError {
|
||||
if canTriggerSessionExpiry(for: req), case .unauthorized(let message) = error {
|
||||
expireSession(message)
|
||||
throw ApiServiceError.sessionExpired(message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func sendEnvelope<T: Decodable>(_ req: ApiRequest) async throws -> ApiEnvelope<T> {
|
||||
let envelope: ApiEnvelope<T> = try await send(req)
|
||||
if canTriggerSessionExpiry(for: req), isSessionExpiredEnvelope(envelope) {
|
||||
expireSession(envelope.message)
|
||||
throw ApiServiceError.sessionExpired(envelope.message)
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
/// A request that never carried the customer JWT (public/unauthenticated
|
||||
/// calls) can never mean *the customer's* session expired — an unrelated
|
||||
/// error (e.g. Atomenta's module-token check) must not force-logout a
|
||||
/// user, anonymous or not, just because its error code happens to
|
||||
/// contain the substring "token". See app-migrate-atomenta-calls-to-pedifoods-bff.md.
|
||||
private func canTriggerSessionExpiry(for req: ApiRequest) -> Bool {
|
||||
req.requiresAuth && tokenStore.jwt != nil
|
||||
}
|
||||
|
||||
private func isSessionExpiredEnvelope<T>(_ envelope: ApiEnvelope<T>) -> 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)
|
||||
}
|
||||
|
||||
/// Every successful profile mutation must leave `profile()`'s cache
|
||||
/// holding the server's authoritative post-mutation state — never
|
||||
/// patched locally from a write-response of possibly different shape,
|
||||
/// and never left merely invalidated for some future caller to lazily
|
||||
/// refetch (which may never happen, leaving stale data visible
|
||||
/// indefinitely within the TTL). Always does a real GET.
|
||||
@discardableResult
|
||||
private func refreshProfileCache() async -> ApiEnvelope<CustomerProfile>? {
|
||||
try? await profile(forceRefresh: true)
|
||||
}
|
||||
|
||||
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<RegistrationResult> {
|
||||
var payload: [String: String] = [
|
||||
"name": name,
|
||||
"email": email,
|
||||
"phoneNumber": phoneNumber
|
||||
]
|
||||
if let birthDate, birthDate.isEmpty == false {
|
||||
payload["birthDate"] = birthDate
|
||||
}
|
||||
// If the visitor picked a state/city via the public locator before
|
||||
// signing up, forward it so the backend can set it as the account's
|
||||
// default city. NOTE: as of this writing Atomenta's customer create
|
||||
// controller only reads name/email/phoneNumber — these two fields
|
||||
// are a no-op server-side until that controller is updated to
|
||||
// persist them (see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md).
|
||||
if let state = GuestLocationStore.shared.selectedState, let city = GuestLocationStore.shared.selectedCity {
|
||||
payload["defaultState"] = state
|
||||
payload["defaultCity"] = city
|
||||
}
|
||||
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<EmptyResult> {
|
||||
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<LoginResult> {
|
||||
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<LoginResult> = 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<CustomerProfile> {
|
||||
let cacheKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
|
||||
if forceRefresh == false,
|
||||
let cached: ApiEnvelope<CustomerProfile> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<CustomerProfile>.self) {
|
||||
return cached
|
||||
}
|
||||
|
||||
let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true)
|
||||
let envelope: ApiEnvelope<CustomerProfile> = 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)
|
||||
if envelope.error == false {
|
||||
await refreshProfileCache()
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
/// See docs/api/push-notifications-integration-guide.md §2b — only reachable
|
||||
/// via `POST /api/customer/:id` today, not `PATCH /profile`.
|
||||
func updateNotificationsEnabled(_ enabled: Bool) async throws -> ApiEnvelope<CustomerProfile> {
|
||||
let currentProfile = try await profile(forceRefresh: true)
|
||||
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
let payload = CustomerNotificationsUpdatePayload(notificationsEnabled: enabled)
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
|
||||
if envelope.error == false {
|
||||
await refreshProfileCache()
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
/// Persists the biometric-login preference only — no LocalAuthentication
|
||||
/// wiring yet, that's a separate later plan.
|
||||
func updateFaceIdEnabled(_ enabled: Bool) async throws -> ApiEnvelope<CustomerProfile> {
|
||||
let currentProfile = try await profile(forceRefresh: true)
|
||||
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
let payload = CustomerFaceIdUpdatePayload(faceIdEnabled: enabled)
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
|
||||
if envelope.error == false {
|
||||
await refreshProfileCache()
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
/// See docs/api/push-notifications-integration-guide.md §2.
|
||||
func registerPushToken(_ token: String, deviceId: String, deviceOS: String) async throws -> ApiEnvelope<EmptyResult> {
|
||||
let currentProfile = try await profile(forceRefresh: true)
|
||||
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
let payload = CustomerPushTokenPayload(pushToken: token, deviceId: deviceId, deviceOS: deviceOS)
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/customer/\(customer.id)/push-token", method: "PUT", module: .customer, requiresAuth: true, body: body)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
/// See docs/api/push-notifications-integration-guide.md §2a. Wholesale
|
||||
/// replace, not a merge — callers must pass every `attributes` key they
|
||||
/// still want kept, not just the changed ones.
|
||||
func updateCustomerAttributes(appVersion: String?, attributes: [String: String]?) async throws -> ApiEnvelope<EmptyResult> {
|
||||
let currentProfile = try await profile(forceRefresh: true)
|
||||
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
let payload = CustomerAttributesUpdatePayload(appVersion: appVersion, attributes: attributes)
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/customer/\(customer.id)/attributes", method: "PUT", module: .customer, requiresAuth: true, body: body)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
/// See docs/api/push-notifications-integration-guide.md §6a. Fire on tap
|
||||
/// only, for `type: "campaign"` pushes — idempotent server-side.
|
||||
func reportPushCampaignOpened(campaignId: String) async throws -> ApiEnvelope<PushCampaignOpenedResult> {
|
||||
let currentProfile = try await profile(forceRefresh: true)
|
||||
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
let payload = PushCampaignOpenedPayload(campaignId: campaignId)
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/customer/\(customer.id)/push-campaigns/opened", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
|
||||
return try await saveCustomerAddress(address, replacingAddressId: nil)
|
||||
}
|
||||
|
||||
func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope<CustomerProfile> {
|
||||
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<CustomerProfile> {
|
||||
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 setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
|
||||
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
|
||||
let envelope: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
|
||||
if envelope.error == false {
|
||||
await refreshProfileCache()
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> {
|
||||
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<CustomerProfile> {
|
||||
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<CustomerProfile> = try await sendEnvelope(req)
|
||||
if envelope.error == false {
|
||||
await refreshProfileCache()
|
||||
} 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<CustomerFavoritesMutationResult> {
|
||||
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "POST", module: .customer, requiresAuth: true)
|
||||
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
|
||||
if envelope.error == false {
|
||||
invalidateFavoritesCache()
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
func removeStoreFromFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
|
||||
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "DELETE", module: .customer, requiresAuth: true)
|
||||
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
|
||||
if envelope.error == false {
|
||||
invalidateFavoritesCache()
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
func setStoreFavorite(storeId: String, isFavorite: Bool) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
|
||||
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
|
||||
}
|
||||
|
||||
// Open endpoint, no guest session needed, but it lives on the BFF
|
||||
// domain (pedifoods.com.br), not Atomenta — see
|
||||
// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
|
||||
let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false, baseURLOverride: ApiConfig.pediFoodsBFFURL)
|
||||
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<StoreInfoResult> {
|
||||
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<CreateOrderResult> {
|
||||
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<CreateOrderResult> = try await sendEnvelope(req)
|
||||
if envelope.error == false {
|
||||
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
func validateDeliveryAddress(storeId: String, payload: ValidateDeliveryAddressPayload) async throws -> ApiEnvelope<ValidateDeliveryAddressResult> {
|
||||
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<PublicOrderResult> {
|
||||
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<SubmitOrderReviewResult> {
|
||||
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<ReviewTagsCatalog> {
|
||||
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<PublicStoreReviewsResult> {
|
||||
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<SavedCardResult> {
|
||||
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<SavedCardResult> {
|
||||
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<EmptyResult> {
|
||||
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "DELETE", module: .customer, requiresAuth: true)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func deleteAccount() async throws -> ApiEnvelope<EmptyResult> {
|
||||
let req = ApiRequest(path: "/api/customer/account", method: "DELETE", module: .customer, requiresAuth: true)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope<ChangePaymentMethodResult> {
|
||||
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<EmptyResult> {
|
||||
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<EmptyResult> = try await sendEnvelope(req)
|
||||
if result.error == false {
|
||||
await refreshProfileCache()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
extension ApiService {
|
||||
static func decodeFlexibleString<K: CodingKey>(from container: KeyedDecodingContainer<K>, 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<K: CodingKey>(from container: KeyedDecodingContainer<K>, 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<K: CodingKey>(from container: KeyedDecodingContainer<K>, 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user