[push/opt-in-prompts] Implement push notifications client integration
Build the full client half of docs/api/push-notifications-integration-guide.md: OS permission + APNs device-token registration and pipeline wiring, profile notifications/biometric-login toggles on the Ver Perfil screen reflecting server truth, order-tracking opt-in fallback prompt, profile-cache refresh on every mutation, a Notification Service Extension for rich/image push, the Push Notifications capability, targeting-attributes sync, campaign open tracking, and tap-to-order deep linking with foreground notification display.
This commit is contained in:
@@ -88,6 +88,17 @@ final class ApiService {
|
||||
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 }
|
||||
@@ -184,10 +195,90 @@ final class ApiService {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -234,7 +325,9 @@ final class ApiService {
|
||||
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)
|
||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||
if envelope.error == false {
|
||||
await refreshProfileCache()
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
@@ -259,9 +352,8 @@ final class ApiService {
|
||||
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)
|
||||
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
|
||||
if envelope.error == false, envelope.result != nil {
|
||||
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
||||
if envelope.error == false {
|
||||
await refreshProfileCache()
|
||||
} else {
|
||||
invalidateFavoritesCache()
|
||||
}
|
||||
@@ -488,7 +580,7 @@ final class ApiService {
|
||||
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 {
|
||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||
await refreshProfileCache()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user