[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:
@@ -38,6 +38,53 @@ struct CustomerIdentityUpdatePayload: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/customer/:id` — partial update, sibling of `CustomerProfileUpdatePayload`.
|
||||
/// See docs/api/push-notifications-integration-guide.md §2b.
|
||||
struct CustomerNotificationsUpdatePayload: Encodable {
|
||||
let notificationsEnabled: Bool
|
||||
}
|
||||
|
||||
/// `POST /api/customer/:id` — biometric-login preference. Persistence only for
|
||||
/// now; the actual Face ID/Touch ID unlock flow is a separate, later plan.
|
||||
struct CustomerFaceIdUpdatePayload: Encodable {
|
||||
let faceIdEnabled: Bool
|
||||
}
|
||||
|
||||
/// `PUT /api/customer/:id/push-token` — see docs/api/push-notifications-integration-guide.md §2.
|
||||
struct CustomerPushTokenPayload: Encodable {
|
||||
let pushToken: String
|
||||
let deviceId: String
|
||||
let deviceOS: String
|
||||
}
|
||||
|
||||
/// `PUT /api/customer/:id/attributes` — wholesale replace, not a merge.
|
||||
/// See docs/api/push-notifications-integration-guide.md §2a.
|
||||
struct CustomerAttributesUpdatePayload: Encodable {
|
||||
let appVersion: String?
|
||||
let attributes: [String: String]?
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encodeIfPresent(appVersion, forKey: .appVersion)
|
||||
try container.encodeIfPresent(attributes, forKey: .attributes)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case appVersion
|
||||
case attributes
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/customer/:id/push-campaigns/opened` — see
|
||||
/// docs/api/push-notifications-integration-guide.md §6a.
|
||||
struct PushCampaignOpenedPayload: Encodable {
|
||||
let campaignId: String
|
||||
}
|
||||
|
||||
struct PushCampaignOpenedResult: Decodable {
|
||||
let recorded: Bool
|
||||
}
|
||||
|
||||
struct CustomerAddressPayload: Encodable {
|
||||
let label: String?
|
||||
let address: String?
|
||||
|
||||
@@ -30,6 +30,8 @@ struct CustomerProfile: Decodable {
|
||||
let profilePicture: String?
|
||||
let favorites: [String]?
|
||||
let addressBook: [CustomerAddress]?
|
||||
let notificationsEnabled: Bool?
|
||||
let faceIdEnabled: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
@@ -39,6 +41,8 @@ struct CustomerProfile: Decodable {
|
||||
case profilePicture
|
||||
case favorites
|
||||
case addressBook = "address_book"
|
||||
case notificationsEnabled
|
||||
case faceIdEnabled
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
215
Sources/PediFoods/Services/PushNotificationCoordinator.swift
Normal file
215
Sources/PediFoods/Services/PushNotificationCoordinator.swift
Normal file
@@ -0,0 +1,215 @@
|
||||
import Foundation
|
||||
|
||||
/// Client-side half of docs/api/push-notifications-integration-guide.md.
|
||||
/// Owns OS permission state, APNs device-token registration, the
|
||||
/// "enable notifications" action shared by the profile toggle and the
|
||||
/// order-tracking fallback prompt (§2b), foreground/tap notification
|
||||
/// handling (§6), and campaign open tracking (§6a).
|
||||
enum PushAuthorizationState {
|
||||
case authorized
|
||||
case denied
|
||||
case notDetermined
|
||||
}
|
||||
|
||||
/// Posted when the user taps an `order_status` push (§6) so `ContentView`
|
||||
/// can route to that order without this service depending on `AppState`.
|
||||
extension Notification.Name {
|
||||
static let pushTappedOrderStatus = Notification.Name("pushTappedOrderStatus")
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class PushNotificationCoordinator: NSObject {
|
||||
static let shared = PushNotificationCoordinator()
|
||||
|
||||
private var deviceTokenObserver: NSObjectProtocol?
|
||||
private var didBecomeDelegate = false
|
||||
|
||||
private override init() {}
|
||||
|
||||
/// Call once at app launch. Listens for the device token `PediFoodsAppDelegate`
|
||||
/// posts after `registerForRemoteNotifications()` resolves, and forwards it
|
||||
/// to Atomenta (§2).
|
||||
func startObservingDeviceToken() {
|
||||
guard deviceTokenObserver == nil else { return }
|
||||
deviceTokenObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { notification in
|
||||
guard let data = notification.userInfo?["deviceToken"] as? Data else { return }
|
||||
let hexToken = data.map { String(format: "%02x", $0) }.joined()
|
||||
Task { await PushNotificationCoordinator.shared.sendTokenToBackend(hexToken) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Call once at app launch, before the first notification could possibly
|
||||
/// arrive — makes this the `UNUserNotificationCenterDelegate` so foreground
|
||||
/// pushes actually display (§6) and taps get routed/tracked (§6, §6a).
|
||||
func becomeNotificationCenterDelegate() {
|
||||
guard didBecomeDelegate == false else { return }
|
||||
didBecomeDelegate = true
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
|
||||
/// Re-registers silently (no OS prompt) if the user already granted
|
||||
/// authorization in a previous session — tokens aren't guaranteed stable
|
||||
/// across launches (§2, §3.3/§4.2 of the guide). Safe to call before login.
|
||||
func refreshRegistrationIfAuthorized() async {
|
||||
guard await currentAuthorizationState() == .authorized else { return }
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
func currentAuthorizationState() async -> PushAuthorizationState {
|
||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
return .authorized
|
||||
case .denied:
|
||||
return .denied
|
||||
case .notDetermined:
|
||||
return .notDetermined
|
||||
@unknown default:
|
||||
return .notDetermined
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the OS permission dialog only if it hasn't been answered yet.
|
||||
/// Always calls `registerForRemoteNotifications()` when authorized —
|
||||
/// including when authorization was already granted in a past session —
|
||||
/// so "enable" reliably produces a fresh device token this run instead of
|
||||
/// relying solely on the once-per-launch refresh.
|
||||
@discardableResult
|
||||
private func requestAuthorizationIfNeeded() async -> Bool {
|
||||
switch await currentAuthorizationState() {
|
||||
case .authorized:
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
return true
|
||||
case .denied:
|
||||
return false
|
||||
case .notDetermined:
|
||||
let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false
|
||||
if granted {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
return granted
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared body of §2b's two touchpoints: request OS permission (if
|
||||
/// undetermined), then flip Atomenta's `notificationsEnabled` flag.
|
||||
/// Returns the server's authoritative post-update profile — callers must
|
||||
/// reflect `result.notificationsEnabled` from this, not assume `true`
|
||||
/// just because the request succeeded.
|
||||
func enableNotifications() async -> CustomerProfile? {
|
||||
guard await requestAuthorizationIfNeeded() else { return nil }
|
||||
do {
|
||||
let response = try await ApiService().updateNotificationsEnabled(true)
|
||||
return response.error == false ? response.result : nil
|
||||
} catch {
|
||||
logger.error("Failed to enable push notifications: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// See docs/api/push-notifications-integration-guide.md §2a. Best-effort,
|
||||
/// silent on failure — Campaign `appVersion`/`attributes` targeting just
|
||||
/// won't match this user until the next successful call. Call right after
|
||||
/// login and once per app launch (covers an app update since last launch).
|
||||
func syncCustomerAttributes() async {
|
||||
guard DefaultTokenStore().jwt != nil else { return }
|
||||
do {
|
||||
_ = try await ApiService().updateCustomerAttributes(appVersion: currentAppVersion(), attributes: nil)
|
||||
} catch {
|
||||
logger.error("Failed to sync customer attributes: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func currentAppVersion() -> String {
|
||||
(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "0.0.0"
|
||||
}
|
||||
|
||||
private func sendTokenToBackend(_ hexToken: String) async {
|
||||
guard DefaultTokenStore().jwt != nil else { return }
|
||||
do {
|
||||
_ = try await ApiService().registerPushToken(hexToken, deviceId: GuestLocationStore.shared.deviceId, deviceOS: "ios")
|
||||
} catch {
|
||||
logger.error("Push token registration failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// §6a — fire on tap only, for `type: "campaign"` pushes. Idempotent
|
||||
/// server-side, so no client-side "already reported" guard needed.
|
||||
private func reportCampaignOpened(campaignId: String) async {
|
||||
guard DefaultTokenStore().jwt != nil else { return }
|
||||
do {
|
||||
_ = try await ApiService().reportPushCampaignOpened(campaignId: campaignId)
|
||||
} catch {
|
||||
logger.error("Failed to report campaign open: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// §6 — routes on the tapped push's `data` payload. `order_status` gets
|
||||
/// forwarded to `ContentView` via `NotificationCenter` (this service has
|
||||
/// no `AppState` binding of its own); `campaign` self-reports its open.
|
||||
fileprivate func handleTap(userInfo: [AnyHashable: Any]) {
|
||||
guard let type = userInfo["type"] as? String else { return }
|
||||
switch type {
|
||||
case "campaign":
|
||||
guard let campaignId = userInfo["campaignId"] as? String else { return }
|
||||
Task { await reportCampaignOpened(campaignId: campaignId) }
|
||||
case "order_status":
|
||||
guard let orderId = userInfo["orderId"] as? String else { return }
|
||||
let shortId = userInfo["shortId"] as? String
|
||||
NotificationCenter.default.post(
|
||||
name: .pushTappedOrderStatus,
|
||||
object: nil,
|
||||
userInfo: ["orderId": orderId, "shortId": shortId as Any]
|
||||
)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PushNotificationCoordinator: UNUserNotificationCenterDelegate {
|
||||
/// Without a delegate, iOS silently drops push notifications while the
|
||||
/// app is foregrounded — this is what makes them display as a banner too.
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
completionHandler([.banner, .list, .sound, .badge])
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
Task { @MainActor in
|
||||
PushNotificationCoordinator.shared.handleTap(userInfo: userInfo)
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
#else
|
||||
@MainActor
|
||||
final class PushNotificationCoordinator {
|
||||
static let shared = PushNotificationCoordinator()
|
||||
private init() {}
|
||||
|
||||
func startObservingDeviceToken() {}
|
||||
func becomeNotificationCenterDelegate() {}
|
||||
func refreshRegistrationIfAuthorized() async {}
|
||||
func currentAuthorizationState() async -> PushAuthorizationState { .denied }
|
||||
func enableNotifications() async -> CustomerProfile? { nil }
|
||||
func syncCustomerAttributes() async {}
|
||||
}
|
||||
#endif
|
||||
@@ -88,6 +88,19 @@ enum SessionStateStore {
|
||||
private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1."
|
||||
private static let orderReviewsKeyPrefix = "session.orders.reviews.v1."
|
||||
private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.v1."
|
||||
private static let pushOptInPromptKey = "session.push.opt-in.last-prompted.v1"
|
||||
private static let pushOptInCooldown: TimeInterval = 60 * 60 * 24
|
||||
|
||||
/// See docs/api/push-notifications-integration-guide.md §2b — avoid
|
||||
/// re-prompting the order-tracking fallback alert on every screen visit.
|
||||
static func shouldPromptPushOptIn() -> Bool {
|
||||
guard let last = UserDefaults.standard.object(forKey: pushOptInPromptKey) as? Date else { return true }
|
||||
return Date().timeIntervalSince(last) > pushOptInCooldown
|
||||
}
|
||||
|
||||
static func recordPushOptInPrompted() {
|
||||
UserDefaults.standard.set(Date(), forKey: pushOptInPromptKey)
|
||||
}
|
||||
|
||||
static func makeUserKey(profileId: String?, email: String?) -> String? {
|
||||
let id = (profileId ?? "")
|
||||
|
||||
Reference in New Issue
Block a user