[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:
Daniel Arantes Loverde
2026-08-04 11:33:56 -03:00
parent 32f12d6c0e
commit 7dd4cda1a4
20 changed files with 943 additions and 12 deletions

View File

@@ -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
}