Repository navigation
fix: rebuild push notifications on a single storefront push channel - #102
Merged
Merged
Conversation
Android pushes never worked with store-level FCM credentials and some iOS devices were rejected, because of several stacked defects: - configureFcm put the service account JSON into credentials.private_key, so no valid Firebase client was ever built from a store channel - FcmChannel required the platform-wide Firebase project to be configured - credentials were always resolved from the store, never the network app - one APNs environment per channel rejected sandbox/dev build tokens - registerDevice never reassigned a token to the latest customer and did not normalize platform casing - mail/database errors ran before push and stopped it; failures and dead tokens were never handled or logged Introduce Push\StorefrontPushChannel with isolated Firebase/APNs clients, network-first credential resolution with wrong-app and wrong-environment retries, dead token pruning, and high priority payloads. Order notifications share a StorefrontOrderNotification base class. Add a customers/unregister-device endpoint and an admin test push action.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #102 +/- ##
===========================================
Coverage 100.00% 100.00%
- Complexity 1775 1867 +92
===========================================
Files 135 144 +9
Lines 7785 7755 -30
===========================================
- Hits 7785 7755 -30
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
6 of 16 tasks
Codecov requires full patch coverage. Cover the FCM and APNs transport send paths, PushMessage setters, APNs environment short-circuit, explicit push routes, and pruning/logging failure handling. Read device attributes with data_get so devices returned by a custom push route do not need to be Eloquent models, and drop two unreachable branches.
This was referenced Sep 26, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Push notifications were not reaching Android devices and some iOS devices. The cause was several separate bugs in how Storefront built push clients, picked credentials and registered devices. This PR replaces the per-notification APNs/FCM code with a single push channel.
Root causes fixed
PushNotification::configureFcmput the entire Firebase service account JSON intocredentials.private_key, on top of the platform-widefirebase.projects.appconfig, so a store's own FCM credentials never built a valid client. Android was broken.Push\FirebaseMessagingFactorydecodes the channel's service account (and repairs escaped\nin the key). It builds aMessagingclient straight from that account and never reads or changesfirebase.projects.*.NotificationChannels\Fcm\FcmChannelneeds the platform-wideMessagingin its constructor, which throws unless the instance also has platform-wide Firebase credentials.FcmChannel/ApnChannel.storefront_id(the store), so marketplace (network) app users got no push, or pushes signed for the wrong app/project.PushCredentialResolver::storefrontsForOrder()prefersstorefront_network_idand falls back to the store. If a provider reports a token as belonging to another app (SenderId mismatch/DeviceTokenNotForTopic), the send is retried on the next candidate channel.->first()picked a channel with no ordering. Tokens from development/sandbox builds were rejected withBadDeviceToken.environment: auto(the new default). The legacyproductionflag now only decides which environment is tried first, and aBadDeviceTokenis retried in the other environment. If a device reports its APNs environment, only that environment is used. Channel order is deterministic.registerDeviceusedfirstOrCreate(token, platform): a token was never moved to a newly logged-in customer, platform casing wasn't normalized (iOSmatched nothing), and there was no validation.platformis validated and normalized. NewPOST customers/unregister-deviceendpoint for logout.mail, database, apn, fcm, so an SMTP ortoArrayerror (e.g. a null company) stopped the push.Storefront::autoAcceptOrderswallowed exceptions without logging.toArrayis null-safe. The swallowed exception is now logged. Listeners guard against orders with no customer.NotificationFailedhandling: dead tokens were never removed and failures were never logged.Unregistered/NotFound/invalid, orBadDeviceTokenin every environment, are markedinvalidand soft-deleted. Every other failure is logged with[Storefront Push]context.android.priority: high(delayed in Doze mode), and APNs messages had no sound.PushMessagerenders high priority, an optionalandroid.channel_id, theapns-push-type/apns-priorityheaders, and sound/badge.Other changes
StorefrontOrderNotification, removing about 1,100 duplicated lines. Database payload:messagenow always holds the human-readable body (it used to be the status code for most types). Addedtype,title,body,order,order_id,store_idandnetwork_id; all existing keys are kept.PromotionalPushNotificationalso writes to thedatabasechannel, so promotions appear in the customer inbox (see the follow-up inbox API). It tries the store's app first, then the apps of the networks the store belongs to.POST storefront/int/v1/notification-channels/{id}/test, plus a Send test button on channel lists (store settings and network page). It sends a test push to a pasted token and shows the raw provider result per environment, so misconfigured credentials can be diagnosed from the console.environmentselect (auto / production / sandbox). FCM drops the unusedfirebase_database_url/firebase_project_namefields and adds an optionalandroid_channel_id. New env varSTOREFRONT_PUSH_ANDROID_CHANNEL_ID.Support\PushNotification(no callers outside this package; fleetops uses core-api's own helper).Related PRs
user_devices.app_identifier,environmentandlast_seen_atcolumns. This PR works without them:registerDeviceonly writes columns that exist.Related Issue
No tracking issue. Reported directly: push notifications not sending on Android and some iOS devices.
Type of Change
Implementation Notes
Push\StorefrontPushChannelgroups a customer's devices by platform (and by the app they registered from, when core-api#276 is deployed). It then walks the candidate channels: device's app → network → store, oldest first. For iOS it also walks the candidate APNs environments. Per-token outcomes come fromTransports\FcmTransport/ApnTransportasPushOutcome(sent / dead / wrong_app / wrong_environment / error), which drives the retries and pruning.Validation
Command output / summary:
composer test:unit -> 477 tests, 3067 assertions, 0 failures (main: 440 tests) composer test:lint -> Found 0 of 261 files that can be fixed pnpm lint -> js/css/intl clean; lint:hbs: 2 errors that already exist on main (addon/components/widget/customers.hbs:35, widget/orders.hbs:49) pnpm build -> production build succeeds composer test:types -> already broken on main: phpstan.neon.dist points at non-existent `src`New or rewritten tests:
server/tests/Unit/Push/PushLayerTest.php,Http/Controllers/CustomerDeviceRegistrationTest.php,Http/Controllers/NotificationChannelTestPushTest.php, andNotifications/NotificationContractsTest.php(the old version asserted the brokencredentials.private_keyshape).Documentation Impact
fleetbase/fleetbase.ioAPI Reference Impact
fleetbase/postmanAPI reference notes:
POST storefront/v1/customers/unregister-device(token).POST storefront/v1/customers/register-devicenow requirestokenandplatform/os(iosorandroid, case-insensitive), accepts an optionalenvironment(production/sandbox), and returns an error for invalid input instead of storing null rows.POST storefront/int/v1/notification-channels/{id}/test(token, optionalenvironment,title,body).Documentation Notes
fleetbase.io, Storefront → notification channels / push setup:
environment(auto / production / sandbox).android_channel_id; newSTOREFRONT_PUSH_ANDROID_CHANNEL_IDenv var.Risk
messageis now the readable body for every type (it used to be the status code for most). Existing keys are kept and new keys added.production: truenow retry sandbox after aBadDeviceToken: at most one extra request for tokens Apple rejects.status = invalid); re-registering the same token restores it.Screenshots / Recordings
Console: a new Send test button on notification channel rows (store settings → notifications, and the network page), which opens a modal showing the provider result; and an APNs environment select in the channel form. Not captured.